diff --git a/python/Bag1.py b/python/Bag1.py index 45a1478..bb4d959 100644 --- a/python/Bag1.py +++ b/python/Bag1.py @@ -9,16 +9,13 @@ Run: python Bag1.py import json import os -import select import socket import sys -import tempfile -import threading import time import tkinter as tk -from datetime import date, datetime, timedelta +from datetime import date, timedelta from tkinter import messagebox, ttk -from typing import Callable, Optional +from typing import Optional import requests @@ -27,28 +24,6 @@ try: except ImportError: serial = None # type: ignore -try: - import win32print # type: ignore[import] - import win32ui # type: ignore[import] - import win32con # type: ignore[import] - import win32gui # type: ignore[import] -except ImportError: - win32print = None # type: ignore[assignment] - win32ui = None # type: ignore[assignment] - win32con = None # type: ignore[assignment] - win32gui = None # type: ignore[assignment] - -try: - from PIL import Image, ImageDraw, ImageFont - import qrcode - _HAS_PIL_QR = True -except ImportError: - Image = None # type: ignore[assignment] - ImageDraw = None # type: ignore[assignment] - ImageFont = None # type: ignore[assignment] - qrcode = None # type: ignore[assignment] - _HAS_PIL_QR = False - 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): @@ -56,17 +31,15 @@ if getattr(sys, "frozen", False): else: _SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__)) SETTINGS_FILE = os.path.join(_SETTINGS_DIR, "bag1_settings.json") -LASER_COUNTER_FILE = os.path.join(_SETTINGS_DIR, "last_batch_count.txt") DEFAULT_SETTINGS = { "api_ip": "localhost", "api_port": "8090", "dabag_ip": "", "dabag_port": "3008", - "laser_ip": "192.168.17.10", - "laser_port": "45678", - # For 標簽機 on Windows, this is the Windows printer name, e.g. "TSC TTP-246M Pro" - "label_com": "TSC TTP-246M Pro", + "laser_ip": "", + "laser_port": "9100", + "label_com": "COM3", } @@ -110,7 +83,7 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: return False if printer_name == "激光機": ip = (sett.get("laser_ip") or "").strip() - port_str = (sett.get("laser_port") or "45678").strip() + port_str = (sett.get("laser_port") or "9100").strip() if not ip: return False try: @@ -121,26 +94,13 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: except (socket.error, ValueError, OSError): return False if printer_name == "標簽機": - target = (sett.get("label_com") or "").strip() - if not target: - return False - # On Windows, allow using a Windows printer name (e.g. "TSC TTP-246M Pro") - # as an alternative to a COM port. If it doesn't look like a COM port, - # try opening it via the Windows print spooler. - if os.name == "nt" and not target.upper().startswith("COM"): - if win32print is None: - return False - try: - handle = win32print.OpenPrinter(target) - win32print.ClosePrinter(handle) - return True - except Exception: - return False - # Fallback: treat as serial COM port (original behaviour) if serial is None: return False + com = (sett.get("label_com") or "").strip() + if not com: + return False try: - ser = serial.Serial(target, timeout=1) + ser = serial.Serial(com, timeout=1) ser.close() return True except (serial.SerialException, OSError): @@ -151,7 +111,8 @@ def try_printer_connection(printer_name: str, sett: dict) -> bool: 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_SIZE_ITEM_CODE = 20 # item code (larger for readability) +FONT_SIZE_ITEM_NAME = 26 # item name (bigger than item code) 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) @@ -174,6 +135,7 @@ PRINTER_CHECK_MS = 60 * 1000 # 1 minute when printer OK PRINTER_RETRY_MS = 30 * 1000 # 30 seconds when printer failed PRINTER_SOCKET_TIMEOUT = 3 DATAFLEX_SEND_TIMEOUT = 10 # seconds when sending ZPL to DataFlex +DATE_AUTO_RESET_SEC = 5 * 60 # 5 minutes: if no manual date change, auto-set to today def _zpl_escape(s: str) -> str: @@ -181,235 +143,60 @@ def _zpl_escape(s: str) -> str: return s.replace("\\", "\\\\").replace("^", "\\^") +def _split_by_word_count(text: str, max_words: int = 8) -> list[str]: + """Split text into segments of at most max_words (words = non-symbol chars; symbols not counted).""" + segments = [] + current = [] + count = 0 + for c in text: + if c.isalnum() or ("\u4e00" <= c <= "\u9fff") or ("\u3400" <= c <= "\u4dbf"): + count += 1 + current.append(c) + if count >= max_words: + segments.append("".join(current)) + current = [] + count = 0 + else: + current.append(c) + if current: + segments.append("".join(current)) + return segments if segments else [""] + + def generate_zpl_dataflex( batch_no: str, item_code: str, item_name: str, - item_id: Optional[int] = None, - stock_in_line_id: Optional[int] = None, lot_no: Optional[str] = None, font_regular: str = "E:STXihei.ttf", font_bold: str = "E:STXihei.ttf", ) -> str: """ - Generate ZPL for DataFlex label (53 mm media, 90° rotated). - QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else QA,{batch_no}. - Label shows lotNo when present, else batch_no. - """ - desc = _zpl_escape((item_name or "—").strip()) - code = _zpl_escape((item_code or "—").strip()) - label_line2 = (lot_no or batch_no or "—").strip() - batch = _zpl_escape(label_line2) - if item_id is not None and stock_in_line_id is not None: - qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) - else: - qr_data = f"QA,{batch_no}" - return f"""^XA -^PW420 -^LL780 -^PO N -^CI28 -^FO70,70 -^A@R,60,60,{font_regular}^FD{desc}^FS -^FO220,70 -^A@R,50,50,{font_bold}^FD{code}^FS -^FO310,70 -^A@R,45,45,{font_bold}^FD批次: {batch}^FS -^FO150,420 -^BQN,2,6^FD{qr_data}^FS -^XZ""" - - -def generate_zpl_label_small( - batch_no: str, - item_code: str, - item_name: str, - item_id: Optional[int] = None, - stock_in_line_id: Optional[int] = None, - lot_no: Optional[str] = None, - font: str = "MingLiUHKSCS", -) -> str: - """ - ZPL for 標簽機. Row 1: item name. Row 2: QR left | item code + lot no (or batch) right. - QR contains {"itemId": xxx, "stockInLineId": xxx} when both present; else batch_no. - Unicode (^CI28); font set for Big-5 (e.g. MingLiUHKSCS). + Row 1 (from zero): QR code, then item name (rotated 90°). + Row 2: Batch/lot (left), item code (right). + Label and QR use lotNo from API when present, else batch_no (Bxxxxx). """ desc = _zpl_escape((item_name or "—").strip()) code = _zpl_escape((item_code or "—").strip()) - label_line2 = (lot_no or batch_no or "—").strip() - label_line2_esc = _zpl_escape(label_line2) - if item_id is not None and stock_in_line_id is not None: - qr_data = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) - else: - qr_data = f"QA,{batch_no}" + label_line = (lot_no or batch_no or "").strip() + label_esc = _zpl_escape(label_line) + qr_value = label_line if label_line else batch_no.strip() return f"""^XA ^CI28 -^PW500 +^PW700 ^LL500 -^FO10,15 -^FB480,3,0,L,0 -^A@N,38,38,{font}^FD{desc}^FS -^FO10,110 -^BQN,2,6^FD{qr_data}^FS -^FO150,110 -^A@N,48,48,{font}^FD{code}^FS -^FO150,175 -^A@N,40,40,{font}^FD{label_line2_esc}^FS +^PO N +^FO10,20 +^BQR,4,7^FDQA,{qr_value}^FS +^FO170,20 +^A@R,72,72,{font_regular}^FD{desc}^FS +^FO0,290 +^A@R,72,72,{font_regular}^FD{label_esc}^FS +^FO75,290 +^A@R,88,88,{font_bold}^FD{code}^FS ^XZ""" -# Label image size (pixels) for 標簽機 image printing; words drawn bigger for readability -LABEL_IMAGE_W = 520 -LABEL_IMAGE_H = 520 -LABEL_PADDING = 20 -LABEL_FONT_NAME_SIZE = 38 # item name (bigger) -LABEL_FONT_CODE_SIZE = 44 # item code (bigger) -LABEL_FONT_BATCH_SIZE = 30 # batch/lot line -LABEL_QR_SIZE = 140 # QR module size in pixels - - -def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]: - """Return a Chinese-capable font for PIL, or None to use default.""" - if ImageFont is None: - return None - # Prefer Traditional Chinese fonts on Windows - for name in ("Microsoft JhengHei UI", "Microsoft JhengHei", "MingLiU", "SimHei", "Microsoft YaHei", "SimSun"): - try: - return ImageFont.truetype(name, size) - except (OSError, IOError): - continue - try: - return ImageFont.load_default() - except Exception: - return None - - -def render_label_to_image( - batch_no: str, - item_code: str, - item_name: str, - item_id: Optional[int] = None, - stock_in_line_id: Optional[int] = None, - lot_no: Optional[str] = None, -) -> "Image.Image": - """ - Render 標簽機 label as a PIL Image (white bg, black text + QR). - Use this image for printing so Chinese displays correctly; words are drawn bigger. - Requires Pillow and qrcode. Raises RuntimeError if not available. - """ - if not _HAS_PIL_QR or Image is None or qrcode is None: - raise RuntimeError("Pillow and qrcode are required for image labels. Run: pip install Pillow qrcode[pil]") - img = Image.new("RGB", (LABEL_IMAGE_W, LABEL_IMAGE_H), "white") - draw = ImageDraw.Draw(img) - # QR payload (same as ZPL) - if item_id is not None and stock_in_line_id is not None: - qr_data = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) - else: - qr_data = f"QA,{batch_no}" - # Draw QR top-left area - qr = qrcode.QRCode(box_size=4, border=2) - qr.add_data(qr_data) - qr.make(fit=True) - qr_img = qr.make_image(fill_color="black", back_color="white") - _resample = getattr(Image, "Resampling", Image).NEAREST - qr_img = qr_img.resize((LABEL_QR_SIZE, LABEL_QR_SIZE), _resample) - img.paste(qr_img, (LABEL_PADDING, LABEL_PADDING)) - # Fonts (bigger for readability) - font_name = _get_chinese_font(LABEL_FONT_NAME_SIZE) - font_code = _get_chinese_font(LABEL_FONT_CODE_SIZE) - font_batch = _get_chinese_font(LABEL_FONT_BATCH_SIZE) - x_right = LABEL_PADDING + LABEL_QR_SIZE + LABEL_PADDING - y_line = LABEL_PADDING - # Line 1: item name (wrap within remaining width) - name_str = (item_name or "—").strip() - max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING - if font_name: - # Wrap by text width (Pillow 8+ textbbox) or by char count - def _wrap_text(text: str, font, max_width: int) -> list: - if hasattr(draw, "textbbox"): - words = list(text) - lines, line = [], [] - for c in words: - line.append(c) - bbox = draw.textbbox((0, 0), "".join(line), font=font) - if bbox[2] - bbox[0] > max_width and len(line) > 1: - lines.append("".join(line[:-1])) - line = [line[-1]] - if line: - lines.append("".join(line)) - return lines - # Fallback: ~12 chars per line for Chinese - chunk = 12 - return [text[i : i + chunk] for i in range(0, len(text), chunk)] - lines = _wrap_text(name_str, font_name, max_name_w) - for i, ln in enumerate(lines): - draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black") - y_line += len(lines) * (LABEL_FONT_NAME_SIZE + 4) + 8 - else: - draw.text((x_right, y_line), name_str[:30], fill="black") - y_line += LABEL_FONT_NAME_SIZE + 12 - # Item code (bigger) - code_str = (item_code or "—").strip() - if font_code: - draw.text((x_right, y_line), code_str, font=font_code, fill="black") - else: - draw.text((x_right, y_line), code_str, fill="black") - y_line += LABEL_FONT_CODE_SIZE + 6 - # Batch/lot line - batch_str = (lot_no or batch_no or "—").strip() - if font_batch: - draw.text((x_right, y_line), batch_str, font=font_batch, fill="black") - else: - draw.text((x_right, y_line), batch_str, fill="black") - return img - - -def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None: - """ - Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly). - Only supported when target is a Windows printer name (not COM port). Requires pywin32. - """ - dest = (printer_name or "").strip() - if not dest: - raise ValueError("Label printer destination is empty.") - if os.name != "nt" or dest.upper().startswith("COM"): - raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).") - if win32print is None or win32ui is None or win32con is None or win32gui is None: - raise RuntimeError("pywin32 is required. Run: pip install pywin32") - # Draw image to printer DC via temp BMP (GDI uses BMP) - with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f: - tmp_bmp = f.name - try: - pil_image.save(tmp_bmp, "BMP") - hbm = win32gui.LoadImage( - 0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0, - win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION, - ) - if hbm == 0: - raise RuntimeError("Failed to load label image as bitmap.") - dc = win32ui.CreateDC() - dc.CreatePrinterDC(dest) - dc.StartDoc("FPSMS Label") - dc.StartPage() - try: - mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) - # PyCBitmap.FromHandle works across pywin32 versions - bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) - mem_dc.SelectObject(bmp) - bmp_w = pil_image.width - bmp_h = pil_image.height - dc.StretchBlt((0, 0), (bmp_w, bmp_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) - finally: - win32gui.DeleteObject(hbm) - dc.EndPage() - dc.EndDoc() - finally: - try: - os.unlink(tmp_bmp) - except OSError: - pass - - def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: """Send ZPL to DataFlex printer via TCP. Raises on connection/send error.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -421,206 +208,6 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: sock.close() -def send_zpl_to_label_printer(target: str, zpl: str) -> None: - """ - Send ZPL to 標簽機. - - On Windows, if target is not a COM port (e.g. "TSC TTP-246M Pro"), - send raw ZPL to the named Windows printer via the spooler. - Otherwise, treat target as a serial COM port (original behaviour). - """ - dest = (target or "").strip() - if not dest: - raise ValueError("Label printer destination is empty.") - - # Unicode (^CI28); send UTF-8 to 標簽機 - raw_bytes = zpl.encode("utf-8") - - # Windows printer name path (USB printer installed as normal printer) - if os.name == "nt" and not dest.upper().startswith("COM"): - if win32print is None: - raise RuntimeError("pywin32 not installed. Run: pip install pywin32") - handle = win32print.OpenPrinter(dest) - try: - job = win32print.StartDocPrinter(handle, 1, ("FPSMS Label", None, "RAW")) - win32print.StartPagePrinter(handle) - win32print.WritePrinter(handle, raw_bytes) - win32print.EndPagePrinter(handle) - win32print.EndDocPrinter(handle) - finally: - win32print.ClosePrinter(handle) - return - - # Fallback: serial COM port - if serial is None: - raise RuntimeError("pyserial not installed. Run: pip install pyserial") - ser = serial.Serial(dest, timeout=5) - try: - ser.write(raw_bytes) - finally: - ser.close() - - -def load_laser_last_count() -> tuple[int, Optional[str]]: - """Load last batch count and date from laser counter file. Returns (count, date_str).""" - if not os.path.exists(LASER_COUNTER_FILE): - return 0, None - try: - with open(LASER_COUNTER_FILE, "r", encoding="utf-8") as f: - lines = f.read().strip().splitlines() - if len(lines) >= 2: - return int(lines[1].strip()), lines[0].strip() - except Exception: - pass - return 0, None - - -def save_laser_last_count(date_str: str, count: int) -> None: - """Save laser batch count and date to file.""" - try: - with open(LASER_COUNTER_FILE, "w", encoding="utf-8") as f: - f.write(f"{date_str}\n{count}") - except Exception: - pass - - -LASER_PUSH_INTERVAL = 2 # seconds between pushes (like sample script) - - -def laser_push_loop( - ip: str, - port: int, - stop_event: threading.Event, - root: tk.Tk, - on_error: Callable[[str], None], -) -> None: - """ - Run in a background thread: persistent connection to EZCAD, push B{yymmdd}{count:03d};; - every LASER_PUSH_INTERVAL seconds. Resets count each new day. Uses counter file. - """ - conn = None - push_count, last_saved_date = load_laser_last_count() - while not stop_event.is_set(): - try: - if conn is None: - conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - conn.settimeout(0.4) - conn.connect((ip, port)) - now = datetime.now() - today_str = now.strftime("%y%m%d") - if last_saved_date != today_str: - push_count = 1 - last_saved_date = today_str - batch = f"B{today_str}{push_count:03d}" - reply = f"{batch};;" - conn.sendall(reply.encode("utf-8")) - save_laser_last_count(today_str, push_count) - rlist, _, _ = select.select([conn], [], [], 0.4) - if rlist: - data = conn.recv(4096) - if not data: - conn.close() - conn = None - push_count += 1 - for _ in range(int(LASER_PUSH_INTERVAL * 2)): - if stop_event.is_set(): - break - time.sleep(0.5) - except socket.timeout: - pass - except Exception as e: - if conn: - try: - conn.close() - except Exception: - pass - conn = None - try: - root.after(0, lambda msg=str(e): on_error(msg)) - except Exception: - pass - for _ in range(6): - if stop_event.is_set(): - break - time.sleep(0.5) - if conn: - try: - conn.close() - except Exception: - pass - - -def send_job_to_laser( - conn_ref: list, - ip: str, - port: int, - item_id: Optional[int], - stock_in_line_id: Optional[int], - item_code: str, - item_name: str, -) -> tuple[bool, str]: - """ - Send to laser. Standard format: {"itemId": xxx, "stockInLineId": xxx}. - conn_ref: [socket or None] - reused across calls; closed only when switching printer. - When both item_id and stock_in_line_id present, sends JSON; else fallback: 0;item_code;item_name;; - Returns (success, message). - """ - if item_id is not None and stock_in_line_id is not None: - reply = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) - else: - code_str = (item_code or "").strip().replace(";", ",") - name_str = (item_name or "").strip().replace(";", ",") - reply = f"0;{code_str};{name_str};;" - conn = conn_ref[0] - try: - if conn is None: - conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - conn.settimeout(3.0) - conn.connect((ip, port)) - conn_ref[0] = conn - conn.settimeout(3.0) - conn.sendall(reply.encode("utf-8")) - conn.settimeout(0.5) - try: - data = conn.recv(4096) - if data: - ack = data.decode("utf-8", errors="ignore").strip().lower() - if "receive" in ack and "invalid" not in ack: - return True, f"已送出激光機:{reply}(已確認)" - except socket.timeout: - pass - return True, f"已送出激光機:{reply}" - except (ConnectionRefusedError, socket.timeout, OSError) as e: - if conn_ref[0] is not None: - try: - conn_ref[0].close() - except Exception: - pass - conn_ref[0] = None - if isinstance(e, ConnectionRefusedError): - return False, f"無法連線至 {ip}:{port},請確認激光機已開機且 IP 正確。" - if isinstance(e, socket.timeout): - return False, f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。" - return False, f"激光機送出失敗:{e}" - - -def send_job_to_laser_with_retry( - conn_ref: list, - ip: str, - port: int, - item_id: Optional[int], - stock_in_line_id: Optional[int], - item_code: str, - item_name: str, -) -> tuple[bool, str]: - """Send job to laser; on failure, retry once. Returns (success, message).""" - ok, msg = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name) - if ok: - return True, msg - ok2, msg2 = send_job_to_laser(conn_ref, ip, port, item_id, stock_in_line_id, item_code, item_name) - return ok2, msg2 - - def format_qty(val) -> str: """Format quantity: integer without .0, with thousand separator.""" if val is None: @@ -635,8 +222,9 @@ def format_qty(val) -> str: 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}" + """Batch no.: B + 2-digit year + jobOrderId zero-padded to 6 digits.""" + short_year = year % 100 + return f"B{short_year:02d}{job_order_id:06d}" def get_font(size: int = FONT_SIZE, bold: bool = False) -> tuple: @@ -668,39 +256,33 @@ def set_row_highlight(row_frame: tk.Frame, selected: bool) -> None: 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}', - ) + """Row click handler (highlight already set; no popup).""" -def ask_laser_count(parent: tk.Tk) -> Optional[int]: +def ask_bag_count(parent: tk.Tk) -> Optional[int]: """ - When printer is 激光機, ask how many times to send (like DataFlex). + When printer is 打袋機 DataFlex, ask how many bags: +50, +10, +5, +1, C, then 確認送出. Returns count (>= 1), or -1 for continuous (C), or None if cancelled. """ - result: list = [None] + result: list[Optional[int]] = [None] count_ref = [0] continuous_ref = [False] win = tk.Toplevel(parent) - win.title("激光機送出數量") + win.title("打袋列印數量") win.geometry("420x200") win.transient(parent) win.grab_set() win.configure(bg=BG_TOP) - ttk.Label(win, text="送出多少次?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) - count_lbl = tk.Label(win, text="數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) + ttk.Label(win, text="列印多少個袋?", font=get_font(FONT_SIZE)).pack(pady=(12, 4)) + count_lbl = tk.Label(win, text="列印數量: 0", font=get_font(FONT_SIZE), bg=BG_TOP) count_lbl.pack(pady=4) def update_display(): if continuous_ref[0]: - count_lbl.configure(text="數量: 連續 (C)") + count_lbl.configure(text="列印數量: 連續 (C)") else: - count_lbl.configure(text=f"數量: {count_ref[0]}") + count_lbl.configure(text=f"列印數量: {count_ref[0]}") def add(n: int): continuous_ref[0] = False @@ -715,19 +297,20 @@ def ask_laser_count(parent: tk.Tk) -> Optional[int]: if continuous_ref[0]: result[0] = -1 elif count_ref[0] < 1: - messagebox.showwarning("激光機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) + messagebox.showwarning("打袋機", "請先按 +50、+10、+5 或 +1 選擇數量。", parent=win) return else: result[0] = count_ref[0] win.destroy() - btn_row = tk.Frame(win, bg=BG_TOP) - btn_row.pack(pady=8) + btn_row1 = tk.Frame(win, bg=BG_TOP) + btn_row1.pack(pady=8) for label, value in [("+50", 50), ("+10", 10), ("+5", 5), ("+1", 1)]: def make_add(v: int): return lambda: add(v) - ttk.Button(btn_row, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) - ttk.Button(btn_row, text="C", command=set_continuous, width=8).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_row1, text=label, command=make_add(value), width=8).pack(side=tk.LEFT, padx=4) + ttk.Button(btn_row1, text="C", command=set_continuous, width=8).pack(side=tk.LEFT, padx=4) + ttk.Button(win, text="確認送出", command=confirm, width=14).pack(pady=12) win.protocol("WM_DELETE_WINDOW", win.destroy) win.wait_window() @@ -804,8 +387,8 @@ def main() -> None: status_frame.configure(bg=BG_STATUS_ERROR) status_lbl.configure(text="連接不到服務器", bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) - def set_status_message(msg: str, is_error: bool = False) -> None: - """Show a message on the status bar.""" + def set_status_message(msg: str, is_error: bool = False): + """Show a temporary message on the status bar.""" if is_error: status_frame.configure(bg=BG_STATUS_ERROR) status_lbl.configure(text=msg, bg=BG_STATUS_ERROR, fg=FG_STATUS_ERROR) @@ -813,11 +396,6 @@ def main() -> None: status_frame.configure(bg=BG_STATUS_OK) status_lbl.configure(text=msg, bg=BG_STATUS_OK, fg=FG_STATUS_OK) - # Laser: keep connection open for repeated sends; close when switching away - laser_conn_ref: list = [None] - laser_thread_ref: list = [None] - laser_stop_ref: list = [None] - # Top: left [前一天] [date] [後一天] | right [printer dropdown] top = tk.Frame(root, padx=12, pady=12, bg=BG_TOP) top.pack(fill=tk.X) @@ -825,23 +403,31 @@ def main() -> None: date_var = tk.StringVar(value=date.today().isoformat()) printer_options = ["打袋機 DataFlex", "標簽機", "激光機"] printer_var = tk.StringVar(value=printer_options[0]) + last_manual_date_change_ref = [time.time()] # track when user last changed date manually + + def mark_manual_date_change(): + last_manual_date_change_ref[0] = time.time() def go_prev_day() -> None: try: d = date.fromisoformat(date_var.get().strip()) date_var.set((d - timedelta(days=1)).isoformat()) + mark_manual_date_change() load_job_orders(from_user_date_change=True) except ValueError: date_var.set(date.today().isoformat()) + mark_manual_date_change() 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()) + mark_manual_date_change() load_job_orders(from_user_date_change=True) except ValueError: date_var.set(date.today().isoformat()) + mark_manual_date_change() load_job_orders(from_user_date_change=True) # 前一天 (previous day) with left arrow icon @@ -858,6 +444,11 @@ def main() -> None: ) date_entry.pack(side=tk.LEFT, padx=(0, 8), ipady=4) + # Track manual typing in date field as user date change + def on_date_entry_key(event): + mark_manual_date_change() + date_entry.bind("", on_date_entry_key) + # 後一天 (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)) @@ -911,15 +502,6 @@ def main() -> None: def on_printer_selection_changed(*args) -> None: check_printer() - if printer_var.get() != "激光機": - if laser_stop_ref[0] is not None: - laser_stop_ref[0].set() - if laser_conn_ref[0] is not None: - try: - laser_conn_ref[0].close() - except Exception: - pass - laser_conn_ref[0] = None printer_var.trace_add("write", on_printer_selection_changed) @@ -951,17 +533,9 @@ def main() -> None: ) grid_row[0] += 1 if key_single: - ttk.Label( - f, - text="列印機名稱 (Windows):", - ).grid( - row=grid_row[0], - column=0, - sticky=tk.W, - pady=2, - ) + 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=22, font=get_font(FONT_SIZE), bg="white") + 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 @@ -988,7 +562,7 @@ def main() -> None: 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("標簽機 (USB)", None, None, "label_com")) + all_vars.extend(add_section("標簽機 COM 埠", None, None, "label_com")) def on_save(): for key, var in all_vars: @@ -1064,13 +638,15 @@ def main() -> None: found_row = None for jo in data: jo_id = jo.get("id") + # Use lotNo from API when present; fall back to generated Bxxxxx batch no. + raw_batch = batch_no(year, jo_id) if jo_id is not None else "—" lot_no_val = jo.get("lotNo") - batch = (lot_no_val or "—").strip() if lot_no_val else "—" + display_batch = (lot_no_val or raw_batch or "—").strip() 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) + # Three columns: lotNo/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) @@ -1078,7 +654,7 @@ def main() -> None: left.pack(side=tk.LEFT, anchor=tk.NW) batch_lbl = tk.Label( left, - text=batch, + text=display_batch, font=get_font(FONT_SIZE_BUTTONS), bg=BG_ROW, fg="black", @@ -1099,7 +675,7 @@ def main() -> None: code_lbl = tk.Label( row, text=item_code, - font=get_font(FONT_SIZE_ITEM), + font=get_font(FONT_SIZE_ITEM_CODE), bg=BG_ROW, fg="black", wraplength=ITEM_CODE_WRAP, @@ -1108,11 +684,11 @@ def main() -> None: ) 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 + # Column 3: item name only, 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), + font=get_font(FONT_SIZE_ITEM_NAME), bg=BG_ROW, fg="black", wraplength=ITEM_NAME_WRAP, @@ -1121,7 +697,7 @@ def main() -> None: ) name_lbl.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, anchor=tk.NW) - def _on_click(e, j=jo, b=batch, r=row): + def _on_click(e, j=jo, b=display_batch, r=row): if selected_row_holder[0] is not None: set_row_highlight(selected_row_holder[0], False) set_row_highlight(r, True) @@ -1137,97 +713,35 @@ def main() -> None: if not ip: messagebox.showerror("打袋機", "請在設定中填寫打袋機 DataFlex 的 IP。") else: - item_code = j.get("itemCode") or "—" - item_name = j.get("itemName") or "—" - item_id = j.get("itemId") - stock_in_line_id = j.get("stockInLineId") - lot_no = j.get("lotNo") - zpl = generate_zpl_dataflex( - b, item_code, item_name, - item_id=item_id, stock_in_line_id=stock_in_line_id, - lot_no=lot_no, - ) - try: - send_zpl_to_dataflex(ip, port, zpl) - messagebox.showinfo("打袋機", f"已送出列印:批次 {b}") - except ConnectionRefusedError: - messagebox.showerror("打袋機", f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。") - except socket.timeout: - messagebox.showerror("打袋機", f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。") - except OSError as err: - messagebox.showerror("打袋機", f"列印失敗:{err}") - elif printer_var.get() == "標簽機": - com = (settings.get("label_com") or "").strip() - if not com: - messagebox.showerror("標簽機", "請在設定中填寫標簽機名稱 (例如:TSC TTP-246M Pro)。") - else: - count = ask_label_count(root) + count = ask_bag_count(root) if count is not None: item_code = j.get("itemCode") or "—" item_name = j.get("itemName") or "—" - item_id = j.get("itemId") - stock_in_line_id = j.get("stockInLineId") lot_no = j.get("lotNo") - n = 100 if count == "C" else int(count) - try: - # Prefer image printing so Chinese displays correctly; words are bigger - if _HAS_PIL_QR and os.name == "nt" and not com.upper().startswith("COM"): - label_img = render_label_to_image( - b, item_code, item_name, - item_id=item_id, stock_in_line_id=stock_in_line_id, - lot_no=lot_no, - ) - for i in range(n): - send_image_to_label_printer(com, label_img) - if i < n - 1: - time.sleep(0.5) - else: - zpl = generate_zpl_label_small( - b, item_code, item_name, - item_id=item_id, stock_in_line_id=stock_in_line_id, - lot_no=lot_no, - ) - for i in range(n): - send_zpl_to_label_printer(com, zpl) - if i < n - 1: - time.sleep(0.5) - msg = f"已送出列印:{n} 張標簽" if count != "C" else f"已送出列印:{n} 張標簽 (連續)" - messagebox.showinfo("標簽機", msg) - except Exception as err: - messagebox.showerror("標簽機", f"列印失敗:{err}") - elif printer_var.get() == "激光機": - ip = (settings.get("laser_ip") or "").strip() - port_str = (settings.get("laser_port") or "45678").strip() - try: - port = int(port_str) - except ValueError: - port = 45678 - if not ip: - set_status_message("請在設定中填寫激光機的 IP。", is_error=True) - else: - count = ask_laser_count(root) - if count is not None: - item_id = j.get("itemId") - stock_in_line_id = j.get("stockInLineId") - item_code_val = j.get("itemCode") or "" - item_name_val = j.get("itemName") or "" + zpl = generate_zpl_dataflex(b, item_code, item_name, lot_no=lot_no) n = 100 if count == -1 else count - sent = 0 - for i in range(n): - ok, msg = send_job_to_laser_with_retry( - laser_conn_ref, ip, port, - item_id, stock_in_line_id, - item_code_val, item_name_val, - ) - if ok: - sent += 1 - else: - set_status_message(f"已送出 {sent} 次,第 {sent + 1} 次失敗:{msg}", is_error=True) - break - if i < n - 1: - time.sleep(0.2) - if sent == n: - set_status_message(f"已送出激光機:{sent} 次", is_error=False) + label_text = (lot_no or b).strip() + try: + for i in range(n): + send_zpl_to_dataflex(ip, port, zpl) + if i < n - 1: + time.sleep(2) + msg = f"已送出列印:批次 {label_text} x {n} 張" if count != -1 else f"已送出列印:批次 {label_text} x {n} 張 (連續)" + set_status_message(msg, is_error=False) + except ConnectionRefusedError: + set_status_message(f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", is_error=True) + except socket.timeout: + set_status_message(f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", is_error=True) + except OSError as err: + set_status_message(f"列印失敗:{err}", is_error=True) + elif printer_var.get() == "標簽機": + count = ask_label_count(root) + if count is not None: + if count == "C": + msg = "已選擇連續列印標簽" + else: + msg = f"將列印 {count} 張標簽" + set_status_message(msg, is_error=False) on_job_order_click(j, b) for w in (row, left, batch_lbl, code_lbl, name_lbl): @@ -1250,6 +764,13 @@ def main() -> None: if after_id_ref[0] is not None: root.after_cancel(after_id_ref[0]) after_id_ref[0] = None + # Auto-reset date to today if user hasn't manually changed it recently (for 24x7 use) + if not from_user_date_change: + elapsed = time.time() - last_manual_date_change_ref[0] + today_str = date.today().isoformat() + if elapsed > DATE_AUTO_RESET_SEC and date_var.get().strip() != today_str: + date_var.set(today_str) + from_user_date_change = True # treat as date change to reset selection/scroll date_str = date_var.get().strip() try: plan_start = date.fromisoformat(date_str) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 69335e9..ba19169 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -462,18 +462,17 @@ open class M18DeliveryOrderService( } // End of save. Check result -// logger.info("Total Success (${doRefType}) (${successList.size}): $successList") logger.info("Total Success (${doRefType}) (${successList.size})") -// if (failList.size > 0) { logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") -// } -// logger.info("Total Success (${doLineRefType}) (${successDetailList.size}): $successDetailList") logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") -// if (failDetailList.size > 0) { logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") -// logger.error("Total Fail M18 Items (${doLineRefType}) (${failItemDetailList.distinct().size}): ${failItemDetailList.distinct()}") -// } + + val feeMarked = deliveryOrderLineService.markDeletedLinesWithFeeItems() + if (feeMarked > 0) { + logger.info("Marked $feeMarked DO line(s) as deleted (isFee items).") + } + logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") return SyncResult( diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderLineRepository.kt index 2ebac47..be8679c 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderLineRepository.kt @@ -1,10 +1,17 @@ package com.ffii.fpsms.modules.deliveryOrder.entity import com.ffii.core.support.AbstractRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import java.io.Serializable @Repository interface DeliveryOrderLineRepository : AbstractRepository { fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): DeliveryOrderLine? + + @Query( + "SELECT dol FROM DeliveryOrderLine dol " + + "WHERE dol.deleted = false AND dol.item IS NOT NULL AND dol.item.isFee = true" + ) + fun findAllByDeletedIsFalseAndItemIsFeeTrue(): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderLineService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderLineService.kt index aa6167e..8f2b8bc 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderLineService.kt @@ -68,4 +68,13 @@ open class DeliveryOrderLineService( return savedDeliveryOrderLine } + + open fun markDeletedLinesWithFeeItems(): Int { + val feeLines = deliveryOrderLineRepository.findAllByDeletedIsFalseAndItemIsFeeTrue() + feeLines.forEach { line -> + line.deleted = true + deliveryOrderLineRepository.saveAndFlush(line) + } + return feeLines.size + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 27f971b..5bbb869 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse +import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus import com.ffii.fpsms.modules.stock.entity.StockInLineRepository @@ -1960,6 +1961,23 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) } + + // stockouts 可能包含不在 suggestedPickLots 內的 inventoryLotLineId,需補齊以便計算 location/availableQty + val stockOutInventoryLotLineIds = stockOutLinesByPickOrderLine.values + .flatten() + .mapNotNull { it.inventoryLotLineId } + .distinct() + + val stockOutInventoryLotLines = if (stockOutInventoryLotLineIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByIdIn(stockOutInventoryLotLineIds) + .filter { it.deleted == false } + } else { + emptyList() + } + + val inventoryLotLineById = (inventoryLotLines + stockOutInventoryLotLines) + .filter { it.id != null } + .associateBy { it.id!! } // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) { @@ -2080,6 +2098,32 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo matchQty = jpo?.matchQty?.toDouble() ) } + + // 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0) + val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> + val illId = sol.inventoryLotLineId + val ill = if (illId != null) inventoryLotLineById[illId] else null + val lot = ill?.inventoryLot + val warehouse = ill?.warehouse + val availableQty = if (sol.status == "rejected") { + null + } else if (ill == null || ill.deleted == true) { + null + } else { + (ill.inQty ?: BigDecimal.ZERO) - (ill.outQty ?: BigDecimal.ZERO) - (ill.holdQty ?: BigDecimal.ZERO) + } + + StockOutLineDetailResponse( + id = sol.id, + status = sol.status, + qty = sol.qty.toDouble(), + lotId = illId, + lotNo = sol.lotNo ?: lot?.lotNo, + location = warehouse?.code, + availableQty = availableQty?.toDouble(), + noLot = (illId == null) + ) + } PickOrderLineWithLotsResponse( id = pol.id!!, @@ -2091,6 +2135,7 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo uomDesc = uom?.udfudesc, status = pol.status?.value, lots = lots, + stockouts = stockouts, handler=handlerName ) } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt index d2f4a42..87e58a9 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt @@ -96,9 +96,25 @@ data class PickOrderLineWithLotsResponse( val uomDesc: String?, val status: String?, val lots: List, + val stockouts: List = emptyList(), val handler: String? ) +/** + * Stock-out line rows that should be shown even when there is no suggested lot. + * `noLot=true` indicates this line currently has no lot assigned / insufficient inventory lot. + */ +data class StockOutLineDetailResponse( + val id: Long?, + val status: String?, + val qty: Double?, + val lotId: Long?, + val lotNo: String?, + val location: String?, + val availableQty: Double?, + val noLot: Boolean +) + data class LotDetailResponse( val lotId: Long?, val lotNo: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index 88ab492..2eb5b77 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -92,10 +92,18 @@ open class PickExecutionIssueService( println(" issueCategory: ${request.issueCategory}") println("========================================") - // 1. 检查是否已经存在相同的 pick execution issue 记录 + // 1. 解析 lot: + // request.lotId 在前端目前传的是 inventory_lot_line.id(用于 SOL 关联/计算 bookQty 等) + // 但 pick_execution_issue.lot_id 在 DB 上外键指向 inventory_lot.id + val inventoryLotLine = request.lotId?.let { + inventoryLotLineRepository.findById(it).orElse(null) + } + val inventoryLotIdForIssue = inventoryLotLine?.inventoryLot?.id + + // 2. 检查是否已经存在相同的 pick execution issue 记录(以 inventory_lot.id 去重) val existingIssues = pickExecutionIssueRepository.findByPickOrderLineIdAndLotIdAndDeletedFalse( - request.pickOrderLineId, - request.lotId ?: 0L + request.pickOrderLineId, + inventoryLotIdForIssue ?: 0L ) println("Checking for existing issues...") @@ -119,12 +127,8 @@ open class PickExecutionIssueService( val pickOrder = pickOrderRepository.findById(request.pickOrderId).orElse(null) println("Pick order: id=${pickOrder?.id}, code=${pickOrder?.code}, type=${pickOrder?.type?.value}") - // 2. 获取 inventory_lot_line 并计算账面数量 (bookQty) - val inventoryLotLine = request.lotId?.let { - inventoryLotLineRepository.findById(it).orElse(null) - } - - println("Inventory lot line: id=${inventoryLotLine?.id}, lotNo=${inventoryLotLine?.inventoryLot?.lotNo}") + // 3. 计算账面数量 (bookQty)(用 inventory_lot_line 快照) + println("Inventory lot line: id=${inventoryLotLine?.id}, lotNo=${inventoryLotLine?.inventoryLot?.lotNo}, inventoryLotId=${inventoryLotIdForIssue}") // 计算账面数量(创建 issue 时的快照) val bookQty = if (inventoryLotLine != null) { @@ -138,13 +142,47 @@ open class PickExecutionIssueService( BigDecimal.ZERO } - // 3. 获取数量值 + // 4. 获取数量值 val requiredQty = request.requiredQty ?: BigDecimal.ZERO val actualPickQty = request.actualPickQty ?: BigDecimal.ZERO val missQty = request.missQty ?: BigDecimal.ZERO val badItemQty = request.badItemQty ?: BigDecimal.ZERO val badReason = request.badReason ?: "quantity_problem" - + val relatedStockOutLines = stockOutLineRepository + .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( + request.pickOrderLineId, + request.lotId ?: 0L + ) + + val currentStatus = relatedStockOutLines.firstOrNull()?.status ?: "" + + if (currentStatus.equals("pending", ignoreCase = true) + && actualPickQty > BigDecimal.ZERO + && missQty == BigDecimal.ZERO + && badItemQty == BigDecimal.ZERO + ) { + return MessageResponse( + id = null, + name = "Invalid issue for pending stock out line", + code = "ERROR", + type = "pick_execution_issue", + message = "Cannot submit only actual pick qty when stock out line is pending. Please rescan the lot or use normal pick flow.", + errorPosition = null + ) + } + val lotRemainAvailable = bookQty // 当前 lot 剩余 + val maxAllowed = requiredQty + lotRemainAvailable + + if (actualPickQty > maxAllowed) { + return MessageResponse( + id = null, + name = "Actual pick qty too large", + code = "ERROR", + type = "pick_execution_issue", + message = "Actual pick qty cannot exceed required qty plus lot remaining available.", + errorPosition = null + ) + } println("=== Quantity Summary ===") println(" Required Qty: $requiredQty") println(" Actual Pick Qty: $actualPickQty") @@ -153,7 +191,7 @@ open class PickExecutionIssueService( println(" Bad Reason: $badReason") println(" Book Qty: $bookQty") - // 4. 计算 issueQty(实际的问题数量) + // 5. 计算 issueQty(实际的问题数量) val issueQty = when { // Bad item 或 bad package:一律用用户输入的 bad 数量,不用 bookQty - actualPickQty badItemQty > BigDecimal.ZERO -> { @@ -179,10 +217,12 @@ open class PickExecutionIssueService( println("=== Final IssueQty Calculation ===") println(" Calculated IssueQty: $issueQty") println("================================================") - - // 5. 创建 pick execution issue 记录 + println("=== Processing Logic Selection ===") + + // 6. 创建 pick execution issue 记录 val issueNo = generateIssueNo() println("Generated issue number: $issueNo") + val lotNoForIssue = request.lotNo ?: inventoryLotLine?.inventoryLot?.lotNo val pickExecutionIssue = PickExecutionIssue( id = null, @@ -200,8 +240,8 @@ open class PickExecutionIssueService( itemId = request.itemId, itemCode = request.itemCode, itemDescription = request.itemDescription, - lotId = request.lotId, - lotNo = request.lotNo, + lotId = inventoryLotIdForIssue, + lotNo = lotNoForIssue, storeLocation = request.storeLocation, requiredQty = request.requiredQty, actualPickQty = request.actualPickQty, @@ -230,7 +270,7 @@ open class PickExecutionIssueService( println(" Handle Status: ${savedIssue.handleStatus}") println(" Issue Qty: ${savedIssue.issueQty}") - // 6. NEW: Update inventory_lot_line.issueQty + // 7. NEW: Update inventory_lot_line.issueQty(仍然用 lotLineId) if (request.lotId != null && inventoryLotLine != null) { println("Updating inventory_lot_line.issueQty...") // ✅ 修改:如果只有 missQty,不更新 issueQty @@ -270,95 +310,19 @@ open class PickExecutionIssueService( } } - // 7. 获取相关数据用于后续处理 - val actualPickQtyForProcessing = request.actualPickQty ?: BigDecimal.ZERO - val missQtyForProcessing = request.missQty ?: BigDecimal.ZERO - val badItemQtyForProcessing = request.badItemQty ?: BigDecimal.ZERO - val lotId = request.lotId - val itemId = request.itemId - - println("=== Processing Logic Selection ===") - println("Actual Pick Qty: $actualPickQtyForProcessing") - println("Miss Qty: $missQtyForProcessing") - println("Bad Item Qty: $badItemQtyForProcessing") - println("Bad Reason: ${request.badReason}") - println("Lot ID: $lotId") - println("Item ID: $itemId") - println("================================================") - - // 8. 新的统一处理逻辑(根据 badReason 决定处理方式) - when { - // 情况1: 只有 miss item (actualPickQty = 0, missQty > 0, badItemQty = 0) - actualPickQtyForProcessing == BigDecimal.ZERO && missQtyForProcessing > BigDecimal.ZERO && badItemQtyForProcessing == BigDecimal.ZERO -> { - println("→ Handling: Miss Item Only") - handleMissItemOnly(request, missQtyForProcessing) - } - - // 情况2: 只有 bad item (badItemQty > 0, missQty = 0) - badItemQtyForProcessing > BigDecimal.ZERO && missQtyForProcessing == BigDecimal.ZERO -> { - println("→ Handling: Bad Item Only") - // NEW: Check bad reason - if (request.badReason == "package_problem") { - println(" Bad reason is 'package_problem' - calling handleBadItemPackageProblem") - handleBadItemPackageProblem(request, badItemQtyForProcessing) - } else { - println(" Bad reason is 'quantity_problem' - calling handleBadItemOnly") - // quantity_problem or default: handle as normal bad item - handleBadItemOnly(request, badItemQtyForProcessing) - } - } - - // 情况3: 既有 miss item 又有 bad item - missQtyForProcessing > BigDecimal.ZERO && badItemQtyForProcessing > BigDecimal.ZERO -> { - println("→ Handling: Both Miss and Bad Item") - // NEW: Check bad reason - if (request.badReason == "package_problem") { - println(" Bad reason is 'package_problem' - calling handleBothMissAndBadItemPackageProblem") - handleBothMissAndBadItemPackageProblem(request, missQtyForProcessing, badItemQtyForProcessing) - } else { - println(" Bad reason is 'quantity_problem' - calling handleBothMissAndBadItem") - handleBothMissAndBadItem(request, missQtyForProcessing, badItemQtyForProcessing) - } - } - - // 情况4: 有 miss item 的情况(无论 actualPickQty 是多少) - missQtyForProcessing > BigDecimal.ZERO -> { - println("→ Handling: Miss Item With Partial Pick") - handleMissItemWithPartialPick(request, actualPickQtyForProcessing, missQtyForProcessing) - } - - // 情况5: 正常拣货 (actualPickQty > 0, 没有 miss 或 bad item) - actualPickQtyForProcessing > BigDecimal.ZERO -> { - println("→ Handling: Normal Pick") - handleNormalPick(request, actualPickQtyForProcessing) - } - - else -> { - println("⚠️ Unknown case: actualPickQty=$actualPickQtyForProcessing, missQty=$missQtyForProcessing, badItemQty=$badItemQtyForProcessing") - } - } - - val pickOrderForCompletion = pickOrderRepository.findById(request.pickOrderId).orElse(null) - val consoCode = pickOrderForCompletion?.consoCode - - if (!consoCode.isNullOrBlank()) { - // 优先走原来的 consoCode 逻辑(兼容已有 DO 流程) - println("🔍 Checking if pick order $consoCode should be completed after lot rejection...") - try { - checkAndCompletePickOrder(consoCode) - } catch (e: Exception) { - println("⚠️ Error checking pick order completion by consoCode: ${e.message}") - } - } else if (pickOrderForCompletion != null) { - // 🔁 没有 consoCode 的情况:改用 pickOrderId 去检查是否可以完结 - println("🔍 Checking if pick order ${pickOrderForCompletion.code} (ID=${pickOrderForCompletion.id}) " + - "should be completed after lot rejection (no consoCode)...") - try { - checkAndCompletePickOrderByPickOrderId(pickOrderForCompletion.id!!) - } catch (e: Exception) { - println("⚠️ Error checking pick order completion by pickOrderId: ${e.message}") - } + // 7. 按规则:issue form 只记录问题 +(可选)把 SOL 标记为 checked + val stockOutLines = stockOutLineRepository + .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( + request.pickOrderLineId, + request.lotId ?: 0L + ) + stockOutLines.forEach { sol -> + sol.status = "checked" + sol.modified = LocalDateTime.now() + sol.modifiedBy = "system" + stockOutLineRepository.save(sol) } + stockOutLineRepository.flush() println("=== recordPickExecutionIssue: SUCCESS ===") println("Issue ID: ${savedIssue.id}, Issue No: ${savedIssue.issueNo}") @@ -385,6 +349,23 @@ open class PickExecutionIssueService( ) } } + private fun handleAllZeroMarkCompleted(request: PickExecutionIssueRequest) { + val stockOutLines = stockOutLineRepository + .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( + request.pickOrderLineId, + request.lotId ?: 0L + ) + + stockOutLines.forEach { sol -> + // issue form 不完结,只标记 checked,让 submit/batch submit 决定 completed(允许 0) + sol.status = "checked" + sol.modified = LocalDateTime.now() + sol.modifiedBy = "system" + stockOutLineRepository.save(sol) + println("All-zero case: mark stock out line ${sol.id} as checked (qty kept as ${sol.qty})") + } + stockOutLineRepository.flush() + } private fun generateIssueNo(): String { val now = LocalDateTime.now() val yearMonth = now.format(java.time.format.DateTimeFormatter.ofPattern("yyMM")) @@ -717,34 +698,18 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac // ✅ 修改:不更新 unavailableQty(因为不 reject lot) - // ✅ 修改:不 reject stock_out_line,根据 actualPickQty 设置状态 + // ✅ 按规则:issue form 不负责完结/数量提交,只记录问题 +(可选)把 SOL 标记为 checked val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( request.pickOrderLineId, request.lotId ?: 0L ) stockOutLines.forEach { stockOutLine -> - val requiredQty = request.requiredQty?.toDouble() ?: 0.0 - val actualPickQtyDouble = actualPickQty.toDouble() - - // 设置状态:如果 actualPickQty >= requiredQty,则为 completed,否则为 partially_completed - val newStatus = if (actualPickQtyDouble >= requiredQty) { - "completed" - } else { - "partially_completed" - } - - stockOutLine.status = newStatus - stockOutLine.qty = actualPickQtyDouble + stockOutLine.status = "checked" stockOutLine.modified = LocalDateTime.now() stockOutLine.modifiedBy = "system" - val savedStockOutLine = stockOutLineRepository.saveAndFlush(stockOutLine) - - println("Updated stock out line ${stockOutLine.id} status to: ${newStatus} (NOT rejected)") - - // ✅ 修复:使用更新前的 onHandQty 计算 balance - val balance = onHandQtyBeforeUpdate - actualPickQtyDouble - createStockLedgerForStockOut(savedStockOutLine, "Nor", balance) + stockOutLineRepository.saveAndFlush(stockOutLine) + println("Issue form: marked stock out line ${stockOutLine.id} as checked (no completion)") } // ✅ 修复:检查 pick order line 是否应该标记为完成 diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index 971f2fa..70f86df 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -21,8 +21,10 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroup import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository import com.ffii.fpsms.modules.pickOrder.web.models.* +import com.ffii.fpsms.modules.stock.entity.InventoryLotLine import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository import com.ffii.fpsms.modules.stock.entity.StockOut +import com.ffii.fpsms.modules.stock.entity.StockOutLine import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.entity.StockOutRepository import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus @@ -1458,7 +1460,7 @@ open class PickOrderService( println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") println("consoCode: $consoCode") - val stockOut = stockOutRepository.findByConsoPickOrderCode(consoCode).orElse(null) + val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) if (stockOut == null) { println("❌ No stock_out found for consoCode: $consoCode") return MessageResponse( @@ -3357,286 +3359,7 @@ ORDER BY val enrichedResults = filteredResults return enrichedResults } - // 修改后的逻辑 - /* - open fun getAllPickOrderLotsWithDetailsHierarchicalold(userId: Long): Map { - println("=== Debug: getAllPickOrderLotsWithDetailsHierarchical (Repository-based) ===") - println("userId filter: $userId") - - val user = userService.find(userId).orElse(null) - if (user == null) { - println("❌ User not found: $userId") - return emptyMap() - } - - // Step 1:直接按 handledBy 查当前用户的活动 do_pick_order(一个 ticket) - val activeTicketStatuses = listOf("released", "picking") // 如果你用的是 DoPickOrderStatus 枚举,也可以改成 List - val doPickOrder = doPickOrderRepository - .findFirstByHandledByAndDeletedFalseAndTicketStatusIn(user.id!!, activeTicketStatuses) - - if (doPickOrder == null) { - println("❌ No active do_pick_order found for handledBy user $userId") - return mapOf( - "fgInfo" to null, - "pickOrders" to emptyList() - ) - } - - val doPickOrderId = doPickOrder.id!! - println(" Using do_pick_order ID (by handledBy): $doPickOrderId") - - // Step 2:用这个 do_pick_orderId 查对应的 do_pick_order_line / pick_order - val allDoPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(doPickOrderId) - val allPickOrderIdsForThisTicket = allDoPickOrderLines.mapNotNull { it.pickOrderId }.distinct() - - println(" Found ${allPickOrderIdsForThisTicket.size} pick orders in this do_pick_order (including completed)") - - // Step 3:加载这些 pick orders(包括 COMPLETED) - val pickOrders = pickOrderRepository.findAllById(allPickOrderIdsForThisTicket) - .filter { - it.deleted == false && - it.assignTo?.id == userId && - it.type?.value == "do" - } - - println(" Loaded ${pickOrders.size} pick orders (including completed)") - - // Step 4:原来你从 3413 行开始的收集所有 line / lots 的逻辑,全部保留 - val allPickOrderLineIds = pickOrders.flatMap { it.pickOrderLines }.mapNotNull { it.id } - - val allSuggestions = suggestPickLotRepository.findAllByPickOrderLineIdIn(allPickOrderLineIds) - val allStockOutLines = allPickOrderLineIds.flatMap { lineId -> - stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(lineId) - } - - val suggestionsByLineId = allSuggestions.groupBy { spl: SuggestedPickLot -> - spl.pickOrderLine?.id - } - val stockOutLinesByLineId = allStockOutLines.groupBy { sol: StockOutLineInfo -> - sol.pickOrderLineId - } - - val allPickOrderLines = mutableListOf>() - val lineCountsPerPickOrder = mutableListOf() - val pickOrderIdsList = mutableListOf() - val pickOrderCodesList = mutableListOf() - val doOrderIdsList = mutableListOf() - val deliveryOrderCodesList = mutableListOf() - - pickOrders.forEach { po -> - pickOrderIdsList.add(po.id!!) - pickOrderCodesList.add(po.code ?: "") - - val doOrderId = po.deliveryOrder?.id - if (doOrderId != null) doOrderIdsList.add(doOrderId) - deliveryOrderCodesList.add(po.deliveryOrder?.code ?: "") - - val lines = po.pickOrderLines.filter { !it.deleted } - - val lineDtos = po.pickOrderLines - .filter { !it.deleted } - .map { pol -> - val lineId = pol.id - val item = pol.item - val uom = pol.uom - - // 获取该 line 的 suggestions 和 stock out lines - val suggestions = lineId?.let { suggestionsByLineId[it] } ?: emptyList() - val stockOutLines = lineId?.let { stockOutLinesByLineId[it] } ?: emptyList() - - // 构建 lots(合并相同 lot 的多个 suggestions) - val lotMap = mutableMapOf() - - suggestions.forEach { spl -> - val ill = spl.suggestedLotLine - if (ill != null && ill.id != null) { - val illId = ill.id!! - val illEntity = inventoryLotLinesMap[illId] ?: ill - val il = illEntity.inventoryLot - val w = illEntity.warehouse - val isExpired = il?.expiryDate?.let { exp -> exp.isBefore(today) } == true - - val availableQty = (illEntity.inQty ?: zero) - .minus(illEntity.outQty ?: zero) - .minus(illEntity.holdQty ?: zero) - - // 查找对应的 stock out line - val stockOutLine = stockOutLines.find { sol -> - sol.inventoryLotLineId == illId - } - - // 计算 actualPickQty - val actualPickQty = stockOutLine?.qty?.let { numToBigDecimal(it as? Number) } - - if (lotMap.containsKey(illId)) { - // 合并 requiredQty - val existing = lotMap[illId]!! - val newRequiredQty = (existing.requiredQty ?: zero).plus(spl.qty ?: zero) - lotMap[illId] = existing.copy(requiredQty = newRequiredQty) - } else { - lotMap[illId] = LotDetailResponse( - id = illId, - lotNo = il?.lotNo, - expiryDate = il?.expiryDate, - location = w?.code, - stockUnit = illEntity.stockUom?.uom?.udfudesc ?: uom?.udfudesc ?: "N/A", - availableQty = availableQty, - requiredQty = spl.qty, - actualPickQty = actualPickQty, - inQty = illEntity.inQty, - outQty = illEntity.outQty, - holdQty = illEntity.holdQty, - lotStatus = illEntity.status?.value, - lotAvailability = when { - isExpired -> "expired" - stockOutLine?.status == "rejected" -> "rejected" - availableQty <= zero -> "insufficient_stock" - illEntity.status?.value == "unavailable" -> "status_unavailable" - else -> "available" - }, - processingStatus = when { - stockOutLine?.status == "completed" -> "completed" - stockOutLine?.status == "rejected" -> "rejected" - else -> "pending" - }, - suggestedPickLotId = spl.id, - stockOutLineId = stockOutLine?.id, - stockOutLineStatus = stockOutLine?.status, - stockOutLineQty = stockOutLine?.qty?.let { numToBigDecimal(it as? Number) }, - router = RouterInfoResponse( - id = null, - index = w?.order.toString(), - route = w?.code, - area = w?.code - ) - ) - } - } - } - - val lots = lotMap.values.toList() - - // 构建 stockouts(包括没有 lot 的) - val stockouts = stockOutLines.map { sol -> - val illId = sol.inventoryLotLineId - val ill = illId?.let { inventoryLotLinesMap[it] } - val il = ill?.inventoryLot - val w = ill?.warehouse - val available = if (ill == null) null else - (ill.inQty ?: zero) - .minus(ill.outQty ?: zero) - .minus(ill.holdQty ?: zero) - - StockOutDetailResponse( - id = sol.id, - status = sol.status, - qty = sol.qty?.let { numToBigDecimal(it as? Number) }, - lotId = ill?.id, - lotNo = il?.lotNo ?: "", - location = w?.code ?: "", - availableQty = available, - noLot = (ill == null) - ) - } - - PickOrderLineDetailResponse( - id = lineId, - requiredQty = pol.qty, - status = pol.status?.value, - item = ItemInfoResponse( - id = item?.id, - code = item?.code, - name = item?.name, - uomCode = uom?.code, - uomDesc = uom?.udfudesc, - uomShortDesc = uom?.udfShortDesc - ), - lots = lots, - stockouts = stockouts - ) - } - - - lineCountsPerPickOrder.add(lineDtos.size) - allPickOrderLines.addAll(lineDtos) - } - - // 排序、fgInfo、mergedPickOrder 这些也全部沿用你当前代码,只要用上面定义好的 doPickOrder/doPickOrderId 即可: - allPickOrderLines.sortWith(compareBy( - { line -> - val lots = line["lots"] as? List> - val firstLot = lots?.firstOrNull() - val router = firstLot?.get("router") as? Map - val indexValue = router?.get("index") - val floorSortValue = when (indexValue) { - is String -> { - val parts = indexValue.split("-") - if (parts.isNotEmpty()) { - val floorPart = parts[0].uppercase() - when (floorPart) { - "1F" -> 1 - "2F", "4F" -> 2 - else -> 3 - } - } else 3 - } - else -> 3 - } - floorSortValue - }, - { line -> - val lots = line["lots"] as? List> - val firstLot = lots?.firstOrNull() - val router = firstLot?.get("router") as? Map - val indexValue = router?.get("index") - when (indexValue) { - is Number -> indexValue.toInt() - is String -> { - val parts = indexValue.split("-") - if (parts.size > 1) { - parts.last().toIntOrNull() ?: 999999 - } else { - indexValue.toIntOrNull() ?: 999999 - } - } - else -> 999999 - } - } - )) - - val fgInfo = mapOf( - "doPickOrderId" to doPickOrderId, - "ticketNo" to doPickOrder.ticketNo, - "storeId" to doPickOrder.storeId, - "shopCode" to doPickOrder.shopCode, - "shopName" to doPickOrder.shopName, - "truckLanceCode" to doPickOrder.truckLanceCode, - "departureTime" to doPickOrder.truckDepartureTime?.toString() - ) - - val mergedPickOrder = if (pickOrders.isNotEmpty()) { - val firstPickOrder = pickOrders.first() - mapOf( - "pickOrderIds" to pickOrderIdsList, - "pickOrderCodes" to pickOrderCodesList, - "doOrderIds" to doOrderIdsList, - "deliveryOrderCodes" to deliveryOrderCodesList, - "lineCountsPerPickOrder" to lineCountsPerPickOrder, - "consoCodes" to pickOrders.mapNotNull { it.consoCode }.distinct(), - "status" to doPickOrder.ticketStatus?.value, - "targetDate" to firstPickOrder.targetDate?.toLocalDate()?.toString(), - "pickOrderLines" to allPickOrderLines - ) - } else { - null - } - return mapOf( - "fgInfo" to fgInfo, - "pickOrders" to listOfNotNull(mergedPickOrder) - ) -} -*/ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map { println("=== Debug: getAllPickOrderLotsWithDetailsHierarchical (NEW STRUCTURE) ===") println("userId filter: $userId") @@ -4159,112 +3882,202 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto ) } } - @Transactional(rollbackFor = [java.lang.Exception::class]) open fun confirmLotSubstitution(req: LotSubstitutionConfirmRequest): MessageResponse { val zero = BigDecimal.ZERO // Validate pick order line - val pol = req.pickOrderLineId.let { pickOrderLineRepository.findById(it).orElse(null) } + val pol = pickOrderLineRepository.findById(req.pickOrderLineId).orElse(null) ?: return MessageResponse( id = null, name = "Pick order line not found", code = "ERROR", type = "pickorder", message = "Pick order line ${req.pickOrderLineId} not found", errorPosition = null ) val polItemId = pol.item?.id - if (polItemId == null) { - return MessageResponse( + ?: return MessageResponse( id = null, name = "Item not found", code = "ERROR", type = "pickorder", message = "Pick order line item is null", errorPosition = null ) - } - // ✅ 根据 lotNo 和 itemId 查找新的 InventoryLotLine - val newIll = when { - // 优先使用 stockInLineId(更可靠) - req.newStockInLineId != null && req.newStockInLineId > 0 -> { - // 通过 stockInLineId 查找 InventoryLot - val inventoryLot = inventoryLotRepository.findByStockInLineIdAndDeletedFalse(req.newStockInLineId) - ?: return MessageResponse( - id = null, name = "Inventory lot not found", code = "ERROR", type = "pickorder", - message = "Inventory lot with stockInLineId ${req.newStockInLineId} not found", - errorPosition = null - ) - - // 通过 InventoryLot 和 itemId 查找 InventoryLotLine - val lotLines = inventoryLotLineRepository.findAllByInventoryLotId(inventoryLot.id!!) - .filter { it.inventoryLot?.item?.id == polItemId && !it.deleted } - - if (lotLines.isEmpty()) { - return MessageResponse( - id = null, name = "Lot line not found", code = "ERROR", type = "pickorder", - message = "Inventory lot line with stockInLineId ${req.newStockInLineId} and itemId ${polItemId} not found", - errorPosition = null - ) - } - - // 如果有多个,取第一个(通常应该只有一个) - lotLines.first() - } - - // 兼容旧方式:使用 lotNo - req.newInventoryLotNo != null && req.newInventoryLotNo.isNotBlank() -> { - inventoryLotLineRepository.findByLotNoAndItemId(req.newInventoryLotNo, polItemId) - ?: return MessageResponse( - id = null, name = "New lot line not found", code = "ERROR", type = "pickorder", - message = "Inventory lot line with lotNo '${req.newInventoryLotNo}' and itemId ${polItemId} not found", - errorPosition = null - ) - } - - else -> { - return MessageResponse( - id = null, name = "Invalid request", code = "ERROR", type = "pickorder", - message = "Either newStockInLineId or newInventoryLotNo must be provided", - errorPosition = null - ) - } - } + // Find new InventoryLotLine (from stockInLineId first, fallback lotNo) + val newIll = resolveNewInventoryLotLine(req, polItemId) + ?: return MessageResponse( + id = null, name = "New lot line not found", code = "ERROR", type = "pickorder", + message = "Cannot resolve new inventory lot line", errorPosition = null + ) - // Item consistency check (应该已经通过上面的查询保证了,但再次确认) + // Item consistency check val newItemId = newIll.inventoryLot?.item?.id - if (newItemId == null || polItemId != newItemId) { + if (newItemId == null || newItemId != polItemId) { return MessageResponse( id = null, name = "Item mismatch", code = "ERROR", type = "pickorder", message = "New lot line item does not match pick order line item", errorPosition = null ) } - val newIllId = newIll.id ?: return MessageResponse( - id = null, name = "Invalid lot line", code = "ERROR", type = "pickorder", - message = "New inventory lot line has no ID", errorPosition = null - ) - val newLotNo = newIll.inventoryLot?.lotNo ?: req.newInventoryLotNo ?: "unknown" - // 1) Update suggested pick lot (if provided): move holdQty from old ILL to new ILL and re-point the suggestion - if (req.originalSuggestedPickLotId != null && req.originalSuggestedPickLotId > 0) { - // ✅ 使用 repository 而不是 SQL - val originalSpl = suggestPickLotRepository.findById(req.originalSuggestedPickLotId).orElse(null) - - if (originalSpl != null) { - val oldIll = originalSpl.suggestedLotLine - val qty = originalSpl.qty ?: zero + // Resolve SuggestedPickLot: + // - If originalSuggestedPickLotId provided: use it + // - Else (1:1 assumption): find by pickOrderLineId (optionally also by stockOutLineId if you add repository method) + val spl = if (req.originalSuggestedPickLotId != null && req.originalSuggestedPickLotId > 0) { + suggestPickLotRepository.findById(req.originalSuggestedPickLotId).orElse(null) + } else { + // 1:1 assumption fallback (you need a repository method; replace with your actual one) + // e.g. suggestPickLotRepository.findFirstByPickOrderLineIdAndDeletedFalseOrderByIdDesc(req.pickOrderLineId) + suggestPickLotRepository.findFirstByPickOrderLineId(req.pickOrderLineId) + } - if (oldIll != null && oldIll.id != newIllId) { - // Decrease hold on old, increase on new - oldIll.holdQty = (oldIll.holdQty ?: zero).minus(qty).max(zero) - inventoryLotLineRepository.save(oldIll) - - newIll.holdQty = (newIll.holdQty ?: zero).plus(qty) - inventoryLotLineRepository.save(newIll) - } + if (spl == null) { + return MessageResponse( + id = null, name = "Suggested pick lot not found", code = "ERROR", type = "pickorder", + message = "SuggestedPickLot not found for pickOrderLineId=${req.pickOrderLineId}", errorPosition = null + ) + } + + val qtyToHold = spl.qty ?: zero + if (qtyToHold.compareTo(zero) <= 0) { + return MessageResponse( + id = null, name = "Invalid qty", code = "ERROR", type = "pickorder", + message = "SuggestedPickLot qty is invalid: $qtyToHold", errorPosition = null + ) + } + + // Availability check on newIll BEFORE updates + val inQty = newIll.inQty ?: zero + val outQty = newIll.outQty ?: zero + val holdQty = newIll.holdQty ?: zero + val issueQty = newIll.issueQty ?: zero + val available = inQty.subtract(outQty).subtract(holdQty).subtract(issueQty) + + if (available.compareTo(qtyToHold) < 0) { + return MessageResponse( + id = null, name = "Insufficient lot qty", code = "REJECT", type = "pickorder", + message = "Reject switch lot: available=$available < required=$qtyToHold", errorPosition = null + ) + } - // ✅ 使用 repository 更新 suggestion - originalSpl.suggestedLotLine = newIll - suggestPickLotRepository.save(originalSpl) + val oldIll = spl.suggestedLotLine + + // Load stock out line (if provided) to decide "bind vs split" + val existingSol = if (req.stockOutLineId != null && req.stockOutLineId > 0) { + stockOutLIneRepository.findById(req.stockOutLineId).orElse(null) + } else null + val pickedQty = existingSol?.qty?.let { numToBigDecimal(it as? Number) } ?: zero + + // Switch lot rule: + // - actual pick == 0: replace/bind (no new line) + // - actual pick > 0: split remaining qty into a NEW suggested pick lot + stock out line + if (pickedQty.compareTo(zero) > 0) { + val remaining = qtyToHold.subtract(pickedQty) + if (remaining.compareTo(zero) <= 0) { + return MessageResponse( + id = null, + name = "No remaining qty", + code = "REJECT", + type = "pickorder", + message = "Reject switch lot: picked=$pickedQty already >= required=$qtyToHold", + errorPosition = null + ) + } + + // Move HOLD for remaining qty (old -> new) + if (oldIll != null && oldIll.id != null && oldIll.id != newIll.id) { + val oldHold = oldIll.holdQty ?: zero + val newOldHold = oldHold.subtract(remaining) + oldIll.holdQty = if (newOldHold.compareTo(zero) < 0) zero else newOldHold + inventoryLotLineRepository.save(oldIll) + + val newHold = (newIll.holdQty ?: zero).add(remaining) + newIll.holdQty = newHold + inventoryLotLineRepository.save(newIll) + } + if (oldIll == null) { + val newHold = (newIll.holdQty ?: zero).add(remaining) + newIll.holdQty = newHold + inventoryLotLineRepository.save(newIll) + } + + // Lock current suggestion qty to the picked qty (picked part stays on oldIll) + spl.qty = pickedQty + suggestPickLotRepository.saveAndFlush(spl) + + // Create a NEW stock out line + suggestion for the remaining qty + val stockOut: StockOut = if (existingSol != null) { + existingSol.stockOut ?: throw IllegalStateException("Existing StockOutLine has null stockOut") + } else { + val consoCode = pol.pickOrder?.consoCode ?: "" + val existing = stockOutRepository.findByConsoPickOrderCode(consoCode).orElse(null) + if (existing != null) { + existing + } else { + val handlerId = pol.pickOrder?.assignTo?.id ?: SecurityUtils.getUser().orElse(null)?.id + require(handlerId != null) { "Cannot create StockOut: handlerId is null" } + val newStockOut = StockOut().apply { + this.consoPickOrderCode = consoCode + this.type = pol.pickOrder?.type?.value ?: "" + this.status = StockOutStatus.PENDING.status + this.handler = handlerId + } + stockOutRepository.save(newStockOut) + } + } + + val newSol = StockOutLine().apply { + this.stockOut = stockOut + this.pickOrderLine = pol + this.item = pol.item + this.inventoryLotLine = newIll + this.qty = 0.0 + this.status = StockOutLineStatus.CHECKED.status + this.startTime = LocalDateTime.now() + this.type = existingSol?.type ?: "Nor" + } + stockOutLIneRepository.saveAndFlush(newSol) + + val newSpl = SuggestedPickLot().apply { + this.type = spl.type + this.pickOrderLine = pol + this.suggestedLotLine = newIll + this.stockOutLine = newSol + this.qty = remaining + this.pickSuggested = spl.pickSuggested } + suggestPickLotRepository.saveAndFlush(newSpl) + + val newLotNo = newIll.inventoryLot?.lotNo ?: req.newInventoryLotNo + return MessageResponse( + id = null, + name = "Lot substitution confirmed (split line)", + code = "SUCCESS", + type = "pickorder", + message = "Picked=$pickedQty, created new line for remaining=$remaining on lotNo '$newLotNo'", + errorPosition = null + ) + } + + // If oldIll exists and different: move hold old -> new + if (oldIll != null && oldIll.id != null && oldIll.id != newIll.id) { + val oldHold = oldIll.holdQty ?: zero + val newOldHold = oldHold.subtract(qtyToHold) + oldIll.holdQty = if (newOldHold.compareTo(zero) < 0) zero else newOldHold + inventoryLotLineRepository.save(oldIll) + + val newHold = (newIll.holdQty ?: zero).add(qtyToHold) + newIll.holdQty = newHold + inventoryLotLineRepository.save(newIll) + } + + // If first bind (oldIll == null): just hold on new + if (oldIll == null) { + val newHold = (newIll.holdQty ?: zero).add(qtyToHold) + newIll.holdQty = newHold + inventoryLotLineRepository.save(newIll) } - // 2) Update stock out line (if provided): re-point to new ILL; keep qty and status unchanged + // Point suggestion to new lot line + spl.suggestedLotLine = newIll + suggestPickLotRepository.save(spl) + + // Update stock out line if provided if (req.stockOutLineId != null && req.stockOutLineId > 0) { val sol = stockOutLIneRepository.findById(req.stockOutLineId).orElse(null) if (sol != null) { @@ -4274,15 +4087,43 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto } } + val newLotNo = newIll.inventoryLot?.lotNo ?: req.newInventoryLotNo return MessageResponse( id = null, name = "Lot substitution confirmed", code = "SUCCESS", type = "pickorder", - message = "Updated suggestion and stock out line to new lot line with lotNo '${newLotNo}'", - errorPosition = null + message = "Updated suggestion and stock out line to new lot line with lotNo '$newLotNo'", + errorPosition = null ) } + + private fun resolveNewInventoryLotLine( + req: LotSubstitutionConfirmRequest, + itemId: Long + ): InventoryLotLine? { + // Prefer stockInLineId + if (req.newStockInLineId != null && req.newStockInLineId > 0) { + val inventoryLot = inventoryLotRepository.findByStockInLineIdAndDeletedFalse(req.newStockInLineId) + ?: return null + + val lotLines = inventoryLotLineRepository.findAllByInventoryLotId(inventoryLot.id!!) + .filter { it.inventoryLot?.item?.id == itemId && !it.deleted } + + return lotLines.firstOrNull() + } + + // Fallback lotNo (req.newInventoryLotNo is non-null String in your model) + if (req.newInventoryLotNo.isNotBlank()) { + return inventoryLotLineRepository + .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( + req.newInventoryLotNo, + itemId + ) + } + + return null + } open fun getCompletedDoPickOrders( userId: Long, diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 5d9d980..123a9c0 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -815,6 +815,107 @@ fun searchMaterialStockOutTraceabilityReport( return jdbcDao.queryForList(sql, args) } + /** + * GRN (Goods Received Note) report: stock-in lines with PO/delivery note, filterable by receipt date range and item code. + * Returns rows for Excel export: poCode, deliveryNoteNo, receiptDate, itemCode, itemName, acceptedQty, demandQty, uom, etc. + */ + fun searchGrnReport( + receiptDateStart: String?, + receiptDateEnd: String?, + itemCode: String? + ): List> { + val args = mutableMapOf() + val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) { + val formatted = receiptDateStart.replace("/", "-") + args["receiptDateStart"] = formatted + "AND DATE(sil.receiptDate) >= DATE(:receiptDateStart)" + } else "" + val receiptDateEndSql = if (!receiptDateEnd.isNullOrBlank()) { + val formatted = receiptDateEnd.replace("/", "-") + args["receiptDateEnd"] = formatted + "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" + } else "" + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + + val sql = """ + SELECT + po.code AS poCode, + CASE + WHEN sil.dnNo = 'DN00000' OR sil.dnNo IS NULL THEN '' + ELSE sil.dnNo + END AS deliveryNoteNo, + DATE_FORMAT(sil.receiptDate, '%Y-%m-%d') AS receiptDate, + COALESCE(it.code, '') AS itemCode, + COALESCE(it.name, '') AS itemName, + COALESCE(sil.acceptedQty, 0) AS acceptedQty, + COALESCE(sil.demandQty, 0) AS demandQty, + COALESCE(uc_stock.udfudesc, uc_pol.udfudesc, '') AS uom, + COALESCE(uc_pol.udfudesc, '') AS purchaseUomDesc, + COALESCE(uc_stock.udfudesc, '') AS stockUomDesc, + COALESCE(sil.productLotNo, '') AS productLotNo, + DATE_FORMAT(sil.expiryDate, '%Y-%m-%d') AS expiryDate, + COALESCE(sp.code, '') AS supplierCode, + COALESCE(sp.name, '') AS supplier, + COALESCE(sil.status, '') AS status, + MAX(grn.m18_record_id) AS grnId + FROM stock_in_line sil + LEFT JOIN items it ON sil.itemId = it.id + LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id + LEFT JOIN shop sp ON po.supplierId = sp.id + LEFT JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id + LEFT JOIN uom_conversion uc_pol ON pol.uomId = uc_pol.id + LEFT JOIN item_uom iu_stock ON it.id = iu_stock.itemId AND iu_stock.stockUnit = true AND iu_stock.deleted = false + LEFT JOIN uom_conversion uc_stock ON iu_stock.uomId = uc_stock.id + LEFT JOIN m18_goods_receipt_note_log grn + ON grn.stock_in_line_id = sil.id + WHERE sil.deleted = false + AND sil.receiptDate IS NOT NULL + AND sil.purchaseOrderId IS NOT NULL + $receiptDateStartSql + $receiptDateEndSql + $itemCodeSql + GROUP BY + po.code, + deliveryNoteNo, + receiptDate, + itemCode, + itemName, + acceptedQty, + demandQty, + uom, + purchaseUomDesc, + stockUomDesc, + productLotNo, + expiryDate, + supplierCode, + supplier, + status + ORDER BY sil.receiptDate, po.code, sil.id + """.trimIndent() + val rows = jdbcDao.queryForList(sql, args) + return rows.map { row -> + mapOf( + "poCode" to row["poCode"], + "deliveryNoteNo" to row["deliveryNoteNo"], + "receiptDate" to row["receiptDate"], + "itemCode" to row["itemCode"], + "itemName" to row["itemName"], + "acceptedQty" to (row["acceptedQty"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + "receivedQty" to (row["acceptedQty"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + "demandQty" to (row["demandQty"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), + "uom" to row["uom"], + "purchaseUomDesc" to row["purchaseUomDesc"], + "stockUomDesc" to row["stockUomDesc"], + "productLotNo" to row["productLotNo"], + "expiryDate" to row["expiryDate"], + "supplierCode" to row["supplierCode"], + "supplier" to row["supplier"], + "status" to row["status"], + "grnId" to row["grnId"] + ) + } + } + /** * Queries the database for Stock Balance Report data (one summarized row per item). * Uses stock_ledger with report period (fromDate/toDate): opening = before fromDate, cum in/out = in period, current = up to toDate. diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index 008eaa9..6579462 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -320,4 +320,18 @@ class ReportController( return ResponseEntity(pdfBytes, headers, HttpStatus.OK) } + /** + * GRN (Goods Received Note) report data for Excel export. + * Query by receipt date range and optional item code. Returns JSON { "rows": [ ... ] }. + */ + @GetMapping("/grn-report") + fun getGrnReport( + @RequestParam(required = false) receiptDateStart: String?, + @RequestParam(required = false) receiptDateEnd: String?, + @RequestParam(required = false) itemCode: String? + ): Map { + val rows = reportService.searchGrnReport(receiptDateStart, receiptDateEnd, itemCode) + return mapOf("rows" to rows) + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 1ab7b17..f94b494 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -53,6 +53,7 @@ interface InventoryLotLineRepository : AbstractRepository +======= + // lotNo + itemId may not be unique (multiple warehouses/lines); pick one deterministically + fun findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( + lotNo: String, + itemId: Long + ): InventoryLotLine? +>>>>>>> 9760717ed6a5c59383467921464fb2b89a7f85a8 // InventoryLotLineRepository.kt 中添加 @Query("SELECT ill FROM InventoryLotLine ill WHERE ill.warehouse.id IN :warehouseIds AND ill.deleted = false") fun findAllByWarehouseIdInAndDeletedIsFalse(@Param("warehouseIds") warehouseIds: List): List diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutRepository.kt index 1878207..902e5a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockOutRepository.kt @@ -7,5 +7,7 @@ import java.util.Optional @Repository interface StockOutRepository: AbstractRepository { fun findByConsoPickOrderCode(consoPickOrderCode: String) : Optional + // consoPickOrderCode 可能在 DB 中存在重复,避免 single-result exception + fun findFirstByConsoPickOrderCodeOrderByIdDesc(consoPickOrderCode: String): StockOut? fun findByStockTakeIdAndDeletedFalse(stockTakeId: Long): StockOut? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt index f9b1e97..75983ab 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/SuggestPickLotRepository.kt @@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository interface SuggestPickLotRepository : AbstractRepository { fun findAllByPickOrderLineIn(lines: List): List fun findAllByPickOrderLineIdIn(pickOrderLineIds: List): List - + fun findFirstByPickOrderLineId(pickOrderLineId: Long): SuggestedPickLot? fun findAllByPickOrderLineId(pickOrderLineId: Long): List + + fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 7e54be0..019fb62 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -43,6 +43,8 @@ import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository import com.ffii.fpsms.modules.stock.entity.InventoryRepository import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository import java.time.LocalTime +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository @Service open class StockOutLineService( private val jdbcDao: JdbcDao, @@ -53,7 +55,9 @@ open class StockOutLineService( private val itemUomRespository: ItemUomRespository, private val pickOrderRepository: PickOrderRepository, private val inventoryLotLineRepository: InventoryLotLineRepository, + private val stockInLineRepository: StockInLineRepository, @Lazy private val suggestedPickLotService: SuggestedPickLotService, + private val suggestPickLotRepository: SuggestPickLotRepository, private val inventoryLotRepository: InventoryLotRepository, private val doPickOrderRepository: DoPickOrderRepository, private val doPickOrderRecordRepository: DoPickOrderRecordRepository, @@ -68,6 +72,35 @@ private val inventoryLotLineService: InventoryLotLineService, private val inventoryRepository: InventoryRepository, private val pickExecutionIssueRepository: PickExecutionIssueRepository ): AbstractBaseEntityService(jdbcDao, stockOutLineRepository) { + private fun isEndStatus(status: String?): Boolean { + val s = status?.trim()?.lowercase() ?: return false + return s == "completed" || s == "rejected" || s == "partially_completed" + } + + @Transactional + private fun tryCompletePickOrderLine(pickOrderLineId: Long) { + val sols = stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) + if (sols.isEmpty()) return + + val allEnded = sols.all { isEndStatus(it.status) } + if (!allEnded) return + + val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return + if (pol.status != PickOrderLineStatus.COMPLETED) { + pol.status = PickOrderLineStatus.COMPLETED + pickOrderLineRepository.save(pol) + } + + // Optionally bubble up to pick order completion (safe no-op if not ready) + val consoCode = pol.pickOrder?.consoCode + if (!consoCode.isNullOrBlank()) { + try { + pickOrderService.checkAndCompletePickOrderByConsoCode(consoCode) + } catch (e: Exception) { + println("⚠️ Error checking pick order completion for consoCode=$consoCode: ${e.message}") + } + } + } @Throws(IOException::class) @Transactional open fun findAllByStockOutId(stockOutId: Long): List { @@ -620,6 +653,10 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { } val savedStockOutLine = stockOutLineRepository.saveAndFlush(stockOutLine) println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") + // If this stock out line is in end status, try completing its pick order line + if (isEndStatus(savedStockOutLine.status)) { + savedStockOutLine.pickOrderLine?.id?.let { tryCompletePickOrderLine(it) } + } try { val item = savedStockOutLine.item val inventoryLotLine = savedStockOutLine.inventoryLotLine @@ -946,22 +983,62 @@ open fun updateStockOutLineStatusByQRCodeAndLotNo(request: UpdateStockOutLineSta // Step 2: Get InventoryLotLine val getInventoryLotLineStart = System.currentTimeMillis() - // 修复:从 stockOutLine.inventoryLotLine 获取 inventoryLot,而不是使用错误的参数 - val inventoryLotLine = stockOutLine.inventoryLotLine + // If StockOutLine has no lot (noLot row), resolve InventoryLotLine by scanned lotNo + itemId and bind it + var inventoryLotLine = stockOutLine.inventoryLotLine + if (inventoryLotLine == null) { + // Prefer stockInLineId from QR for deterministic binding + val resolved = if (request.stockInLineId != null && request.stockInLineId > 0) { + println(" Resolving InventoryLotLine by stockInLineId=${request.stockInLineId} ...") + val sil = stockInLineRepository.findById(request.stockInLineId).orElse(null) + val ill = sil?.inventoryLotLine + if (ill == null) { + println(" StockInLine ${request.stockInLineId} has no associated InventoryLotLine") + null + } else { + // item consistency guard + val illItemId = ill.inventoryLot?.item?.id + if (illItemId != null && illItemId != request.itemId) { + println(" InventoryLotLine item mismatch for stockInLineId=${request.stockInLineId}: $illItemId != ${request.itemId}") + null + } else { + ill + } + } + } else { + println(" StockOutLine has no associated InventoryLotLine, resolving by lotNo+itemId...") + inventoryLotLineRepository + .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( + request.inventoryLotNo, + request.itemId + ) + } + if (resolved == null) { + println(" Cannot resolve InventoryLotLine by lotNo=${request.inventoryLotNo}, itemId=${request.itemId}") + return MessageResponse( + id = null, + name = "No inventory lot line", + code = "NO_INVENTORY_LOT_LINE", + type = "error", + message = "Cannot resolve InventoryLotLine (stockInLineId=${request.stockInLineId ?: "null"}, lotNo=${request.inventoryLotNo}, itemId=${request.itemId})", + errorPosition = null + ) + } + // Bind the lot line to this stockOutLine so subsequent operations can proceed + stockOutLine.inventoryLotLine = resolved + stockOutLine.item = stockOutLine.item ?: resolved.inventoryLot?.item + inventoryLotLine = resolved + + // Also update SuggestedPickLot to point to the resolved lot line (so UI/holdQty logic matches DO confirmLotSubstitution) + val spl = suggestPickLotRepository.findFirstByStockOutLineId(stockOutLine.id!!) + if (spl != null) { + spl.suggestedLotLine = resolved + suggestPickLotRepository.saveAndFlush(spl) + } + } val getInventoryLotLineTime = System.currentTimeMillis() - getInventoryLotLineStart println("⏱️ [STEP 2] Get InventoryLotLine: ${getInventoryLotLineTime}ms") - if (inventoryLotLine == null) { - println(" StockOutLine has no associated InventoryLotLine") - return MessageResponse( - id = null, - name = "No inventory lot line", - code = "NO_INVENTORY_LOT_LINE", - type = "error", - message = "StockOutLine ${request.stockOutLineId} has no associated InventoryLotLine", - errorPosition = null - ) - } + // inventoryLotLine is guaranteed non-null here // Step 3: Get InventoryLot val getInventoryLotStart = System.currentTimeMillis() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index 4c1656c..832e29a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -40,6 +40,7 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.web.model.StockOutStatus +import com.ffii.fpsms.modules.common.SecurityUtils @Service open class SuggestedPickLotService( val suggestedPickLotRepository: SuggestPickLotRepository, @@ -433,7 +434,32 @@ open class SuggestedPickLotService( } open fun saveAll(request: List): List { - return suggestedPickLotRepository.saveAllAndFlush(request) + val saved = suggestedPickLotRepository.saveAllAndFlush(request) + + // For insufficient stock (suggestedLotLine == null), create a no-lot stock_out_line so UI can display & close the line. + // Also backfill SuggestedPickLot.stockOutLineId for downstream flows (e.g. hierarchical API -> stockouts). + val toBackfill = saved.filter { it.suggestedLotLine == null && it.pickOrderLine != null } + if (toBackfill.isNotEmpty()) { + val updated = mutableListOf() + toBackfill.forEach { spl -> + val pickOrder = spl.pickOrderLine?.pickOrder + if (pickOrder == null) return@forEach + + // Only create/backfill when stockOutLine is missing + if (spl.stockOutLine == null) { + val sol = createStockOutLineForSuggestion(spl, pickOrder) + if (sol != null) { + spl.stockOutLine = sol + updated.add(spl) + } + } + } + if (updated.isNotEmpty()) { + suggestedPickLotRepository.saveAllAndFlush(updated) + } + } + + return saved } private fun createStockOutLineForSuggestion( suggestion: SuggestedPickLot, @@ -470,10 +496,13 @@ open class SuggestedPickLotService( // Get or create StockOut val stockOut = stockOutRepository.findByConsoPickOrderCode(pickOrder.consoCode ?: "") .orElseGet { + val handlerId = pickOrder.assignTo?.id ?: SecurityUtils.getUser().orElse(null)?.id + require(handlerId != null) { "Cannot create StockOut: handlerId is null" } val newStockOut = StockOut().apply { this.consoPickOrderCode = pickOrder.consoCode ?: "" this.type = pickOrderTypeValue // Use pick order type (do, job, material, etc.) this.status = StockOutStatus.PENDING.status + this.handler = handlerId } stockOutRepository.save(newStockOut) } @@ -484,7 +513,8 @@ open class SuggestedPickLotService( this.pickOrderLine = pickOrderLine this.item = item this.inventoryLotLine = null // No lot available - this.qty = (suggestion.qty ?: BigDecimal.ZERO).toDouble() + // qty on StockOutLine represents picked qty; for no-lot placeholder it must start from 0 + this.qty = 0.0 this.status = StockOutLineStatus.PENDING.status this.deleted = false this.type = "Nor" diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt index 495237e..ee8fbaa 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockOutRequest.kt @@ -64,6 +64,7 @@ data class UpdateStockOutLineStatusRequest( data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( val pickOrderLineId: Long, val inventoryLotNo: String, + val stockInLineId: Long? = null, val stockOutLineId: Long, val itemId: Long, val status: String