| @@ -0,0 +1,33 @@ | |||||
| --- | |||||
| description: Prevent double-click / duplicate API calls from frontend UI | |||||
| alwaysApply: true | |||||
| --- | |||||
| # Prevent duplicated API calls (frontend) | |||||
| When wiring UI actions (buttons, row actions, dialogs) to backend APIs, **always prevent double submission**. Relying on `setState` + `disabled` alone is not sufficient because rapid double-click can fire twice before React re-renders. | |||||
| - **Must**: add an **in-flight lock** (e.g. `useRef(false)`) and early-return if already running. | |||||
| - **Must**: keep the UI disabled/loading (`disabled={isLoading}`) for user feedback. | |||||
| - **Must**: clear the lock in `finally` so it always releases. | |||||
| - **Should**: if the same endpoint can be triggered from multiple places, consider a shared “single-flight” helper (dedupe by `method+url+body` key). | |||||
| Example pattern: | |||||
| ```tsx | |||||
| const inFlightRef = useRef(false); | |||||
| const [isSaving, setIsSaving] = useState(false); | |||||
| const onSave = async () => { | |||||
| if (inFlightRef.current) return; | |||||
| inFlightRef.current = true; | |||||
| setIsSaving(true); | |||||
| try { | |||||
| await doRequest(); | |||||
| } finally { | |||||
| setIsSaving(false); | |||||
| inFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| ``` | |||||
| @@ -35,6 +35,10 @@ out/ | |||||
| ### VS Code ### | ### VS Code ### | ||||
| .vscode/ | .vscode/ | ||||
| ### Cursor (local-only rules) ### | |||||
| .cursor/rules/local/ | |||||
| package-lock.json | package-lock.json | ||||
| python/Bag3.spec | python/Bag3.spec | ||||
| python/dist/Bag3.exe | |||||
| python/dist | |||||
| @@ -16,6 +16,7 @@ Bag2 is kept as a separate legacy v2.x line; do not assume Bag2 matches Bag3. | |||||
| Run: python Bag3.py | Run: python Bag3.py | ||||
| """ | """ | ||||
| import errno | |||||
| import json | import json | ||||
| import os | import os | ||||
| import select | import select | ||||
| @@ -25,6 +26,7 @@ import tempfile | |||||
| import threading | import threading | ||||
| import time | import time | ||||
| import tkinter as tk | import tkinter as tk | ||||
| from dataclasses import dataclass | |||||
| from datetime import date, datetime, timedelta | from datetime import date, datetime, timedelta | ||||
| from tkinter import messagebox, ttk | from tkinter import messagebox, ttk | ||||
| from typing import Callable, Optional, Tuple | from typing import Callable, Optional, Tuple | ||||
| @@ -344,6 +346,25 @@ DATAFLEX_UI_PROGRESS_EVERY = max( | |||||
| DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | DATAFLEX_SINGLE_TCP_JOB = _dataflex_bool_env( | ||||
| "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | "FPSMS_DATAFLEX_SINGLE_TCP_JOB", False | ||||
| ) | ) | ||||
| # Link-OS SGD: raw-ZPL job shows this host id instead of a generic name (e.g. "ZPL. EMULATION"). | |||||
| # Same id when the same job order is printed again. Disable: FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD=0 | |||||
| DATAFLEX_HOST_IDENTIFICATION_SGD = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_HOST_IDENTIFICATION_SGD", True | |||||
| ) | |||||
| # Bag ZPL size (dots). ^PW700 matched little content (mostly vertical ^A@R), so previews showed a wide strip with empty right margin. | |||||
| DATAFLEX_LABEL_PW = max( | |||||
| 280, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_PW", 400), | |||||
| ) | |||||
| DATAFLEX_LABEL_LL = max( | |||||
| 200, | |||||
| _dataflex_int_env("FPSMS_DATAFLEX_LABEL_LL", 500), | |||||
| ) | |||||
| # Some Zebra/DataFlex units RST the socket on host half-close; Windows surfaces WinError 10054. | |||||
| # Set FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR=1 to omit shutdown(SHUT_WR) and only close() (often avoids RST). | |||||
| DATAFLEX_SKIP_SHUTDOWN_WR = _dataflex_bool_env( | |||||
| "FPSMS_DATAFLEX_SKIP_SHUTDOWN_WR", False | |||||
| ) | |||||
| # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | # Full recovery (~JR soft reset) — used by「打袋重設」only; longer delay for firmware | ||||
| DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC = 1.2 | ||||
| # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | # Zebra ~RO only (used when FPSMS_DATAFLEX_NO_JR is set for full recovery) | ||||
| @@ -364,12 +385,56 @@ def _zpl_escape(s: str) -> str: | |||||
| return s.replace("\\", "\\\\").replace("^", "\\^") | return s.replace("\\", "\\\\").replace("^", "\\^") | ||||
| def _dataflex_host_identification_sgd_prefix(job_order_id: Optional[int]) -> str: | |||||
| """ | |||||
| Optional ASCII prefix before ^XA: set zpl.host_identification so the printer lists the job | |||||
| under the job order id instead of a generic raw-ZPL label. | |||||
| """ | |||||
| if not DATAFLEX_HOST_IDENTIFICATION_SGD or job_order_id is None: | |||||
| return "" | |||||
| try: | |||||
| jid = str(int(job_order_id)) | |||||
| except (TypeError, ValueError): | |||||
| return "" | |||||
| if not jid.isdigit(): | |||||
| return "" | |||||
| return f'! U1 setvar "zpl.host_identification" "{jid}"\r\n' | |||||
| def _dataflex_zpl_bytes(zpl: str) -> bytes: | def _dataflex_zpl_bytes(zpl: str) -> bytes: | ||||
| """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | """UTF-8 ZPL with one trailing CRLF so the printer sees a clear job boundary.""" | ||||
| s = (zpl or "").rstrip("\r\n") | s = (zpl or "").rstrip("\r\n") | ||||
| return (s + "\r\n").encode("utf-8") | return (s + "\r\n").encode("utf-8") | ||||
| def _dataflex_is_benign_tcp_reset(err: BaseException) -> bool: | |||||
| """True when peer closed with RST/FIN in a way that is normal for raw printer TCP (Windows 10054).""" | |||||
| if isinstance(err, (BrokenPipeError, ConnectionResetError, ConnectionAbortedError)): | |||||
| return True | |||||
| if isinstance(err, OSError): | |||||
| if getattr(err, "winerror", None) == 10054: # WSAECONNRESET | |||||
| return True | |||||
| if err.errno in ( | |||||
| errno.ECONNRESET, | |||||
| errno.EPIPE, | |||||
| errno.ECONNABORTED, | |||||
| ): | |||||
| return True | |||||
| return False | |||||
| def _dataflex_shutdown_write_maybe(sock: socket.socket) -> None: | |||||
| """Half-close write side; ignore printer RST (common after ZPL on port 9100-style links).""" | |||||
| if DATAFLEX_SKIP_SHUTDOWN_WR: | |||||
| return | |||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError as e: | |||||
| if _dataflex_is_benign_tcp_reset(e): | |||||
| return | |||||
| raise | |||||
| def generate_zpl_dataflex( | def generate_zpl_dataflex( | ||||
| batch_no: str, | batch_no: str, | ||||
| item_code: str, | item_code: str, | ||||
| @@ -377,6 +442,7 @@ def generate_zpl_dataflex( | |||||
| item_id: Optional[int] = None, | item_id: Optional[int] = None, | ||||
| stock_in_line_id: Optional[int] = None, | stock_in_line_id: Optional[int] = None, | ||||
| lot_no: Optional[str] = None, | lot_no: Optional[str] = None, | ||||
| job_order_id: Optional[int] = None, | |||||
| font_regular: str = "E:STXihei.ttf", | font_regular: str = "E:STXihei.ttf", | ||||
| font_bold: str = "E:STXihei.ttf", | font_bold: str = "E:STXihei.ttf", | ||||
| ) -> str: | ) -> str: | ||||
| @@ -398,11 +464,12 @@ def generate_zpl_dataflex( | |||||
| qr_value = _zpl_escape(qr_payload) | qr_value = _zpl_escape(qr_payload) | ||||
| # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | # Explicit ^PQ1: each ^XA…^XZ is exactly one bag. Avoids E1005 "over quantity" on some Zebra/DataFlex | ||||
| # firmware when many labels are sent on one TCP session without a per-job quantity. | # firmware when many labels are sent on one TCP session without a per-job quantity. | ||||
| return f"""^XA | |||||
| host_id = _dataflex_host_identification_sgd_prefix(job_order_id) | |||||
| return host_id + f"""^XA | |||||
| ^PQ1,0,1,N | ^PQ1,0,1,N | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW{DATAFLEX_LABEL_PW} | |||||
| ^LL{DATAFLEX_LABEL_LL} | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,{qr_value}^FS | ^BQN,2,4^FDQA,{qr_value}^FS | ||||
| @@ -447,10 +514,7 @@ def send_dataflex_preprint_reset(ip: str, port: int, *, force: bool = False) -> | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(DATAFLEX_PREPRINT_BYTES) | sock.sendall(DATAFLEX_PREPRINT_BYTES) | ||||
| time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | time.sleep(DATAFLEX_POST_PREPRINT_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -472,10 +536,7 @@ def send_dataflex_job_counter_reset(ip: str, port: int, *, force: bool = False) | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_full_recovery_payload()) | sock.sendall(_dataflex_full_recovery_payload()) | ||||
| time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | time.sleep(DATAFLEX_POST_FULL_RECOVERY_DELAY_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -527,10 +588,7 @@ def send_dataflex_reset_and_labels( | |||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| if i < copies - 1: | if i < copies - 1: | ||||
| time.sleep(delay_sec) | time.sleep(delay_sec) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -879,10 +937,7 @@ def send_zpl_to_dataflex(ip: str, port: int, zpl: str) -> None: | |||||
| sock.connect((ip, port)) | sock.connect((ip, port)) | ||||
| sock.sendall(_dataflex_zpl_bytes(zpl)) | sock.sendall(_dataflex_zpl_bytes(zpl)) | ||||
| time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | time.sleep(DATAFLEX_POST_LABEL_SETTLE_SEC) | ||||
| try: | |||||
| sock.shutdown(socket.SHUT_WR) | |||||
| except OSError: | |||||
| pass | |||||
| _dataflex_shutdown_write_maybe(sock) | |||||
| finally: | finally: | ||||
| sock.close() | sock.close() | ||||
| @@ -907,6 +962,10 @@ def query_dataflex_host_status(ip: str, port: int) -> str: | |||||
| data = sock.recv(4096) | data = sock.recv(4096) | ||||
| except socket.timeout: | except socket.timeout: | ||||
| break | break | ||||
| except OSError as ex: | |||||
| if _dataflex_is_benign_tcp_reset(ex): | |||||
| break | |||||
| raise | |||||
| if not data: | if not data: | ||||
| break | break | ||||
| chunks.append(data) | chunks.append(data) | ||||
| @@ -1829,6 +1888,204 @@ def ask_bag_count(parent: tk.Tk) -> Optional[Tuple[int, bool]]: | |||||
| return result[0] | return result[0] | ||||
| @dataclass(frozen=True) | |||||
| class DataflexPrintSession: | |||||
| """ | |||||
| Snapshot taken when the user starts DataFlex print (especially C 連續印). | |||||
| The worker must use only this object — not grid row index, scroll position, or selection. | |||||
| """ | |||||
| job_order_id: Optional[int] | |||||
| job_code: str | |||||
| item_code: str | |||||
| item_name: str | |||||
| label_text: str | |||||
| zpl: str | |||||
| printer_ip: str | |||||
| printer_port: int | |||||
| batch_display: str | |||||
| def build_dataflex_print_session( | |||||
| jo: dict, | |||||
| batch: str, | |||||
| zpl: str, | |||||
| label_text: str, | |||||
| printer_ip: str, | |||||
| printer_port: int, | |||||
| ) -> DataflexPrintSession: | |||||
| jo_id = jo.get("id") | |||||
| jo_code = (jo.get("code") or "").strip() | |||||
| if not jo_code and jo_id is not None: | |||||
| jo_code = f"#{jo_id}" | |||||
| elif not jo_code: | |||||
| jo_code = "—" | |||||
| return DataflexPrintSession( | |||||
| job_order_id=int(jo_id) if jo_id is not None else None, | |||||
| job_code=jo_code, | |||||
| item_code=(jo.get("itemCode") or "—").strip(), | |||||
| item_name=(jo.get("itemName") or "—").strip(), | |||||
| label_text=label_text, | |||||
| zpl=zpl, | |||||
| printer_ip=printer_ip, | |||||
| printer_port=printer_port, | |||||
| batch_display=(batch or "—").strip(), | |||||
| ) | |||||
| def run_dataflex_continuous_thread( | |||||
| root: tk.Tk, | |||||
| session: DataflexPrintSession, | |||||
| stop_event: threading.Event, | |||||
| stop_win: tk.Toplevel, | |||||
| dataflex_lock: threading.Lock, | |||||
| dataflex_busy_ref: list, | |||||
| dataflex_stop_win_ref: list, | |||||
| active_session_ref: list, | |||||
| base_url: str, | |||||
| set_status_message: Callable[[str, bool], None], | |||||
| on_recorded: Callable[[], None], | |||||
| ) -> None: | |||||
| """Send bags in a loop until stop_event; all payload comes from session (in-memory snapshot).""" | |||||
| def worker() -> None: | |||||
| with dataflex_lock: | |||||
| if dataflex_busy_ref[0]: | |||||
| active_session_ref[0] = None | |||||
| def _abort_start() -> None: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ) | |||||
| dataflex_stop_win_ref[0] = None | |||||
| try: | |||||
| stop_win.destroy() | |||||
| except tk.TclError: | |||||
| pass | |||||
| root.after(0, _abort_start) | |||||
| return | |||||
| dataflex_busy_ref[0] = True | |||||
| ip = session.printer_ip | |||||
| port = session.printer_port | |||||
| zpl = session.zpl | |||||
| label_text = session.label_text | |||||
| printed = 0 | |||||
| error_shown = False | |||||
| try: | |||||
| send_dataflex_start_job_reset(ip, port, force=True) | |||||
| while not stop_event.is_set(): | |||||
| send_dataflex_label_with_recovery(ip, port, zpl) | |||||
| printed += 1 | |||||
| if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( | |||||
| printed == 1 or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 | |||||
| ): | |||||
| p = printed | |||||
| root.after( | |||||
| 0, | |||||
| lambda p=p, jc=session.job_code: set_status_message( | |||||
| f"連續打袋 · 工單 {jc}… 已印 {p} 張", | |||||
| is_error=False, | |||||
| ), | |||||
| ) | |||||
| if ( | |||||
| DATAFLEX_VERIFY_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 | |||||
| ): | |||||
| recover_dataflex_if_host_fault(ip, port) | |||||
| if ( | |||||
| DATAFLEX_COOLDOWN_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible(stop_event, max(0.0, DATAFLEX_COOLDOWN_SEC)) | |||||
| if ( | |||||
| DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible(stop_event, max(0.0, DATAFLEX_THERMAL_REST_SEC)) | |||||
| _sleep_interruptible(stop_event, DATAFLEX_INTER_LABEL_DELAY_SEC) | |||||
| except ConnectionRefusedError: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except socket.timeout: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except OSError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message(f"列印失敗:{e}", is_error=True), | |||||
| ) | |||||
| except RuntimeError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message(f"打袋機錯誤:{e}", is_error=True), | |||||
| ) | |||||
| except Exception as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message(f"打袋機例外:{e}", is_error=True), | |||||
| ) | |||||
| finally: | |||||
| with dataflex_lock: | |||||
| dataflex_busy_ref[0] = False | |||||
| active_session_ref[0] = None | |||||
| def _done() -> None: | |||||
| dataflex_stop_win_ref[0] = None | |||||
| try: | |||||
| if os.name == "nt": | |||||
| stop_win.attributes("-topmost", False) | |||||
| except tk.TclError: | |||||
| pass | |||||
| try: | |||||
| stop_win.destroy() | |||||
| except tk.TclError: | |||||
| pass | |||||
| jc = session.job_code | |||||
| if printed > 0: | |||||
| set_status_message( | |||||
| f"連續列印結束:工單 {jc} · {label_text},已印 {printed} 張", | |||||
| is_error=False, | |||||
| ) | |||||
| if session.job_order_id is not None: | |||||
| try: | |||||
| submit_job_order_print_submit( | |||||
| base_url, | |||||
| session.job_order_id, | |||||
| printed, | |||||
| "DATAFLEX", | |||||
| ) | |||||
| on_recorded() | |||||
| except requests.RequestException as ex: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", | |||||
| ) | |||||
| elif not error_shown: | |||||
| set_status_message("連續列印未印出或已取消", is_error=True) | |||||
| root.after(0, _done) | |||||
| threading.Thread(target=worker, daemon=True).start() | |||||
| def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | def _sleep_interruptible(stop_event: threading.Event, total_sec: float) -> None: | ||||
| """Sleep up to total_sec but return early if stop_event is set.""" | """Sleep up to total_sec but return early if stop_event is set.""" | ||||
| end = time.perf_counter() + total_sec | end = time.perf_counter() + total_sec | ||||
| @@ -1845,16 +2102,18 @@ def open_dataflex_stop_window( | |||||
| parent: tk.Tk, | parent: tk.Tk, | ||||
| stop_event: threading.Event, | stop_event: threading.Event, | ||||
| stop_win_ref: list, | stop_win_ref: list, | ||||
| session: DataflexPrintSession, | |||||
| ) -> tk.Toplevel: | ) -> tk.Toplevel: | ||||
| """ | """ | ||||
| Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). | Small window with 停止列印 for DataFlex continuous mode (non-modal so stop stays usable). | ||||
| Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, | Stays above other dialogs (e.g. 標籤機 quantity) via periodic lift + optional topmost on Windows, | ||||
| so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. | so switching printer and printing labels does not hide the stop control. Ref is cleared on destroy. | ||||
| Job details come from the in-memory session snapshot, not the grid selection. | |||||
| """ | """ | ||||
| win = tk.Toplevel(parent) | win = tk.Toplevel(parent) | ||||
| win.title("打袋機連續列印") | win.title("打袋機連續列印") | ||||
| win.geometry("420x170") | |||||
| win.geometry("480x240") | |||||
| # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | # On Windows, transient(root) can hide this Toplevel when the menubutton / printer row | ||||
| # updates (e.g. switching to 激光機); keep transient only on non-Windows. | # updates (e.g. switching to 激光機); keep transient only on non-Windows. | ||||
| if os.name != "nt": | if os.name != "nt": | ||||
| @@ -1869,11 +2128,28 @@ def open_dataflex_stop_window( | |||||
| tk.Label( | tk.Label( | ||||
| win, | win, | ||||
| text="連續列印進行中(與上方列印機選項無關),可隨時按下方停止。", | |||||
| text="連續列印進行中(內容以按下 C 時的工單為準,與列表捲動/日期無關)", | |||||
| font=get_font(FONT_SIZE_META), | |||||
| bg=BG_TOP, | |||||
| wraplength=440, | |||||
| justify=tk.CENTER, | |||||
| ).pack(pady=(12, 6)) | |||||
| detail = ( | |||||
| f"工單:{session.job_code}\n" | |||||
| f"品號:{session.item_code}\n" | |||||
| f"品名:{session.item_name}\n" | |||||
| f"批次/批號:{session.label_text}" | |||||
| ) | |||||
| tk.Label( | |||||
| win, | |||||
| text=detail, | |||||
| font=get_font(FONT_SIZE), | font=get_font(FONT_SIZE), | ||||
| bg=BG_TOP, | bg=BG_TOP, | ||||
| wraplength=400, | |||||
| ).pack(pady=(16, 8)) | |||||
| fg="#111111", | |||||
| wraplength=440, | |||||
| justify=tk.LEFT, | |||||
| anchor=tk.W, | |||||
| ).pack(padx=16, pady=(0, 8), fill=tk.X) | |||||
| def clear_topmost() -> None: | def clear_topmost() -> None: | ||||
| if os.name == "nt": | if os.name == "nt": | ||||
| @@ -1985,6 +2261,8 @@ def main() -> None: | |||||
| label_busy_ref: list = [False] | label_busy_ref: list = [False] | ||||
| # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | # DataFlex continuous: stop Toplevel ref so we can lift it after other dialogs | ||||
| dataflex_stop_win_ref: list = [None] | dataflex_stop_win_ref: list = [None] | ||||
| # In-memory job snapshot for C 連續印 (not tied to grid row position after start) | |||||
| active_dataflex_session_ref: list[Optional[DataflexPrintSession]] = [None] | |||||
| def lift_dataflex_stop_if_running() -> None: | def lift_dataflex_stop_if_running() -> None: | ||||
| """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | """After closing another dialog (e.g. 標籤印數), bring the stop panel forward again.""" | ||||
| @@ -2417,6 +2695,20 @@ def main() -> None: | |||||
| name_lbl.pack(anchor=tk.NW) | name_lbl.pack(anchor=tk.NW) | ||||
| def _on_click(e, j=jo, b=batch, r=row): | def _on_click(e, j=jo, b=batch, r=row): | ||||
| if ( | |||||
| printer_var.get() == "打袋機 DataFlex" | |||||
| and dataflex_busy_ref[0] | |||||
| and active_dataflex_session_ref[0] is not None | |||||
| ): | |||||
| s = active_dataflex_session_ref[0] | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"連續列印進行中,請先按「停止列印」。\n\n" | |||||
| f"工單:{s.job_code}\n" | |||||
| f"品號:{s.item_code}\n" | |||||
| f"品名:{s.item_name}", | |||||
| ) | |||||
| return | |||||
| if selected_row_holder[0] is not None: | if selected_row_holder[0] is not None: | ||||
| set_row_highlight(selected_row_holder[0], False) | set_row_highlight(selected_row_holder[0], False) | ||||
| set_row_highlight(r, True) | set_row_highlight(r, True) | ||||
| @@ -2451,161 +2743,47 @@ def main() -> None: | |||||
| item_id=item_id, | item_id=item_id, | ||||
| stock_in_line_id=stock_in_line_id, | stock_in_line_id=stock_in_line_id, | ||||
| lot_no=lot_no, | lot_no=lot_no, | ||||
| job_order_id=j.get("id"), | |||||
| ) | ) | ||||
| label_text = (lot_no or b).strip() | label_text = (lot_no or b).strip() | ||||
| if continuous: | if continuous: | ||||
| if dataflex_busy_ref[0]: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ) | |||||
| return | |||||
| session = build_dataflex_print_session( | |||||
| j, | |||||
| b, | |||||
| zpl, | |||||
| label_text, | |||||
| ip, | |||||
| port, | |||||
| ) | |||||
| active_dataflex_session_ref[0] = session | |||||
| stop_ev = threading.Event() | stop_ev = threading.Event() | ||||
| stop_win = open_dataflex_stop_window( | stop_win = open_dataflex_stop_window( | ||||
| root, stop_ev, dataflex_stop_win_ref | |||||
| root, | |||||
| stop_ev, | |||||
| dataflex_stop_win_ref, | |||||
| session, | |||||
| ) | |||||
| run_dataflex_continuous_thread( | |||||
| root=root, | |||||
| session=session, | |||||
| stop_event=stop_ev, | |||||
| stop_win=stop_win, | |||||
| dataflex_lock=dataflex_lock, | |||||
| dataflex_busy_ref=dataflex_busy_ref, | |||||
| dataflex_stop_win_ref=dataflex_stop_win_ref, | |||||
| active_session_ref=active_dataflex_session_ref, | |||||
| base_url=base_url_ref[0], | |||||
| set_status_message=set_status_message, | |||||
| on_recorded=lambda: load_job_orders( | |||||
| from_user_date_change=False | |||||
| ), | |||||
| ) | ) | ||||
| def dflex_worker() -> None: | |||||
| with dataflex_lock: | |||||
| if dataflex_busy_ref[0]: | |||||
| root.after( | |||||
| 0, | |||||
| lambda: messagebox.showwarning( | |||||
| "打袋機", | |||||
| "請等待目前列印完成或先停止連續列印。", | |||||
| ), | |||||
| ) | |||||
| return | |||||
| dataflex_busy_ref[0] = True | |||||
| printed = 0 | |||||
| error_shown = False | |||||
| try: | |||||
| # One TCP job per bag (not one endless stream). Persistent socket | |||||
| # caused E1005 over-qty on some DataFlex units after a few labels. | |||||
| send_dataflex_start_job_reset(ip, port, force=True) | |||||
| while not stop_ev.is_set(): | |||||
| send_dataflex_label_with_recovery(ip, port, zpl) | |||||
| printed += 1 | |||||
| if DATAFLEX_UI_PROGRESS_EVERY > 0 and ( | |||||
| printed == 1 | |||||
| or printed % DATAFLEX_UI_PROGRESS_EVERY == 0 | |||||
| ): | |||||
| p = printed | |||||
| root.after( | |||||
| 0, | |||||
| lambda p=p: set_status_message( | |||||
| f"連續打袋列印中… 已印 {p} 張", | |||||
| is_error=False, | |||||
| ), | |||||
| ) | |||||
| if ( | |||||
| DATAFLEX_VERIFY_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_VERIFY_EVERY_LABELS == 0 | |||||
| ): | |||||
| recover_dataflex_if_host_fault(ip, port) | |||||
| if ( | |||||
| DATAFLEX_COOLDOWN_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_COOLDOWN_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| max(0.0, DATAFLEX_COOLDOWN_SEC), | |||||
| ) | |||||
| if ( | |||||
| DATAFLEX_THERMAL_REST_EVERY_LABELS > 0 | |||||
| and printed % DATAFLEX_THERMAL_REST_EVERY_LABELS == 0 | |||||
| ): | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| max(0.0, DATAFLEX_THERMAL_REST_SEC), | |||||
| ) | |||||
| _sleep_interruptible( | |||||
| stop_ev, | |||||
| DATAFLEX_INTER_LABEL_DELAY_SEC, | |||||
| ) | |||||
| except ConnectionRefusedError: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"無法連線至 {ip}:{port},請確認印表機已開機且 IP 正確。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except socket.timeout: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda: set_status_message( | |||||
| f"連線逾時 ({ip}:{port}),請檢查網路與連接埠。", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except OSError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message( | |||||
| f"列印失敗:{e}", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except RuntimeError as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message( | |||||
| f"打袋機錯誤:{e}", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| except Exception as err: | |||||
| error_shown = True | |||||
| root.after( | |||||
| 0, | |||||
| lambda e=err: set_status_message( | |||||
| f"打袋機例外:{e}", | |||||
| is_error=True, | |||||
| ), | |||||
| ) | |||||
| finally: | |||||
| with dataflex_lock: | |||||
| dataflex_busy_ref[0] = False | |||||
| def _done() -> None: | |||||
| dataflex_stop_win_ref[0] = None | |||||
| try: | |||||
| if os.name == "nt": | |||||
| stop_win.attributes("-topmost", False) | |||||
| except tk.TclError: | |||||
| pass | |||||
| try: | |||||
| stop_win.destroy() | |||||
| except tk.TclError: | |||||
| pass | |||||
| if printed > 0: | |||||
| set_status_message( | |||||
| f"連續列印結束:批次 {label_text},已印 {printed} 張", | |||||
| is_error=False, | |||||
| ) | |||||
| jo_id = j.get("id") | |||||
| if jo_id is not None: | |||||
| try: | |||||
| submit_job_order_print_submit( | |||||
| base_url_ref[0], | |||||
| int(jo_id), | |||||
| printed, | |||||
| "DATAFLEX", | |||||
| ) | |||||
| load_job_orders(from_user_date_change=False) | |||||
| except requests.RequestException as ex: | |||||
| messagebox.showwarning( | |||||
| "打袋機", | |||||
| f"列印可能已完成,但伺服器記錄失敗(可再試):{ex}", | |||||
| ) | |||||
| elif not error_shown: | |||||
| set_status_message( | |||||
| "連續列印未印出或已取消", | |||||
| is_error=True, | |||||
| ) | |||||
| root.after(0, _done) | |||||
| threading.Thread(target=dflex_worker, daemon=True).start() | |||||
| else: | else: | ||||
| run_dataflex_fixed_qty_thread( | run_dataflex_fixed_qty_thread( | ||||
| root=root, | root=root, | ||||
| @@ -2803,5 +2981,41 @@ def main() -> None: | |||||
| root.mainloop() | root.mainloop() | ||||
| def _startup_error_log_path() -> str: | |||||
| if getattr(sys, "frozen", False): | |||||
| base = os.path.dirname(sys.executable) | |||||
| else: | |||||
| base = os.path.dirname(os.path.abspath(__file__)) | |||||
| return os.path.join(base, "bag3_startup_error.log") | |||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||
| main() | |||||
| try: | |||||
| main() | |||||
| except SystemExit: | |||||
| raise | |||||
| except Exception: | |||||
| import traceback | |||||
| log_path = _startup_error_log_path() | |||||
| try: | |||||
| with open(log_path, "w", encoding="utf-8") as f: | |||||
| traceback.print_exc(file=f) | |||||
| except OSError: | |||||
| log_path = "(could not write log file)" | |||||
| msg = f"Bag3 啟動失敗,詳情已寫入:\n{log_path}" | |||||
| print(msg, file=sys.stderr) | |||||
| traceback.print_exc() | |||||
| try: | |||||
| _err_root = tk.Tk() | |||||
| _err_root.withdraw() | |||||
| messagebox.showerror("Bag3", msg) | |||||
| _err_root.destroy() | |||||
| except Exception: | |||||
| pass | |||||
| if getattr(sys, "frozen", False): | |||||
| try: | |||||
| input("按 Enter 關閉…") | |||||
| except (EOFError, KeyboardInterrupt): | |||||
| pass | |||||
| sys.exit(1) | |||||
| @@ -1,5 +1,35 @@ | |||||
| # Bag3 Windows exe build (run all commands in this python/ folder) | |||||
| py -m pip install --upgrade pyinstaller | py -m pip install --upgrade pyinstaller | ||||
| py -m pip install --upgrade pywin32 | py -m pip install --upgrade pywin32 | ||||
| py -m pip install --upgrade Pillow "qrcode[pil]" | |||||
| py -m pip install --upgrade Pillow "qrcode[pil]" requests | |||||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| # Output: dist\Bag3\Bag3.exe plus dist\Bag3\_internal\... | |||||
| # Copy the ENTIRE dist\Bag3\ folder to the client PC (not only Bag3.exe). | |||||
| # --- If the client exe flashes and closes --- | |||||
| 1) On the client PC, open cmd in the Bag3 folder and run: | |||||
| Bag3.exe | |||||
| You should see the error in the console, or open bag3_startup_error.log next to Bag3.exe. | |||||
| 2) Compare BUILD machines (both should match): | |||||
| py --version | |||||
| py -m PyInstaller --version | |||||
| py -m pip show pyinstaller pywin32 Pillow qrcode requests | |||||
| A broken build is often caused by: | |||||
| - Different Python major version (e.g. 3.13 vs 3.11) | |||||
| - Incomplete tkinter on that Python (Store Python / partial install) | |||||
| - Old PyInstaller missing Tcl/Tk files in the bundle | |||||
| 3) Rebuild on the machine that works, or reinstall Python from python.org (64-bit) | |||||
| and reinstall deps above, then rebuild. | |||||
| 4) Bag3.spec disables UPX (upx=False) for stability; do not re-enable unless you test on the client. | |||||
| 5) Client needs 64-bit Windows and Microsoft VC++ Redistributable (same as your Python installer). | |||||
| py -m PyInstaller --noconfirm --clean Bag3.spec | |||||
| 6) Antivirus may quarantine files under _internal\ — whitelist the Bag3 folder if the log mentions missing DLL. | |||||
| @@ -91,6 +91,24 @@ public class SecurityConfig { | |||||
| .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | ||||
| .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | ||||
| .hasAuthority("TESTING") | .hasAuthority("TESTING") | ||||
| .requestMatchers(HttpMethod.GET, "/device-presence/ping").authenticated() | |||||
| .requestMatchers(HttpMethod.POST, "/device-presence/heartbeat").authenticated() | |||||
| .requestMatchers(HttpMethod.GET, "/device-presence/active") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/device-presence/history") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/printer-monitor/status") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/printer-monitor/history") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.POST, "/printer-monitor/check") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/label-printer-monitor/status") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.POST, "/label-printer-monitor/check") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .requestMatchers(HttpMethod.GET, "/label-printer-monitor/label-stats") | |||||
| .hasAnyAuthority("TESTING", "ADMIN") | |||||
| .anyRequest().authenticated()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | ||||
| @@ -0,0 +1,48 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| /** | |||||
| * Audit log for FPSMS → M18 udfBomForShop sync (request / response bodies). | |||||
| */ | |||||
| @Entity | |||||
| @Table(name = "m18_bom_shop_sync_log") | |||||
| open class M18BomShopSyncLog : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @Column(name = "bom_id", nullable = false) | |||||
| open var bomId: Long? = null | |||||
| @Column(name = "finished_item_code", length = 100) | |||||
| open var finishedItemCode: String? = null | |||||
| @Column(name = "m18_header_code", length = 200) | |||||
| open var m18HeaderCode: String? = null | |||||
| @Column(name = "request_fingerprint", length = 64) | |||||
| open var requestFingerprint: String? = null | |||||
| @Column(name = "m18_record_id") | |||||
| open var m18RecordId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "m18_api_status", nullable = false) | |||||
| open var m18ApiStatus: Boolean = false | |||||
| @NotNull | |||||
| @Column(name = "synced", nullable = false) | |||||
| open var synced: Boolean = false | |||||
| @Column(name = "message", length = 4000) | |||||
| open var message: String? = null | |||||
| @Column(name = "request_json", columnDefinition = "LONGTEXT") | |||||
| open var requestJson: String? = null | |||||
| @Column(name = "response_json", columnDefinition = "LONGTEXT") | |||||
| open var responseJson: String? = null | |||||
| } | |||||
| @@ -0,0 +1,49 @@ | |||||
| package com.ffii.fpsms.m18.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Long> { | |||||
| fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog? | |||||
| fun findTop100ByBomIdOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog> | |||||
| /** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */ | |||||
| fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog> | |||||
| fun findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc( | |||||
| bomId: Long, | |||||
| requestFingerprint: String, | |||||
| ): M18BomShopSyncLog? | |||||
| fun findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( | |||||
| bomId: Long, | |||||
| m18HeaderCode: String, | |||||
| ): M18BomShopSyncLog? | |||||
| @Query( | |||||
| """ | |||||
| SELECT l FROM M18BomShopSyncLog l | |||||
| WHERE l.deleted = false | |||||
| AND (:syncDateStart IS NULL OR l.created >= :syncDateStart) | |||||
| AND (:syncDateEnd IS NULL OR l.created <= :syncDateEnd) | |||||
| AND ( | |||||
| :finishedItemCode IS NULL OR :finishedItemCode = '' | |||||
| OR LOWER(l.finishedItemCode) LIKE LOWER(CONCAT('%', :finishedItemCode, '%')) | |||||
| ) | |||||
| AND ( | |||||
| :syncStatus IS NULL OR :syncStatus = '' OR :syncStatus = 'all' | |||||
| OR (:syncStatus = 'success' AND l.synced = true) | |||||
| OR (:syncStatus = 'failed' AND l.synced = false) | |||||
| ) | |||||
| ORDER BY l.created DESC, l.id DESC | |||||
| """, | |||||
| ) | |||||
| fun searchForReport( | |||||
| @Param("syncDateStart") syncDateStart: java.time.LocalDateTime?, | |||||
| @Param("syncDateEnd") syncDateEnd: java.time.LocalDateTime?, | |||||
| @Param("finishedItemCode") finishedItemCode: String?, | |||||
| @Param("syncStatus") syncStatus: String?, | |||||
| ): List<M18BomShopSyncLog> | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Outcome of [com.ffii.fpsms.m18.service.M18BomForShopService.saveBomForShopWithVersionRetry] | |||||
| * (may differ from the initial request when header version was bumped). | |||||
| */ | |||||
| data class M18BomForShopSaveAttemptResult( | |||||
| val request: M18BomForShopSaveRequest, | |||||
| val response: GoodsReceiptNoteResponse?, | |||||
| val callError: Throwable?, | |||||
| val versionBumps: Int = 0, | |||||
| /** True when M18 save was skipped because an identical payload was already synced successfully. */ | |||||
| val skippedUnchanged: Boolean = false, | |||||
| ) | |||||
| @@ -0,0 +1,96 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| import com.fasterxml.jackson.annotation.JsonInclude | |||||
| import com.fasterxml.jackson.annotation.JsonProperty | |||||
| /** | |||||
| * M18 save payload for Shop BOM (udfBomForShop). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| * | |||||
| * Same idea as GRN (`mainan` + `ant`): header and lines each wrapped as `{ "values": [ ... ] }`. | |||||
| * Root keys: **`udfbomforshop`** and **`udfproduct`** (same as M18 read [M18BomData]). | |||||
| * (Spelling is **udf**, not "uni".) | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18BomForShopSaveRequest( | |||||
| @JsonProperty("udfbomforshop") | |||||
| val udfbomforshop: M18MainUdfBomForShopWrapper, | |||||
| @JsonProperty("udfproduct") | |||||
| val udfproduct: M18UdfProductWrapper, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopWrapper( | |||||
| val values: List<M18MainUdfBomForShopValue>, | |||||
| ) | |||||
| /** | |||||
| * Header row for udfBomForShop. Field names match M18 read/sample JSON. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18MainUdfBomForShopValue( | |||||
| /** | |||||
| * Existing M18 udfBomForShop header id for **update** (same as FPSMS [Bom.m18Id] after first sync). | |||||
| * Omit or null for **create**. Sent as JSON string for M18 compatibility (like GRN mainan `id`). | |||||
| */ | |||||
| val id: String? = null, | |||||
| val code: String? = null, | |||||
| val beId: Int? = null, | |||||
| val desc: String? = null, | |||||
| @JsonProperty("desc_en") | |||||
| val descEn: String? = null, | |||||
| @JsonProperty("udfBOMCode") | |||||
| val udfBomCode: String? = null, | |||||
| val rev: String? = null, | |||||
| val udfUnit: Long? = null, | |||||
| /** Harvest qty: [Bom.outputQty] × pack multiple from header item stock UOM code (e.g. PACK2LB → ×2), else plain output qty. */ | |||||
| val udfHarvest: String? = null, | |||||
| /** Trailing unit letters from that code (e.g. LB); null if code not parsed. */ | |||||
| val udfHarvestUnit: String? = null, | |||||
| /** Epoch milliseconds (M18-style; same as read `lastModifyDate`). From FPSMS [com.ffii.core.entity.BaseEntity.created] in Asia/Hong_Kong. */ | |||||
| @JsonProperty("udfeffectivedate") | |||||
| val udfEffectiveDate: Long? = null, | |||||
| @JsonProperty("udfYieldratePP") | |||||
| val udfYieldratePP: Number? = null, | |||||
| val udftypeoffood: String? = null, | |||||
| @JsonProperty("udfconfirmed") | |||||
| val udfconfirmed: Boolean? = null, | |||||
| val staffId: Int? = null, | |||||
| val flowTypeId: Int? = null, | |||||
| val virDeptId: Int? = null, | |||||
| val status: String? = null, | |||||
| ) | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductWrapper( | |||||
| val values: List<M18UdfProductSaveValue>, | |||||
| ) | |||||
| /** | |||||
| * Line payload for `udfproduct.values[]`. **`udfBaseUnit`** is the FPSMS UOM **code** for the line. | |||||
| * **`udfPackingUnit`** / **`udfPackingQty`** / **`udfproremark`** are not sent. | |||||
| */ | |||||
| @JsonInclude(JsonInclude.Include.NON_NULL) | |||||
| data class M18UdfProductSaveValue( | |||||
| /** Line id in M18 when updating */ | |||||
| val id: Long? = null, | |||||
| val udfqty: Number? = null, | |||||
| val udfProduct: Long? = null, | |||||
| val udfIngredients: String? = null, | |||||
| /** Line UOM: [com.ffii.fpsms.modules.master.entity.UomConversion.code] (same unit as [udfqty]). */ | |||||
| val udfBaseUnit: String? = null, | |||||
| /** M18 vendor id ([StSearchType.VENDOR]) for the BOM business entity: PP → [M18Config.BEID_PP], PF → [M18Config.BEID_PF]. */ | |||||
| val udfSupplier: Long? = null, | |||||
| /** | |||||
| * M18 UOM id for price/purchase unit on the **M18-linked** PO line (`m18DataLog` present): | |||||
| * [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uomM18] (M18 `unitId`) then | |||||
| * [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uom] → [com.ffii.fpsms.modules.master.entity.UomConversion.m18Id]. | |||||
| */ | |||||
| @JsonProperty("udfpurchaseUnit") | |||||
| val udfpurchaseUnit: Long? = null, | |||||
| /** Line sequence, e.g. " 1" */ | |||||
| val itemNo: String? = null, | |||||
| val udfoptions: String? = null, | |||||
| val udfoption: Number? = null, | |||||
| val udfYieldRate: Number? = null, | |||||
| ) | |||||
| @@ -0,0 +1,18 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Result of scheduling job [com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed]. | |||||
| */ | |||||
| data class M18BomShopBatchSyncSummary( | |||||
| /** BOM rows with deleted=false scanned. */ | |||||
| val totalProcessed: Int, | |||||
| val synced: Int, | |||||
| /** Pushed attempted but [M18BomShopSyncTriggerResult.synced] is false (includes build/API failures). */ | |||||
| val notSynced: Int, | |||||
| /** [SettingNames.M18_BOM_SHOP_SYNC_ENABLED] is off — no BOMs attempted. */ | |||||
| val skippedBecauseFeatureDisabled: Boolean = false, | |||||
| ) { | |||||
| /** One-line summary for logs / scheduler_sync_log.query */ | |||||
| fun toLogQuery(): String = | |||||
| "BOMShop batch: processed=$totalProcessed synced=$synced notSynced=$notSynced skippedFeatureDisabled=$skippedBecauseFeatureDisabled" | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.m18.model | |||||
| /** | |||||
| * Result of [com.ffii.fpsms.modules.master.service.BomService.pushBomToM18ShopIfAllowed] | |||||
| * (e.g. POST /m18/test/bom-shop-sync/{bomId}). | |||||
| */ | |||||
| data class M18BomShopSyncTriggerResult( | |||||
| val bomId: Long, | |||||
| val synced: Boolean, | |||||
| val skippedReason: String? = null, | |||||
| val recordId: Long? = null, | |||||
| val status: Boolean? = null, | |||||
| val messageSummary: String? = null, | |||||
| ) | |||||
| @@ -0,0 +1,648 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.fasterxml.jackson.core.JsonGenerator | |||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.M18Config | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.model.M18BomForShopSaveAttemptResult | |||||
| import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue | |||||
| import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductSaveValue | |||||
| import com.ffii.fpsms.m18.model.M18UdfProductWrapper | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||||
| import com.ffii.fpsms.modules.master.service.ShopService | |||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | |||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.util.LinkedMultiValueMap | |||||
| import reactor.core.publisher.Mono | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| import java.nio.charset.StandardCharsets | |||||
| import java.security.MessageDigest | |||||
| import java.time.ZoneId | |||||
| /** | |||||
| * Push FPSMS BOM + materials to M18 udfBomForShop (similar to GRN save/an). | |||||
| * PUT /root/api/save/udfbomforshop?menuCode=udfbomforshop | |||||
| */ | |||||
| @Service | |||||
| open class M18BomForShopService( | |||||
| private val m18Config: M18Config, | |||||
| private val apiCallerService: ApiCallerService, | |||||
| private val itemUomService: ItemUomService, | |||||
| private val purchaseOrderLineRepository: PurchaseOrderLineRepository, | |||||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||||
| private val shopService: ShopService, | |||||
| private val m18VendorLookupService: M18VendorLookupService, | |||||
| private val m18BomHeaderLookupService: M18BomHeaderLookupService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java) | |||||
| private val savePath = "/root/api/save/udfbomforshop" | |||||
| private val menuCode = "udfbomforshop" | |||||
| /** M18 business entity id for udfBomForShop header (`udfbomforshop.values[0].beId`). */ | |||||
| private val bomShopMainBeId: Int = 29 | |||||
| /** | |||||
| * Stock UOM `code` on the **BOM header item** (e.g. PACK2LB = prefix + pack multiple + unit suffix). | |||||
| * [udfHarvest] = [Bom.outputQty] × middle number; [udfHarvestUnit] = trailing unit (e.g. LB). | |||||
| */ | |||||
| private val bomItemStockUomPackCodeRegex = Regex("^([A-Za-z]+)(\\d+)([A-Za-z]+)$") | |||||
| companion object { | |||||
| private const val HARVEST_CALC_SCALE = 10 | |||||
| internal const val BOM_SHOP_HEADER_VERSION_DIGITS = 4 | |||||
| private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") | |||||
| private fun formatBomShopHeaderCode(itemCode: String, version: Int): String = | |||||
| "BOM${itemCode}V${version.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')}" | |||||
| internal fun normalizedHeaderRevision(versionDigits: String): String = | |||||
| versionDigits.padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') | |||||
| } | |||||
| @Suppress("DEPRECATION") | |||||
| private val objectMapper: ObjectMapper = jacksonObjectMapper().apply { | |||||
| disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) | |||||
| } | |||||
| /** | |||||
| * Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality). | |||||
| * Used with [M18BomShopSyncLog] to decide V0000 vs V0001+. | |||||
| */ | |||||
| open fun contentFingerprint(request: M18BomForShopSaveRequest): String { | |||||
| val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) | |||||
| return sha256Hex(json) | |||||
| } | |||||
| private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest { | |||||
| val v = request.udfbomforshop.values.firstOrNull() | |||||
| ?: return request | |||||
| val headerNorm = v.copy(id = null, code = null, rev = null) | |||||
| val linesSorted = request.udfproduct.values.sortedWith( | |||||
| compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }), | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)), | |||||
| udfproduct = M18UdfProductWrapper(values = linesSorted), | |||||
| ) | |||||
| } | |||||
| private fun sha256Hex(text: String): String { | |||||
| val md = MessageDigest.getInstance("SHA-256") | |||||
| val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8)) | |||||
| return bytes.joinToString("") { "%02x".format(it) } | |||||
| } | |||||
| /** | |||||
| * Builds M18 save body from a persisted BOM (materials loaded). | |||||
| * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only, | |||||
| * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible. | |||||
| * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnnn`. | |||||
| */ | |||||
| open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { | |||||
| val bomId = bom.id ?: return null | |||||
| val routingCode = bom.code ?: return null | |||||
| val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty { | |||||
| logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId") | |||||
| return null | |||||
| } | |||||
| val flowTypeId = resolveFlowTypeId(routingCode) | |||||
| val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null | |||||
| val outputQty = bom.outputQty ?: BigDecimal.ZERO | |||||
| val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty) | |||||
| val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli() | |||||
| val targetBeId = resolveTargetBeId(flowTypeId) | |||||
| val supplierCache = mutableMapOf<String, Long?>() | |||||
| val lines = bom.bomMaterials | |||||
| .filter { it.deleted != true } | |||||
| .sortedBy { it.id ?: 0L } | |||||
| .mapIndexedNotNull { idx, mat -> | |||||
| toProductLine(mat, idx + 1, flowTypeId, targetBeId, supplierCache) | |||||
| } | |||||
| if (lines.isEmpty()) { | |||||
| logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save") | |||||
| return null | |||||
| } | |||||
| val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id( | |||||
| bomId = bomId, | |||||
| itemCode = itemCode, | |||||
| lines = lines, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| bomYield = bom.yield, | |||||
| bomName = bom.name, | |||||
| bomDescription = bom.description, | |||||
| flowTypeId = flowTypeId, | |||||
| headerM18IdOverride = headerM18IdOverride, | |||||
| bomM18Id = bom.m18Id?.takeIf { it > 0 }, | |||||
| ) | |||||
| val header = M18MainUdfBomForShopValue( | |||||
| id = headerM18IdForRequest?.toString(), | |||||
| code = headerCode, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bom.name ?: bom.description, | |||||
| descEn = bom.name ?: bom.description, | |||||
| udfBomCode = itemCode, | |||||
| rev = rev, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bom.yield, | |||||
| udftypeoffood = "半成品", | |||||
| udfconfirmed = true, | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| logger.info( | |||||
| "[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " + | |||||
| "mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})", | |||||
| ) | |||||
| return M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(header)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| } | |||||
| @Suppress("LongParameterList") | |||||
| private fun resolveHeaderCodeAndM18Id( | |||||
| bomId: Long, | |||||
| itemCode: String, | |||||
| lines: List<M18UdfProductSaveValue>, | |||||
| udfUnit: Long, | |||||
| udfHarvest: String, | |||||
| udfHarvestUnit: String?, | |||||
| udfEffectiveDate: Long?, | |||||
| bomYield: BigDecimal?, | |||||
| bomName: String?, | |||||
| bomDescription: String?, | |||||
| flowTypeId: Int, | |||||
| headerM18IdOverride: Long?, | |||||
| bomM18Id: Long?, | |||||
| ): Triple<String, String?, Long?> { | |||||
| val draftHeader = M18MainUdfBomForShopValue( | |||||
| id = null, | |||||
| code = null, | |||||
| beId = bomShopMainBeId, | |||||
| desc = bomName ?: bomDescription, | |||||
| descEn = bomName ?: bomDescription, | |||||
| udfBomCode = itemCode, | |||||
| rev = null, | |||||
| udfUnit = udfUnit, | |||||
| udfHarvest = udfHarvest, | |||||
| udfHarvestUnit = udfHarvestUnit, | |||||
| udfEffectiveDate = udfEffectiveDate, | |||||
| udfYieldratePP = bomYield, | |||||
| udftypeoffood = "半成品", | |||||
| udfconfirmed = true, | |||||
| staffId = 232, | |||||
| flowTypeId = flowTypeId, | |||||
| virDeptId = 117, | |||||
| status = "Y", | |||||
| ) | |||||
| val draftRequest = M18BomForShopSaveRequest( | |||||
| udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)), | |||||
| udfproduct = M18UdfProductWrapper(values = lines), | |||||
| ) | |||||
| val fp = contentFingerprint(draftRequest) | |||||
| val forcedId = headerM18IdOverride?.takeIf { it > 0 } | |||||
| if (forcedId != null) { | |||||
| val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId) | |||||
| val codeForUpdate = | |||||
| latest?.m18HeaderCode?.takeIf { it.isNotBlank() } | |||||
| ?: formatBomShopHeaderCode(itemCode, 0) | |||||
| val forcedRev = parseTrailingVersion(codeForUpdate) | |||||
| ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) | |||||
| return Triple(codeForUpdate, forcedRev, forcedId) | |||||
| } | |||||
| // Identical BOM details already synced — reuse header code + M18 id (update), never allocate a new version. | |||||
| findSuccessfulSyncByFingerprint(bomId, fp)?.let { match -> | |||||
| val reuseCode = match.m18HeaderCode?.trim().orEmpty() | |||||
| if (reuseCode.isNotEmpty()) { | |||||
| val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: bomM18Id | |||||
| val revReuse = parseTrailingVersion(reuseCode) | |||||
| ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) | |||||
| return Triple(reuseCode, revReuse, reuseId) | |||||
| } | |||||
| } | |||||
| // Content changed — next version number (new M18 header code). | |||||
| val maxV = maxVersionFromLogs(bomId, itemCode) | |||||
| val nextV = maxV + 1 | |||||
| val newCode = formatBomShopHeaderCode(itemCode, nextV) | |||||
| val rev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') | |||||
| return Triple(newCode, rev, null) | |||||
| } | |||||
| private fun findSuccessfulSyncByFingerprint(bomId: Long, fingerprint: String): M18BomShopSyncLog? = | |||||
| m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc( | |||||
| bomId, | |||||
| fingerprint, | |||||
| ) | |||||
| private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { | |||||
| val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") | |||||
| // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnnn. | |||||
| return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId) | |||||
| .mapNotNull { row -> | |||||
| val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { | |||||
| extractHeaderCodeFromJson(row.requestJson).orEmpty() | |||||
| } | |||||
| versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull() | |||||
| } | |||||
| .maxOrNull() ?: -1 | |||||
| } | |||||
| private fun extractHeaderCodeFromJson(json: String?): String? { | |||||
| if (json.isNullOrBlank()) return null | |||||
| return runCatching { | |||||
| val node = objectMapper.readTree(json) | |||||
| val text = node.path("udfbomforshop").path("values").path(0).path("code").asText() | |||||
| text.trim().takeIf { it.isNotEmpty() } | |||||
| }.getOrNull() | |||||
| } | |||||
| private fun parseTrailingVersion(headerCode: String): String? = | |||||
| Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.let { normalizedHeaderRevision(it) } | |||||
| /** | |||||
| * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code] | |||||
| * (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix. | |||||
| * Falls back to plain [outputQty] and null unit when item/stock UOM/code is missing or does not match. | |||||
| */ | |||||
| private fun resolveUdfHarvestFields(bom: Bom, outputQty: BigDecimal): Pair<String, String?> { | |||||
| val itemId = bom.item?.id | |||||
| if (itemId == null) { | |||||
| logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty() | |||||
| if (stockCode.isEmpty()) { | |||||
| logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}") | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode) | |||||
| if (match == null) { | |||||
| logger.warn( | |||||
| "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " + | |||||
| "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val mult = match.groupValues[2].toBigDecimalOrNull() | |||||
| if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) { | |||||
| logger.warn( | |||||
| "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}", | |||||
| ) | |||||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||||
| } | |||||
| val unitSuffix = match.groupValues[3] | |||||
| val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros() | |||||
| return harvestQty.toPlainString() to unitSuffix | |||||
| } | |||||
| private fun toProductLine( | |||||
| mat: BomMaterial, | |||||
| lineNo: Int, | |||||
| flowTypeId: Int, | |||||
| targetBeId: Long?, | |||||
| supplierCache: MutableMap<String, Long?>, | |||||
| ): M18UdfProductSaveValue? { | |||||
| val proId = mat.item?.m18Id?.takeIf { it > 0 } ?: run { | |||||
| logger.warn("[M18 BOM] material item m18Id missing bomMaterialId=${mat.id} itemId=${mat.item?.id}") | |||||
| return null | |||||
| } | |||||
| val udfBaseUnit = mat.uom?.code?.trim()?.takeIf { it.isNotEmpty() } ?: run { | |||||
| logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}") | |||||
| return null | |||||
| } | |||||
| val itemId = mat.item?.id | |||||
| val latestPoLine = itemId?.let { id -> | |||||
| pickPreferredPoLine( | |||||
| purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 10)), | |||||
| targetBeId, | |||||
| ) | |||||
| } | |||||
| val supplierM18Id = resolveSupplierM18Id(latestPoLine, flowTypeId, supplierCache) | |||||
| /** | |||||
| * M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync, | |||||
| * else [PurchaseOrderLine.uom] when uomM18 is missing. | |||||
| */ | |||||
| val purchaseUnitM18Id = | |||||
| latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L } | |||||
| ?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L } | |||||
| val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble() | |||||
| return M18UdfProductSaveValue( | |||||
| id = mat.m18Id?.takeIf { it > 0 }, | |||||
| udfqty = udfqty, | |||||
| udfProduct = proId, | |||||
| udfIngredients = mat.itemName ?: mat.item?.name, | |||||
| udfBaseUnit = udfBaseUnit, | |||||
| udfSupplier = supplierM18Id, | |||||
| udfpurchaseUnit = purchaseUnitM18Id, | |||||
| itemNo = String.format("%6d", lineNo), | |||||
| udfoptions = "", | |||||
| udfoption = 0.0, | |||||
| udfYieldRate = 0.0, | |||||
| ) | |||||
| } | |||||
| /** Prefer a PO line whose header [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18BeId] matches the BOM BE. */ | |||||
| private fun pickPreferredPoLine(lines: List<PurchaseOrderLine>, preferredBeId: Long?): PurchaseOrderLine? { | |||||
| if (lines.isEmpty()) return null | |||||
| if (preferredBeId == null) return lines.first() | |||||
| return lines.firstOrNull { it.purchaseOrder?.m18BeId == preferredBeId } ?: lines.first() | |||||
| } | |||||
| private fun resolveTargetBeId(flowTypeId: Int): Long? = when (flowTypeId) { | |||||
| 2 -> m18Config.BEID_PF.toLongOrNull() | |||||
| 3 -> m18Config.BEID_PP.toLongOrNull() | |||||
| else -> null | |||||
| } | |||||
| /** | |||||
| * Resolves M18 vendor id for BOM material line supplier: | |||||
| * - PF BOMs: M18 search by supplier code + [M18Config.BEID_PF] | |||||
| * - PP BOMs: M18 search by supplier code + [M18Config.BEID_PP] (never local [Shop.m18Id] first — duplicate codes may be PF ids) | |||||
| */ | |||||
| private fun resolveSupplierM18Id( | |||||
| latestPoLine: PurchaseOrderLine?, | |||||
| flowTypeId: Int, | |||||
| cache: MutableMap<String, Long?>, | |||||
| ): Long? { | |||||
| val po = latestPoLine?.purchaseOrder | |||||
| val supplier = po?.supplier | |||||
| val directM18Id = supplier?.m18Id?.takeIf { it > 0L } | |||||
| val supplierCode = supplier?.code?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val targetBeId = resolveTargetBeId(flowTypeId) | |||||
| val poBeId = po?.m18BeId | |||||
| if (supplierCode == null) { | |||||
| return directM18Id | |||||
| } | |||||
| if (flowTypeId == 2 || flowTypeId == 3) { | |||||
| val cacheKey = "$supplierCode|$flowTypeId" | |||||
| cache[cacheKey]?.let { return it } | |||||
| val beId = if (flowTypeId == 2) m18Config.BEID_PF else m18Config.BEID_PP | |||||
| val beLabel = if (flowTypeId == 2) "PF" else "PP" | |||||
| val resolved = | |||||
| m18VendorLookupService.findVendorM18IdByCode(supplierCode, beId) | |||||
| ?: directM18Id.takeIf { poBeId != null && poBeId == targetBeId } | |||||
| if (resolved == null) { | |||||
| logger.warn("[M18 BOM] $beLabel vendor M18 id not found for supplierCode=$supplierCode") | |||||
| } | |||||
| cache[cacheKey] = resolved | |||||
| return resolved | |||||
| } | |||||
| return shopService.findVendorByCode(supplierCode)?.m18Id?.takeIf { it > 0L } | |||||
| ?: directM18Id | |||||
| } | |||||
| private fun resolveFlowTypeId(code: String): Int = when { | |||||
| code.startsWith("TOA") -> 1 | |||||
| code.startsWith("BOMPP") || code.startsWith("PP") -> 3 | |||||
| code.startsWith("BOMPF") || code.startsWith("PF") || code.startsWith("PFP") -> 2 | |||||
| else -> 1 | |||||
| } | |||||
| /** M18 rejects duplicate header [M18MainUdfBomForShopValue.code] on create (core_101903). */ | |||||
| open fun isSameCodeFoundError(resp: GoodsReceiptNoteResponse?): Boolean { | |||||
| if (resp == null || resp.status) return false | |||||
| return resp.messages.any { msg -> | |||||
| msg.msgCode == "core_101903" || | |||||
| msg.msgDetail?.contains("Same Code found", ignoreCase = true) == true | |||||
| } | |||||
| } | |||||
| private fun withHeaderM18Id( | |||||
| request: M18BomForShopSaveRequest, | |||||
| headerCode: String, | |||||
| m18Id: Long, | |||||
| ): M18BomForShopSaveRequest { | |||||
| val header = request.udfbomforshop.values.firstOrNull() | |||||
| ?: return request | |||||
| val rev = parseTrailingVersion(headerCode) ?: header.rev | |||||
| val newHeader = header.copy( | |||||
| id = m18Id.toString(), | |||||
| code = headerCode, | |||||
| rev = rev, | |||||
| ) | |||||
| return request.copy( | |||||
| udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)), | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * Increments `BOM{item}Vnnnn` tail, clears header `id` (new M18 row), updates `rev`. | |||||
| * Returns null when [udfBomCode] is missing. | |||||
| */ | |||||
| open fun bumpHeaderVersionForRetry(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest? { | |||||
| val header = request.udfbomforshop.values.firstOrNull() ?: return null | |||||
| val itemCode = header.udfBomCode?.trim().orEmpty().ifEmpty { return null } | |||||
| val currentCode = header.code?.trim().orEmpty() | |||||
| val currentV = | |||||
| parseTrailingVersion(currentCode)?.toIntOrNull() | |||||
| ?: Regex("V(\\d+)$").find(currentCode)?.groupValues?.get(1)?.toIntOrNull() | |||||
| ?: -1 | |||||
| val nextV = currentV + 1 | |||||
| val newCode = formatBomShopHeaderCode(itemCode, nextV) | |||||
| val newRev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') | |||||
| val newHeader = header.copy(id = null, code = newCode, rev = newRev) | |||||
| return request.copy( | |||||
| udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)), | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * Saves to M18. On duplicate code: update existing row when details match a prior sync or M18 lookup; | |||||
| * bump version only when BOM content (fingerprint) is new. | |||||
| */ | |||||
| open fun saveBomForShopWithVersionRetry( | |||||
| request: M18BomForShopSaveRequest, | |||||
| bomId: Long, | |||||
| maxSameCodeRetries: Int = 20, | |||||
| ): M18BomForShopSaveAttemptResult { | |||||
| val fp = contentFingerprint(request) | |||||
| findSuccessfulSyncByFingerprint(bomId, fp)?.let { match -> | |||||
| val reuseCode = match.m18HeaderCode?.trim().orEmpty() | |||||
| val reuseId = match.m18RecordId?.takeIf { it > 0L } | |||||
| if (reuseCode.isNotEmpty() && reuseId != null) { | |||||
| logger.info( | |||||
| "[M18 BOM] Unchanged BOM details; skip new code (reuse headerCode={} m18Id={})", | |||||
| reuseCode, | |||||
| reuseId, | |||||
| ) | |||||
| return M18BomForShopSaveAttemptResult( | |||||
| request = withHeaderM18Id(request, reuseCode, reuseId), | |||||
| response = GoodsReceiptNoteResponse(recordId = reuseId, status = true), | |||||
| callError = null, | |||||
| versionBumps = 0, | |||||
| skippedUnchanged = true, | |||||
| ) | |||||
| } | |||||
| } | |||||
| var current = request | |||||
| var bumps = 0 | |||||
| var lastResp: GoodsReceiptNoteResponse? = null | |||||
| var lastError: Throwable? = null | |||||
| val attachIdAttemptedForCode = mutableSetOf<String>() | |||||
| while (true) { | |||||
| lastError = null | |||||
| try { | |||||
| lastResp = saveBomForShop(current) | |||||
| } catch (e: Exception) { | |||||
| lastError = e | |||||
| break | |||||
| } | |||||
| if ( | |||||
| lastResp == null || | |||||
| !isSameCodeFoundError(lastResp) || | |||||
| bumps >= maxSameCodeRetries | |||||
| ) { | |||||
| break | |||||
| } | |||||
| val header = current.udfbomforshop.values.firstOrNull() ?: break | |||||
| val code = header.code?.trim().orEmpty() | |||||
| // Same details already synced — update existing row, do not create a new version code. | |||||
| val reuseFromSync = resolveReuseFromSuccessfulSync(bomId, fp) | |||||
| if (reuseFromSync != null) { | |||||
| val (reuseCode, reuseId) = reuseFromSync | |||||
| current = withHeaderM18Id(current, reuseCode, reuseId) | |||||
| logger.info( | |||||
| "[M18 BOM] Same Code found; same details as prior sync — update headerCode={} m18Id={}", | |||||
| reuseCode, | |||||
| reuseId, | |||||
| ) | |||||
| continue | |||||
| } | |||||
| // Code exists in M18 without header id — attach id and update (same code, not a new version). | |||||
| if (header.id.isNullOrBlank() && code.isNotEmpty() && code !in attachIdAttemptedForCode) { | |||||
| attachIdAttemptedForCode.add(code) | |||||
| val m18Id = m18BomHeaderLookupService.findM18IdByHeaderCode(code) | |||||
| if (m18Id != null) { | |||||
| current = withHeaderM18Id(current, code, m18Id) | |||||
| logger.info("[M18 BOM] Same Code found; attach M18 id={} for headerCode={}", m18Id, code) | |||||
| continue | |||||
| } | |||||
| } | |||||
| // Same code already holds this exact content in our sync log — update that row. | |||||
| if (code.isNotEmpty()) { | |||||
| val logAtCode = | |||||
| m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( | |||||
| bomId, | |||||
| code, | |||||
| ) | |||||
| if (logAtCode?.requestFingerprint == fp && logAtCode.m18RecordId != null) { | |||||
| current = withHeaderM18Id(current, code, logAtCode.m18RecordId!!) | |||||
| logger.info( | |||||
| "[M18 BOM] Same Code found; headerCode={} already has matching content — update m18Id={}", | |||||
| code, | |||||
| logAtCode.m18RecordId, | |||||
| ) | |||||
| continue | |||||
| } | |||||
| } | |||||
| // Content differs from existing code — allocate next version (new code for new details). | |||||
| val bumped = bumpHeaderVersionForRetry(current) ?: break | |||||
| val bumpedCode = bumped.udfbomforshop.values.firstOrNull()?.code?.trim().orEmpty() | |||||
| if (bumpedCode.isNotEmpty()) { | |||||
| val logAtBumped = | |||||
| m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( | |||||
| bomId, | |||||
| bumpedCode, | |||||
| ) | |||||
| if (logAtBumped?.requestFingerprint == fp && logAtBumped.m18RecordId != null) { | |||||
| current = withHeaderM18Id(bumped, bumpedCode, logAtBumped.m18RecordId!!) | |||||
| logger.info( | |||||
| "[M18 BOM] Version {} already synced with same details — update m18Id={}", | |||||
| bumpedCode, | |||||
| logAtBumped.m18RecordId, | |||||
| ) | |||||
| continue | |||||
| } | |||||
| } | |||||
| current = bumped | |||||
| bumps++ | |||||
| logger.info( | |||||
| "[M18 BOM] Same Code found; content changed — bump version (#$bumps) headerCode={}", | |||||
| bumpedCode, | |||||
| ) | |||||
| } | |||||
| return M18BomForShopSaveAttemptResult( | |||||
| request = current, | |||||
| response = lastResp, | |||||
| callError = lastError, | |||||
| versionBumps = bumps, | |||||
| ) | |||||
| } | |||||
| private fun resolveReuseFromSuccessfulSync(bomId: Long, fingerprint: String): Pair<String, Long>? { | |||||
| val match = findSuccessfulSyncByFingerprint(bomId, fingerprint) ?: return null | |||||
| val reuseCode = match.m18HeaderCode?.trim().orEmpty().ifEmpty { return null } | |||||
| val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: return null | |||||
| return reuseCode to reuseId | |||||
| } | |||||
| open fun toJson(request: M18BomForShopSaveRequest): String = | |||||
| objectMapper.writeValueAsString(request) | |||||
| open fun toJson(response: GoodsReceiptNoteResponse): String = | |||||
| objectMapper.writeValueAsString(response) | |||||
| open fun saveBomForShop(request: M18BomForShopSaveRequest): GoodsReceiptNoteResponse? = | |||||
| saveBomForShopMono(request).block() | |||||
| open fun saveBomForShopMono(request: M18BomForShopSaveRequest): Mono<GoodsReceiptNoteResponse> { | |||||
| val queryParams = LinkedMultiValueMap<String, String>().apply { | |||||
| add("menuCode", menuCode) | |||||
| } | |||||
| val qs = queryParams.entries.flatMap { (k, v) -> v.map { "$k=$it" } }.joinToString("&") | |||||
| val fullUrl = "${m18Config.BASE_URL}$savePath?$qs" | |||||
| val bodyJson = objectMapper.writeValueAsString(request) | |||||
| logger.info("[M18 BOM udfBomForShop] PUT url=$fullUrl bodyUtf8Bytes=${bodyJson.toByteArray(StandardCharsets.UTF_8).size}") | |||||
| logger.debug("[M18 BOM udfBomForShop] PUT body=$bodyJson") | |||||
| return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>( | |||||
| urlPath = savePath, | |||||
| queryParams = queryParams, | |||||
| bodyJson = bodyJson, | |||||
| ).doOnSuccess { r -> | |||||
| logger.info("[M18 BOM udfBomForShop] response status=${r.status} recordId=${r.recordId} messages=${r.messages}") | |||||
| }.doOnError { e -> | |||||
| logger.error("[M18 BOM udfBomForShop] failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.model.M18BomListResponse | |||||
| import com.ffii.fpsms.m18.model.M18CommonListRequest | |||||
| import com.ffii.fpsms.m18.model.StSearchType | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| /** | |||||
| * M18 udfBomForShop header lookup by [code] — isolated from [M18MasterDataService] / [M18BomForShopService] cycles. | |||||
| */ | |||||
| @Service | |||||
| open class M18BomHeaderLookupService( | |||||
| private val apiCallerService: ApiCallerService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18BomHeaderLookupService::class.java) | |||||
| private val fetchListApi = "/search/search" | |||||
| open fun findM18IdByHeaderCode(headerCode: String): Long? { | |||||
| val trimmed = headerCode.trim() | |||||
| if (trimmed.isEmpty()) return null | |||||
| val conds = "(code=equal=$trimmed)" | |||||
| val listResponse = try { | |||||
| apiCallerService.get<M18BomListResponse, M18CommonListRequest>( | |||||
| fetchListApi, | |||||
| M18CommonListRequest( | |||||
| stSearch = StSearchType.BOM.value, | |||||
| params = null, | |||||
| conds = conds, | |||||
| ), | |||||
| ).block() | |||||
| } catch (e: Exception) { | |||||
| logger.warn("(findM18IdByHeaderCode) M18 search failed code=$trimmed: ${e.message}") | |||||
| null | |||||
| } | |||||
| return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L } | |||||
| } | |||||
| } | |||||
| @@ -12,6 +12,7 @@ import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderLineStatus | |||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderLineService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderLineService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderLineRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderLineRequest | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | import com.ffii.fpsms.modules.master.entity.ItemUom | ||||
| @@ -23,7 +24,6 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType | |||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.sql.SQLException | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import kotlin.reflect.full.memberProperties | import kotlin.reflect.full.memberProperties | ||||
| @@ -35,6 +35,7 @@ open class M18DeliveryOrderService( | |||||
| val apiCallerService: ApiCallerService, | val apiCallerService: ApiCallerService, | ||||
| val m18DataLogService: M18DataLogService, | val m18DataLogService: M18DataLogService, | ||||
| val deliveryOrderService: DeliveryOrderService, | val deliveryOrderService: DeliveryOrderService, | ||||
| val deliveryOrderRepository: DeliveryOrderRepository, | |||||
| val deliveryOrderLineService: DeliveryOrderLineService, | val deliveryOrderLineService: DeliveryOrderLineService, | ||||
| val itemsService: ItemsService, | val itemsService: ItemsService, | ||||
| val shopService: ShopService, | val shopService: ShopService, | ||||
| @@ -106,7 +107,6 @@ open class M18DeliveryOrderService( | |||||
| if (request.dDateEqual != null) { | if (request.dDateEqual != null) { | ||||
| shopPoConds += "=and=(${dDateEqualConds})" | shopPoConds += "=and=(${dDateEqualConds})" | ||||
| } | } | ||||
| logger.info("shopPoConds: ${shopPoConds}") | logger.info("shopPoConds: ${shopPoConds}") | ||||
| val shopPoParams = M18PurchaseOrderListRequest( | val shopPoParams = M18PurchaseOrderListRequest( | ||||
| @@ -151,20 +151,41 @@ open class M18DeliveryOrderService( | |||||
| return deliveryOrder | return deliveryOrder | ||||
| } | } | ||||
| open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { | |||||
| open fun saveDeliveryOrders(request: M18CommonRequest, skipExistingDo: Boolean = false): SyncResult { | |||||
| val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | val deliveryOrdersWithType = getDeliveryOrdersWithType(request) | ||||
| return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType) | |||||
| return saveDeliveryOrdersWithPreparedList( | |||||
| deliveryOrdersWithType, | |||||
| syncisExtra = false, | |||||
| skipExistingDo = skipExistingDo, | |||||
| ) | |||||
| } | } | ||||
| /** | /** | ||||
| * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | ||||
| * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | ||||
| * | |||||
| * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | |||||
| * No M18-side "加單" filtering is used. | |||||
| * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. | |||||
| */ | */ | ||||
| open fun saveDeliveryOrderByCode(code: String): SyncResult { | |||||
| open fun saveDeliveryOrderByCode( | |||||
| code: String, | |||||
| isExtraSync: Boolean = false, | |||||
| newOnly: Boolean = false, | |||||
| ): SyncResult { | |||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | |||||
| return SyncResult( | |||||
| totalProcessed = 1, | |||||
| totalSuccess = 0, | |||||
| totalFail = 0, | |||||
| query = "skipped (newOnly=true): delivery_order.code already exists: $code", | |||||
| ) | |||||
| } | |||||
| val conds = "(code=equal=$code)" | |||||
| val searchRequest = M18PurchaseOrderListRequest( | val searchRequest = M18PurchaseOrderListRequest( | ||||
| stSearch = "po", | stSearch = "po", | ||||
| params = null, | params = null, | ||||
| conds = "(code=equal=$code)" | |||||
| conds = conds | |||||
| ) | ) | ||||
| val doListResponse = try { | val doListResponse = try { | ||||
| apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | ||||
| @@ -183,30 +204,36 @@ open class M18DeliveryOrderService( | |||||
| totalProcessed = 1, | totalProcessed = 1, | ||||
| totalSuccess = 0, | totalSuccess = 0, | ||||
| totalFail = 1, | totalFail = 1, | ||||
| query = "code=equal=$code" | |||||
| query = conds | |||||
| ) | ) | ||||
| } | } | ||||
| val prepared = M18PurchaseOrderListResponseWithType( | val prepared = M18PurchaseOrderListResponseWithType( | ||||
| valuesWithType = mutableListOf(Pair(PurchaseOrderType.SHOP, doListResponse)), | valuesWithType = mutableListOf(Pair(PurchaseOrderType.SHOP, doListResponse)), | ||||
| query = "code=equal=$code" | |||||
| query = conds | |||||
| ) | ) | ||||
| return saveDeliveryOrdersWithPreparedList(prepared) | |||||
| return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync, skipExistingDo = newOnly) | |||||
| } | } | ||||
| private fun saveDeliveryOrdersWithPreparedList( | private fun saveDeliveryOrdersWithPreparedList( | ||||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType? | |||||
| deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, | |||||
| syncisExtra: Boolean = false, | |||||
| skipExistingDo: Boolean = false, | |||||
| ): SyncResult { | ): SyncResult { | ||||
| logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") | ||||
| if (skipExistingDo) { | |||||
| logger.info("skipExistingDo=true — local delivery orders will not be updated") | |||||
| } | |||||
| val successList = mutableListOf<Long>() | val successList = mutableListOf<Long>() | ||||
| val skippedList = mutableListOf<Long>() | |||||
| val successDetailList = mutableListOf<Long>() | val successDetailList = mutableListOf<Long>() | ||||
| val failList = mutableListOf<Long>() | val failList = mutableListOf<Long>() | ||||
| val failDetailList = mutableListOf<Long>() | val failDetailList = mutableListOf<Long>() | ||||
| val failItemDetailList = mutableListOf<Long>() | val failItemDetailList = mutableListOf<Long>() | ||||
| val uomByM18IdCache = mutableMapOf<Long, ItemUom?>() | val uomByM18IdCache = mutableMapOf<Long, ItemUom?>() | ||||
| val itemIdCache = mutableMapOf<Long, Long?>() | |||||
| val itemIdCache = mutableMapOf<Long, Long>() | |||||
| val stockUomIdCache = mutableMapOf<Pair<Long, Long>, Long?>() | val stockUomIdCache = mutableMapOf<Pair<Long, Long>, Long?>() | ||||
| val doRefType = "Delivery Order" | val doRefType = "Delivery Order" | ||||
| @@ -223,6 +250,22 @@ open class M18DeliveryOrderService( | |||||
| if (deliveryOrdersValues != null) { | if (deliveryOrdersValues != null) { | ||||
| deliveryOrdersValues.forEach { deliveryOrder -> | deliveryOrdersValues.forEach { deliveryOrder -> | ||||
| if (skipExistingDo) { | |||||
| val latestDeliveryOrderLog = | |||||
| m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) | |||||
| val existingByM18 = latestDeliveryOrderLog?.id?.let { | |||||
| deliveryOrderService.findByM18DataLogId(it) | |||||
| } | |||||
| if (existingByM18 != null && existingByM18.deleted != true) { | |||||
| logger.info( | |||||
| "${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} " + | |||||
| "code=${existingByM18.code} localId=${existingByM18.id} status=${existingByM18.status}" | |||||
| ) | |||||
| skippedList.add(deliveryOrder.id) | |||||
| return@forEach | |||||
| } | |||||
| } | |||||
| val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id) | val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id) | ||||
| var deliveryOrderId: Long? = null //FP-MTMS | var deliveryOrderId: Long? = null //FP-MTMS | ||||
| @@ -236,6 +279,14 @@ open class M18DeliveryOrderService( | |||||
| // delivery_order + m18_data_log table | // delivery_order + m18_data_log table | ||||
| if (mainpo != null) { | if (mainpo != null) { | ||||
| if (skipExistingDo && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(mainpo.code)) { | |||||
| logger.info( | |||||
| "${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} code=${mainpo.code} (local DO exists by code)" | |||||
| ) | |||||
| skippedList.add(deliveryOrder.id) | |||||
| return@forEach | |||||
| } | |||||
| // Find the latest m18 data log by m18 id & type | // Find the latest m18 data log by m18 id & type | ||||
| // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | ||||
| val latestDeliveryOrderLog = | val latestDeliveryOrderLog = | ||||
| @@ -283,7 +334,8 @@ open class M18DeliveryOrderService( | |||||
| m18DataLogId = saveM18DeliveryOrderLog.id, | m18DataLogId = saveM18DeliveryOrderLog.id, | ||||
| handlerId = null, | handlerId = null, | ||||
| m18BeId = mainpo.beId, | m18BeId = mainpo.beId, | ||||
| deleted = mainpo.udfIsVoid == true | |||||
| deleted = mainpo.udfIsVoid == true, | |||||
| isExtra = syncisExtra, | |||||
| ) | ) | ||||
| val saveDeliveryOrderResponse = | val saveDeliveryOrderResponse = | ||||
| @@ -354,14 +406,10 @@ open class M18DeliveryOrderService( | |||||
| // logger.info("${doLineRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLineLog.id}") | // logger.info("${doLineRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLineLog.id}") | ||||
| // logger.info("${doLineRefType}: Finding item...") | // logger.info("${doLineRefType}: Finding item...") | ||||
| val itemId: Long? = itemIdCache.getOrPut(line.proId) { | |||||
| val item = itemsService.findByM18Id(line.proId) | |||||
| if (item == null) { | |||||
| m18MasterDataService.saveProduct(line.proId)?.id | |||||
| } else { | |||||
| item.id | |||||
| val itemId: Long? = itemIdCache[line.proId] | |||||
| ?: m18MasterDataService.resolveLocalItemId(line.proId)?.also { | |||||
| itemIdCache[line.proId] = it | |||||
| } | } | ||||
| } | |||||
| val stockUomId: Long? = if (itemId != null) { | val stockUomId: Long? = if (itemId != null) { | ||||
| val key = line.proId to line.unitId // safe key | val key = line.proId to line.unitId // safe key | ||||
| @@ -373,6 +421,23 @@ open class M18DeliveryOrderService( | |||||
| // logger.info("${doLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | // logger.info("${doLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | ||||
| if (itemId == null) { | |||||
| failDetailList.add(line.id) | |||||
| failItemDetailList.add(line.proId) | |||||
| logger.error( | |||||
| "${doLineRefType}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}" | |||||
| ) | |||||
| val errorSaveM18DeliveryOrderLineLogRequest = SaveM18DataLogRequest( | |||||
| id = saveM18DeliveryOrderLineLog.id, | |||||
| dataLog = mutableMapOf( | |||||
| "Exception Message" to "Cannot resolve local item for M18 proId=${line.proId}" | |||||
| ), | |||||
| statusEnum = M18DataLogStatus.FAIL | |||||
| ) | |||||
| m18DataLogService.saveM18DataLog(errorSaveM18DeliveryOrderLineLogRequest) | |||||
| return@forEach | |||||
| } | |||||
| try { | try { | ||||
| // Find the delivery_order_line if exist | // Find the delivery_order_line if exist | ||||
| // logger.info("${doLineRefType}: Finding exising delivery order line...") | // logger.info("${doLineRefType}: Finding exising delivery order line...") | ||||
| @@ -387,14 +452,27 @@ open class M18DeliveryOrderService( | |||||
| itemUomService.findByM18Id(line.unitId) | itemUomService.findByM18Id(line.unitId) | ||||
| } | } | ||||
| val m18UomId = itemUom?.uom?.id | |||||
| val sourceQty = line.qty | |||||
| val stockQty = | |||||
| if (itemId != null && m18UomId != null && m18UomId == stockUomId) { | |||||
| // M18 line unit is already the stock unit — skip ratio conversion | |||||
| // (avoids bad qty when item_uom ratioN/ratioD hold spec numbers like 350g). | |||||
| sourceQty | |||||
| } else if (itemId != null && m18UomId != null) { | |||||
| itemUomService.convertQtyToStockQty(itemId, m18UomId, sourceQty) | |||||
| } else { | |||||
| sourceQty | |||||
| } | |||||
| val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest( | val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest( | ||||
| id = existingDeliveryOrderLine?.id, | id = existingDeliveryOrderLine?.id, | ||||
| itemId = itemId, | itemId = itemId, | ||||
| uomIdM18 = itemUom?.uom?.id, | |||||
| uomIdM18 = m18UomId, | |||||
| uomId= stockUomId, | uomId= stockUomId, | ||||
| deliveryOrderId = deliveryOrderId, | deliveryOrderId = deliveryOrderId, | ||||
| qtyM18 = line.qty, | |||||
| qty = itemUomService.convertQtyToStockQty(itemId?:0, itemUom?.uom?.id?: 0, line.qty), | |||||
| qtyM18 = sourceQty, | |||||
| qty = stockQty, | |||||
| up = line.up, | up = line.up, | ||||
| price = line.amt, | price = line.amt, | ||||
| // m18CurrencyId = mainpo.curId, | // m18CurrencyId = mainpo.curId, | ||||
| @@ -421,7 +499,7 @@ open class M18DeliveryOrderService( | |||||
| successDetailList.add(line.id) | successDetailList.add(line.id) | ||||
| // logger.info("${doLineRefType}: Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | // logger.info("${doLineRefType}: Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | ||||
| //logger.info("${doLineRefType}: Saved delivery order line. ID: ${saveDeliveryOrderLineResponse.id} | M18 Line ID: ${line.id} | Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | //logger.info("${doLineRefType}: Saved delivery order line. ID: ${saveDeliveryOrderLineResponse.id} | M18 Line ID: ${line.id} | Delivery order ID: ${deliveryOrderId} | M18 ID: ${deliveryOrder.id}") | ||||
| } catch (e: SQLException) { | |||||
| } catch (e: Exception) { | |||||
| failDetailList.add(line.id) | failDetailList.add(line.id) | ||||
| failItemDetailList.add(line.proId) | failItemDetailList.add(line.proId) | ||||
| // logger.error("${doLineRefType}: Saving Failure!") | // logger.error("${doLineRefType}: Saving Failure!") | ||||
| @@ -528,6 +606,9 @@ open class M18DeliveryOrderService( | |||||
| // End of save. Check result | // End of save. Check result | ||||
| logger.info("Total Success (${doRefType}) (${successList.size})") | logger.info("Total Success (${doRefType}) (${successList.size})") | ||||
| logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") | logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") | ||||
| if (skippedList.isNotEmpty()) { | |||||
| logger.info("Total Skipped (${doRefType}) (${skippedList.size}): $skippedList") | |||||
| } | |||||
| logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") | logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") | ||||
| logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | ||||
| @@ -540,11 +621,12 @@ open class M18DeliveryOrderService( | |||||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | ||||
| val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" | |||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = successList.size + failList.size, | |||||
| totalProcessed = successList.size + failList.size + skippedList.size, | |||||
| totalSuccess = successList.size, | totalSuccess = successList.size, | ||||
| totalFail = failList.size, | totalFail = failList.size, | ||||
| query = deliveryOrdersWithType?.query ?: "" | |||||
| query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -188,6 +188,13 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| } | } | ||||
| /** Resolve local items.id for an M18 product id; sync from M18 when missing. */ | |||||
| open fun resolveLocalItemId(m18ItemId: Long): Long? { | |||||
| itemsService.findByM18Id(m18ItemId)?.id?.let { return it } | |||||
| saveProduct(m18ItemId)?.id?.let { return it } | |||||
| return itemsService.findByM18Id(m18ItemId)?.id | |||||
| } | |||||
| open fun saveProduct(id: Long): MessageResponse? { | open fun saveProduct(id: Long): MessageResponse? { | ||||
| try { | try { | ||||
| ensureCunitSeededForAllIfEmpty() | ensureCunitSeededForAllIfEmpty() | ||||
| @@ -231,9 +238,18 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| val savedItem = itemsService.saveItem(saveItemRequest) | val savedItem = itemsService.saveItem(saveItemRequest) | ||||
| val localItemId = savedItem.id | |||||
| if (localItemId == null) { | |||||
| logger.error("saveItem returned null id for M18 item $id (code=${pro.code}): ${savedItem.message}") | |||||
| return null | |||||
| } | |||||
| if (savedItem.errorPosition == "code") { | |||||
| logger.error("saveItem duplicate code for M18 item $id (code=${pro.code}): ${savedItem.message}") | |||||
| return null | |||||
| } | |||||
| logger.info("Processing item uom...") | logger.info("Processing item uom...") | ||||
| // Find the item uom that ready to delete (not in m18) | // Find the item uom that ready to delete (not in m18) | ||||
| val existingItemUoms = savedItem.id?.let { itemUomService.findAllByItemsId(it) } | |||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | |||||
| val m18ItemUomIds = price?.map { it.id } ?: listOf() | val m18ItemUomIds = price?.map { it.id } ?: listOf() | ||||
| // Delete the item uom | // Delete the item uom | ||||
| @@ -267,7 +283,7 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| val itemUomRequest = ItemUomRequest( | val itemUomRequest = ItemUomRequest( | ||||
| m18UomId = it.unitId, | m18UomId = it.unitId, | ||||
| itemId = savedItem.id, | |||||
| itemId = localItemId, | |||||
| baseUnit = it.basicUnit, | baseUnit = it.basicUnit, | ||||
| stockUnit = it.stkUnit, | stockUnit = it.stkUnit, | ||||
| pickingUnit = it.pickUnit, | pickingUnit = it.pickUnit, | ||||
| @@ -284,12 +300,11 @@ open class M18MasterDataService( | |||||
| deleted = it.expired || endInstant.isBefore(now) | deleted = it.expired || endInstant.isBefore(now) | ||||
| ) | ) | ||||
| // logger.info("saved item id: ${savedItem.id}") | |||||
| itemUomService.saveItemUom(itemUomRequest) | itemUomService.saveItemUom(itemUomRequest) | ||||
| } | } | ||||
| logger.info("Success (M18 Item): ${id} | ${pro.code} | ${pro.desc}") | logger.info("Success (M18 Item): ${id} | ${pro.code} | ${pro.desc}") | ||||
| return savedItem | |||||
| return savedItem.copy(id = localItemId) | |||||
| } else { | } else { | ||||
| logger.error("Fail Message: ${itemDetail?.messages?.get(0)?.msgDetail}") | logger.error("Fail Message: ${itemDetail?.messages?.get(0)?.msgDetail}") | ||||
| logger.error("Fail: Item ID - ${id} Not Found") | logger.error("Fail: Item ID - ${id} Not Found") | ||||
| @@ -404,11 +419,20 @@ open class M18MasterDataService( | |||||
| ) | ) | ||||
| val savedItem = itemsService.saveItem(saveItemRequest) | val savedItem = itemsService.saveItem(saveItemRequest) | ||||
| val localItemId = savedItem.id | |||||
| if (localItemId == null) { | |||||
| failList.add(item.id) | |||||
| logger.error("saveItem returned null id for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | |||||
| return@forEach | |||||
| } | |||||
| if (savedItem.errorPosition == "code") { | |||||
| failList.add(item.id) | |||||
| logger.error("saveItem duplicate code for M18 item ${item.id} (code=${pro.code}): ${savedItem.message}") | |||||
| return@forEach | |||||
| } | |||||
| logger.info("Processing item uom...") | logger.info("Processing item uom...") | ||||
| // Optional: cache findAllByItemsId if you think it might be called multiple times | |||||
| // (usually not needed here because each savedItem.id is unique) | |||||
| val existingItemUoms = savedItem.id?.let { itemUomService.findAllByItemsId(it) } | |||||
| val existingItemUoms = itemUomService.findAllByItemsId(localItemId) | |||||
| val m18ItemUomIds = price?.map { it.id } ?: listOf() | val m18ItemUomIds = price?.map { it.id } ?: listOf() | ||||
| @@ -442,7 +466,7 @@ open class M18MasterDataService( | |||||
| val itemUomRequest = ItemUomRequest( | val itemUomRequest = ItemUomRequest( | ||||
| m18UomId = it.unitId, | m18UomId = it.unitId, | ||||
| itemId = savedItem.id, | |||||
| itemId = localItemId, | |||||
| baseUnit = it.basicUnit, | baseUnit = it.basicUnit, | ||||
| stockUnit = it.stkUnit, | stockUnit = it.stkUnit, | ||||
| pickingUnit = it.pickUnit, | pickingUnit = it.pickUnit, | ||||
| @@ -315,6 +315,19 @@ open class M18PurchaseOrderService( | |||||
| val latestPurchaseOrderLog = | val latestPurchaseOrderLog = | ||||
| m18DataLogService.findLatestM18DataLogWithSuccess(m18PurchaseOrderId, poRefType) | m18DataLogService.findLatestM18DataLogWithSuccess(m18PurchaseOrderId, poRefType) | ||||
| val existingPurchaseOrderForSync = | |||||
| latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) } | |||||
| if (existingPurchaseOrderForSync != null && | |||||
| existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | |||||
| ) { | |||||
| logger.info( | |||||
| "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + | |||||
| "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + | |||||
| "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" | |||||
| ) | |||||
| return@forEach | |||||
| } | |||||
| // logger.info(latestPurchaseOrderLog.toString()) | // logger.info(latestPurchaseOrderLog.toString()) | ||||
| // Save to m18_data_log table | // Save to m18_data_log table | ||||
| // logger.info("${poRefType}: Saving for M18 Data Log...") | // logger.info("${poRefType}: Saving for M18 Data Log...") | ||||
| @@ -336,10 +349,9 @@ open class M18PurchaseOrderService( | |||||
| // logger.info("${poRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLog.id}") | // logger.info("${poRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLog.id}") | ||||
| try { | try { | ||||
| // Find the purchase_order if exist | |||||
| // Find the purchase_order if exist (re-use lookup from pending guard above) | |||||
| // logger.info("${poRefType}: Finding exising purchase order...") | // logger.info("${poRefType}: Finding exising purchase order...") | ||||
| val existingPurchaseOrder = | |||||
| latestPurchaseOrderLog?.id?.let { purchaseOrderService.findByM18DataLogId(it) } | |||||
| val existingPurchaseOrder = existingPurchaseOrderForSync | |||||
| // logger.info("${poRefType}: Exising purchase order ID: ${existingPurchaseOrder?.id}") | // logger.info("${poRefType}: Exising purchase order ID: ${existingPurchaseOrder?.id}") | ||||
| // Save to purchase_order table | // Save to purchase_order table | ||||
| @@ -427,14 +439,25 @@ open class M18PurchaseOrderService( | |||||
| // logger.info("${poLineRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLineLog.id}") | // logger.info("${poLineRefType}: Saved M18 Data Log. ID: ${saveM18PurchaseOrderLineLog.id}") | ||||
| // logger.info("${poLineRefType}: Finding item...") | // logger.info("${poLineRefType}: Finding item...") | ||||
| val item = itemsService.findByM18Id(line.proId) | |||||
| val itemId: Long? = if (item == null) { | |||||
| m18MasterDataService.saveProduct(line.proId)?.id | |||||
| } else { | |||||
| item.id | |||||
| } | |||||
| val itemId: Long? = m18MasterDataService.resolveLocalItemId(line.proId) | |||||
| logger.info("${poLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | logger.info("${poLineRefType}: Item ID: ${itemId} | M18 Item ID: ${line.proId}") | ||||
| if (itemId == null) { | |||||
| failDetailList.add(line.id) | |||||
| logger.error( | |||||
| "${poLineRefType}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}" | |||||
| ) | |||||
| val errorSaveM18PurchaseOrderLineLogRequest = SaveM18DataLogRequest( | |||||
| id = saveM18PurchaseOrderLineLog.id, | |||||
| dataLog = mutableMapOf( | |||||
| "Exception Message" to "Cannot resolve local item for M18 proId=${line.proId}" | |||||
| ), | |||||
| statusEnum = M18DataLogStatus.FAIL | |||||
| ) | |||||
| m18DataLogService.saveM18DataLog(errorSaveM18PurchaseOrderLineLogRequest) | |||||
| return@forEach | |||||
| } | |||||
| try { | try { | ||||
| // Find the purchase_order_line if exist (stable key: PO + M18 line id) | // Find the purchase_order_line if exist (stable key: PO + M18 line id) | ||||
| // logger.info("${poLineRefType}: Finding exising purchase order line...") | // logger.info("${poLineRefType}: Finding exising purchase order line...") | ||||
| @@ -0,0 +1,43 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | |||||
| import com.ffii.fpsms.m18.model.M18CommonListRequest | |||||
| import com.ffii.fpsms.m18.model.M18VendorListResponse | |||||
| import com.ffii.fpsms.m18.model.StSearchType | |||||
| import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| /** | |||||
| * Lightweight M18 vendor search — kept separate from [M18MasterDataService] to avoid a Spring cycle | |||||
| * ([M18BomForShopService] → [M18MasterDataService] → [com.ffii.fpsms.modules.master.service.BomService] → [M18BomForShopService]). | |||||
| */ | |||||
| @Service | |||||
| open class M18VendorLookupService( | |||||
| private val apiCallerService: ApiCallerService, | |||||
| ) { | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18VendorLookupService::class.java) | |||||
| private val fetchListApi = "/search/search" | |||||
| /** M18 vendor id for [code] scoped to [beId] (e.g. PF vs PP business entity). */ | |||||
| open fun findVendorM18IdByCode(code: String, beId: String): Long? { | |||||
| val trimmed = code.trim() | |||||
| if (trimmed.isEmpty() || beId.isBlank()) return null | |||||
| val conds = "(code=equal=$trimmed)=and=(beId=equal=$beId)" | |||||
| val listResponse = try { | |||||
| apiCallerService.get<M18VendorListResponse, M18CommonListRequest>( | |||||
| fetchListApi, | |||||
| M18CommonListRequest( | |||||
| stSearch = StSearchType.VENDOR.value, | |||||
| params = null, | |||||
| conds = conds, | |||||
| ), | |||||
| ).block() | |||||
| } catch (e: Exception) { | |||||
| logger.warn("(findVendorM18IdByCode) M18 search failed code=$trimmed beId=$beId: ${e.message}") | |||||
| null | |||||
| } | |||||
| return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L } | |||||
| } | |||||
| } | |||||
| @@ -4,8 +4,9 @@ import com.ffii.core.utils.JwtTokenUtil | |||||
| import com.ffii.fpsms.m18.M18Config | import com.ffii.fpsms.m18.M18Config | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.m18.service.* | import com.ffii.fpsms.m18.service.* | ||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | import com.ffii.fpsms.m18.web.models.M18CommonRequest | ||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.master.service.BomService | |||||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | import com.ffii.fpsms.modules.master.entity.ItemUom | ||||
| import com.ffii.fpsms.modules.master.entity.Items | import com.ffii.fpsms.modules.master.entity.Items | ||||
| @@ -35,6 +36,7 @@ class M18TestController ( | |||||
| private val m18DeliveryOrderService: M18DeliveryOrderService, | private val m18DeliveryOrderService: M18DeliveryOrderService, | ||||
| val schedulerService: SchedulerService, | val schedulerService: SchedulerService, | ||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| private val bomService: BomService, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| @@ -65,6 +67,14 @@ class M18TestController ( | |||||
| return schedulerService.getM18Pos(); | return schedulerService.getM18Pos(); | ||||
| } | } | ||||
| @PostMapping("/test/bom-shop-sync/{bomId}") | |||||
| fun testBomShopSync( | |||||
| @PathVariable bomId: Long, | |||||
| @RequestParam(required = false) m18HeaderId: Long?, | |||||
| ): M18BomShopSyncTriggerResult { | |||||
| return bomService.pushBomToM18ShopIfAllowed(bomId, m18HeaderId) | |||||
| } | |||||
| @GetMapping("/test/po-by-code") | @GetMapping("/test/po-by-code") | ||||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | fun testSyncPoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | return m18PurchaseOrderService.savePurchaseOrderByCode(code) | ||||
| @@ -72,7 +82,14 @@ class M18TestController ( | |||||
| @GetMapping("/test/do-by-code") | @GetMapping("/test/do-by-code") | ||||
| fun testSyncDoByCode(@RequestParam code: String): SyncResult { | fun testSyncDoByCode(@RequestParam code: String): SyncResult { | ||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code) | |||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = false) | |||||
| } | |||||
| /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isExtra]=true(不做 M18 端加單條件過濾) */ | |||||
| @GetMapping("/test/do-by-code-extra") | |||||
| fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult { | |||||
| // 加單 tab: only sync when it's a NEW order (not existing in local system) | |||||
| return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isExtraSync = true, newOnly = true) | |||||
| } | } | ||||
| @GetMapping("/test/product-by-code") | @GetMapping("/test/product-by-code") | ||||
| @@ -29,7 +29,7 @@ open class BagService( | |||||
| ) { | ) { | ||||
| open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | open fun createBagLotLinesByBagId(request: CreateBagLotLineRequest): MessageResponse { | ||||
| val bag = bagRepository.findById(request.bagId).orElse(null) | val bag = bagRepository.findById(request.bagId).orElse(null) | ||||
| val lot = inventoryLotRepository.findByLotNoAndItemId(request.lotNo, request.itemId) | |||||
| val lot = inventoryLotRepository.findByIdAndDeletedFalse(request.lotId) | |||||
| val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | val BaseUnitOfMeasure= itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(request.itemId) | ||||
| val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | val baseRatioN = BaseUnitOfMeasure?.ratioN ?: BigDecimal.ONE | ||||
| println("baseRatioN: $baseRatioN") | println("baseRatioN: $baseRatioN") | ||||
| @@ -1,38 +1,21 @@ | |||||
| package com.ffii.fpsms.modules.bag.web | package com.ffii.fpsms.modules.bag.web | ||||
| import com.ffii.core.response.RecordsRes | |||||
| import com.ffii.fpsms.modules.bag.service.BagService | import com.ffii.fpsms.modules.bag.service.BagService | ||||
| import jakarta.validation.Valid | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagConsumptionResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagInfo | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagLotLineResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagSummaryResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.BagUsageRecordResponse | |||||
| import com.ffii.fpsms.modules.bag.web.model.CreateJoBagConsumptionRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.ModelAttribute | |||||
| import org.springframework.web.bind.annotation.PathVariable | import org.springframework.web.bind.annotation.PathVariable | ||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||
| import org.springframework.web.bind.annotation.PutMapping | |||||
| import org.springframework.web.bind.annotation.RequestBody | import org.springframework.web.bind.annotation.RequestBody | ||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JoPickOrderService | |||||
| import com.ffii.fpsms.modules.productProcess.service.ProductProcessService | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.* | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportPickRecordRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintPickRecordRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanSubmitRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanIssueRequest | |||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperPrint | |||||
| import org.aspectj.weaver.tools.UnsupportedPointcutPrimitiveException | |||||
| import org.springframework.context.NoSuchMessageException | |||||
| import java.io.OutputStream | |||||
| import java.io.UnsupportedEncodingException | |||||
| import java.text.ParseException | |||||
| import org.springframework.web.bind.annotation.* | |||||
| import org.springframework.web.bind.annotation.RequestParam | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.UpdateJoPickOrderHandledByRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.projections.JobOrderInfo | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.projections.JobOrderInfoWithTypeName | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | |||||
| import com.ffii.fpsms.modules.bag.web.model.* | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/bag") | @RequestMapping("/bag") | ||||
| class BagController( | class BagController( | ||||
| @@ -43,14 +26,17 @@ class BagController( | |||||
| fun getBagInfo(): List<BagInfo> { | fun getBagInfo(): List<BagInfo> { | ||||
| return bagService.getAllBagInfo() | return bagService.getAllBagInfo() | ||||
| } | } | ||||
| @PostMapping("/createJoBagConsumption") | @PostMapping("/createJoBagConsumption") | ||||
| fun createJoBagConsumption(@RequestBody request: CreateJoBagConsumptionRequest): MessageResponse { | fun createJoBagConsumption(@RequestBody request: CreateJoBagConsumptionRequest): MessageResponse { | ||||
| return bagService.createJoBagConsumption(request) | return bagService.createJoBagConsumption(request) | ||||
| } | } | ||||
| @GetMapping("/bagUsageRecords") | @GetMapping("/bagUsageRecords") | ||||
| fun getBagUsageRecords(): List<BagUsageRecordResponse> { | fun getBagUsageRecords(): List<BagUsageRecordResponse> { | ||||
| return bagService.getAllBagUsageRecords() | return bagService.getAllBagUsageRecords() | ||||
| } | } | ||||
| @GetMapping("/bags") | @GetMapping("/bags") | ||||
| fun getBags(): List<BagSummaryResponse> = | fun getBags(): List<BagSummaryResponse> = | ||||
| bagService.getBagSummaries() | bagService.getBagSummaries() | ||||
| @@ -66,4 +52,4 @@ class BagController( | |||||
| @PutMapping("/by-item/{itemId}/soft-delete") | @PutMapping("/by-item/{itemId}/soft-delete") | ||||
| fun softDeleteBagByItemId(@PathVariable itemId: Long): MessageResponse = | fun softDeleteBagByItemId(@PathVariable itemId: Long): MessageResponse = | ||||
| bagService.softDeleteBagByItemId(itemId) | bagService.softDeleteBagByItemId(itemId) | ||||
| } | |||||
| } | |||||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.chart.service | |||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | |||||
| @Service | @Service | ||||
| open class ChartService( | open class ChartService( | ||||
| @@ -15,52 +16,40 @@ open class ChartService( | |||||
| */ | */ | ||||
| fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(sl.date) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(sl.date) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | ||||
| COALESCE(SUM(sl.inQty), 0) AS inQty, | COALESCE(SUM(sl.inQty), 0) AS inQty, | ||||
| COALESCE(SUM(sl.outQty), 0) AS outQty, | COALESCE(SUM(sl.outQty), 0) AS outQty, | ||||
| COALESCE(SUM(COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0)), 0) AS totalQty | COALESCE(SUM(COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0)), 0) AS totalQty | ||||
| FROM stock_ledger sl | |||||
| FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date) | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL | WHERE sl.deleted = 0 AND sl.date IS NOT NULL | ||||
| $startSql $endSql | |||||
| GROUP BY sl.date | |||||
| ORDER BY sl.date | |||||
| $rangeSql | |||||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') | |||||
| ORDER BY date | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| /** | /** | ||||
| * Delivery orders: order count and total line qty by date. | * Delivery orders: order count and total line qty by date. | ||||
| * Uses delivery_order.completeDate or estimatedArrivalDate for date. | |||||
| * X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback). | |||||
| * Rows without estimatedArrivalDate are excluded. | |||||
| */ | */ | ||||
| fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%Y-%m-%d') AS date, | |||||
| DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date, | |||||
| COUNT(DISTINCT do.id) AS orderCount, | COUNT(DISTINCT do.id) AS orderCount, | ||||
| COALESCE(SUM(dol.qty), 0) AS totalQty | COALESCE(SUM(dol.qty), 0) AS totalQty | ||||
| FROM delivery_order do | FROM delivery_order do | ||||
| LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | ||||
| WHERE do.deleted = 0 $startSql $endSql | |||||
| GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL | |||||
| $rangeSql | |||||
| GROUP BY DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') | |||||
| ORDER BY date | ORDER BY date | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -529,37 +518,45 @@ open class ChartService( | |||||
| * Stock in vs stock out by date. | * Stock in vs stock out by date. | ||||
| * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created. | ||||
| * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | * Stock out: stock_out_line.qty, date from stock_out.completeDate or created. | ||||
| * | |||||
| * Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate | |||||
| * all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines). | |||||
| */ | */ | ||||
| fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND u.dt >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND u.dt <= :endDate" | |||||
| } else "" | |||||
| val rangeStart = startDate?.atStartOfDay() | |||||
| val rangeEndExclusive = endDate?.plusDays(1)?.atStartOfDay() | |||||
| if (rangeStart != null) args["inOutRangeStart"] = rangeStart | |||||
| if (rangeEndExclusive != null) args["inOutRangeEndExclusive"] = rangeEndExclusive | |||||
| val inDateFilter = stockInOutCoalescedDateRangeSql( | |||||
| "COALESCE(si.completeDate, sil.receiptDate, si.created)", | |||||
| rangeStart, | |||||
| rangeEndExclusive, | |||||
| ) | |||||
| val outDateFilter = stockInOutCoalescedDateRangeSql( | |||||
| "COALESCE(so.completeDate, so.created)", | |||||
| rangeStart, | |||||
| rangeEndExclusive, | |||||
| ) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, | |||||
| SELECT u.dt AS date, | |||||
| COALESCE(SUM(u.inQty), 0) AS inQty, | COALESCE(SUM(u.inQty), 0) AS inQty, | ||||
| COALESCE(SUM(u.outQty), 0) AS outQty | COALESCE(SUM(u.outQty), 0) AS outQty | ||||
| FROM ( | FROM ( | ||||
| SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, | |||||
| SELECT DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') AS dt, | |||||
| SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty | ||||
| FROM stock_in_line sil | |||||
| INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 | |||||
| WHERE sil.deleted = 0 | |||||
| GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) | |||||
| FROM stock_in si | |||||
| STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0 | |||||
| WHERE si.deleted = 0$inDateFilter | |||||
| GROUP BY DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') | |||||
| UNION ALL | UNION ALL | ||||
| SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, | |||||
| SELECT DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') AS dt, | |||||
| 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty | ||||
| FROM stock_out_line sol | |||||
| INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 | |||||
| WHERE sol.deleted = 0 | |||||
| GROUP BY DATE(COALESCE(so.completeDate, so.created)) | |||||
| FROM stock_out so | |||||
| STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0 | |||||
| WHERE so.deleted = 0$outDateFilter | |||||
| GROUP BY DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') | |||||
| ) u | ) u | ||||
| WHERE 1=1 $startSql $endSql | |||||
| GROUP BY u.dt | GROUP BY u.dt | ||||
| ORDER BY u.dt | ORDER BY u.dt | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -568,23 +565,19 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Distinct items that appear in delivery_order_line in the period (for multi-select options). | * Distinct items that appear in delivery_order_line in the period (for multi-select options). | ||||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||||
| */ | */ | ||||
| fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) | |||||
| val sql = """ | val sql = """ | ||||
| SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName | ||||
| FROM delivery_order_line dol | |||||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||||
| WHERE dol.deleted = 0 $startSql $endSql | |||||
| FROM delivery_order do | |||||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL | |||||
| $rangeSql | |||||
| ORDER BY it.code | ORDER BY it.code | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -592,6 +585,8 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied). | ||||
| * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded. | |||||
| * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`). | |||||
| */ | */ | ||||
| fun getTopDeliveryItems( | fun getTopDeliveryItems( | ||||
| startDate: LocalDate?, | startDate: LocalDate?, | ||||
| @@ -600,14 +595,7 @@ open class ChartService( | |||||
| itemCodes: List<String>? | itemCodes: List<String>? | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>("limit" to limit) | val args = mutableMapOf<String, Any>("limit" to limit) | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" | |||||
| } else "" | |||||
| val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) | |||||
| val itemSql = if (!itemCodes.isNullOrEmpty()) { | val itemSql = if (!itemCodes.isNullOrEmpty()) { | ||||
| val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() } | ||||
| if (codes.isEmpty()) "" else { | if (codes.isEmpty()) "" else { | ||||
| @@ -620,10 +608,11 @@ open class ChartService( | |||||
| it.code AS itemCode, | it.code AS itemCode, | ||||
| it.name AS itemName, | it.name AS itemName, | ||||
| SUM(COALESCE(dol.qty, 0)) AS totalQty | SUM(COALESCE(dol.qty, 0)) AS totalQty | ||||
| FROM delivery_order_line dol | |||||
| INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 | |||||
| INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 | |||||
| WHERE dol.deleted = 0 $startSql $endSql $itemSql | |||||
| FROM delivery_order do | |||||
| STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0 | |||||
| WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL | |||||
| $rangeSql $itemSql | |||||
| GROUP BY dol.itemId, it.code, it.name | GROUP BY dol.itemId, it.code, it.name | ||||
| ORDER BY totalQty DESC | ORDER BY totalQty DESC | ||||
| LIMIT :limit | LIMIT :limit | ||||
| @@ -641,26 +630,26 @@ open class ChartService( | |||||
| itemCode: String? | itemCode: String? | ||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND sl.date >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND sl.date <= :endDate" | |||||
| } else "" | |||||
| val itemSql = if (!itemCode.isNullOrBlank()) { | |||||
| val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate) | |||||
| val hasItemFilter = !itemCode.isNullOrBlank() | |||||
| if (hasItemFilter) { | |||||
| args["itemCode"] = "%$itemCode%" | args["itemCode"] = "%$itemCode%" | ||||
| "AND sl.itemCode LIKE :itemCode" | |||||
| } else "" | |||||
| } | |||||
| val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else "" | |||||
| val fromClause = if (hasItemFilter) { | |||||
| "FROM stock_ledger sl" | |||||
| } else { | |||||
| "FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | DATE_FORMAT(sl.date, '%Y-%m-%d') AS date, | ||||
| COALESCE(SUM(sl.balance), 0) AS balance | COALESCE(SUM(sl.balance), 0) AS balance | ||||
| FROM stock_ledger sl | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL $startSql $endSql $itemSql | |||||
| GROUP BY sl.date | |||||
| ORDER BY sl.date | |||||
| $fromClause | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL | |||||
| $rangeSql $itemSql | |||||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') | |||||
| ORDER BY date | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| @@ -677,27 +666,35 @@ open class ChartService( | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val yearSql = if (year != null) { | val yearSql = if (year != null) { | ||||
| args["year"] = year | |||||
| "AND YEAR(sl.date) = :year" | |||||
| } else "" | |||||
| val startSql = if (startDate != null) { | |||||
| args["startDate"] = startDate.toString() | |||||
| "AND sl.date >= :startDate" | |||||
| } else "" | |||||
| val endSql = if (endDate != null) { | |||||
| args["endDate"] = endDate.toString() | |||||
| "AND sl.date <= :endDate" | |||||
| args["consumptionYearStart"] = LocalDate.of(year, 1, 1).atStartOfDay() | |||||
| args["consumptionYearEndExclusive"] = LocalDate.of(year + 1, 1, 1).atStartOfDay() | |||||
| "AND sl.date >= :consumptionYearStart AND sl.date < :consumptionYearEndExclusive" | |||||
| } else "" | } else "" | ||||
| val itemSql = if (!itemCode.isNullOrBlank()) { | |||||
| val rangeSql = ledgerDateTimeRangeSql( | |||||
| args, | |||||
| "sl.date", | |||||
| startDate, | |||||
| endDate, | |||||
| startArg = "consumptionRangeStart", | |||||
| endArg = "consumptionRangeEndExclusive", | |||||
| ) | |||||
| val hasItemFilter = !itemCode.isNullOrBlank() | |||||
| if (hasItemFilter) { | |||||
| args["itemCode"] = "%$itemCode%" | args["itemCode"] = "%$itemCode%" | ||||
| "AND sl.itemCode LIKE :itemCode" | |||||
| } else "" | |||||
| } | |||||
| val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else "" | |||||
| val fromClause = if (hasItemFilter) { | |||||
| "FROM stock_ledger sl" | |||||
| } else { | |||||
| "FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(sl.date, '%Y-%m') AS month, | DATE_FORMAT(sl.date, '%Y-%m') AS month, | ||||
| COALESCE(SUM(sl.outQty), 0) AS outQty | COALESCE(SUM(sl.outQty), 0) AS outQty | ||||
| FROM stock_ledger sl | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL $yearSql $startSql $endSql $itemSql | |||||
| $fromClause | |||||
| WHERE sl.deleted = 0 AND sl.date IS NOT NULL | |||||
| $yearSql $rangeSql $itemSql | |||||
| GROUP BY DATE_FORMAT(sl.date, '%Y-%m') | GROUP BY DATE_FORMAT(sl.date, '%Y-%m') | ||||
| ORDER BY month | ORDER BY month | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -721,23 +718,29 @@ open class ChartService( | |||||
| /** | /** | ||||
| * Staff delivery performance: daily pick ticket count and total time per staff. | * Staff delivery performance: daily pick ticket count and total time per staff. | ||||
| * Uses do_pick_order_record (handler = handledBy); time = sum of (ticketCompleteDateTime - ticketReleaseTime) per record. | |||||
| * Optionally use do_pick_order_line_record for line count; here orderCount = number of completed pick tickets. | |||||
| * Uses delivery_order_pick_order (handler = handledBy); time = sum of | |||||
| * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket. | |||||
| * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). | ||||
| * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId). | |||||
| * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed). | |||||
| * When no store filter, FORCE INDEX (idx_dopo_staff_perf_complete) so the optimizer uses a | |||||
| * ticketCompleteDateTime range scan instead of a less selective store composite index. | |||||
| */ | */ | ||||
| fun getStaffDeliveryPerformance( | fun getStaffDeliveryPerformance( | ||||
| startDate: LocalDate?, | startDate: LocalDate?, | ||||
| endDate: LocalDate?, | endDate: LocalDate?, | ||||
| staffNos: List<String>? | |||||
| staffNos: List<String>?, | |||||
| storeId: String?, | |||||
| storeIdNull: Boolean?, | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val startSql = if (startDate != null) { | val startSql = if (startDate != null) { | ||||
| args["startDate"] = startDate.toString() | |||||
| "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" | |||||
| args["startDate"] = startDate.atStartOfDay() | |||||
| "AND dop.ticketCompleteDateTime >= :startDate" | |||||
| } else "" | } else "" | ||||
| val endSql = if (endDate != null) { | val endSql = if (endDate != null) { | ||||
| args["endDate"] = endDate.toString() | |||||
| "AND DATE(dpor.ticketCompleteDateTime) <= :endDate" | |||||
| args["endExclusive"] = endDate.plusDays(1).atStartOfDay() | |||||
| "AND dop.ticketCompleteDateTime < :endExclusive" | |||||
| } else "" | } else "" | ||||
| val staffSql = if (!staffNos.isNullOrEmpty()) { | val staffSql = if (!staffNos.isNullOrEmpty()) { | ||||
| val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() } | ||||
| @@ -746,25 +749,40 @@ open class ChartService( | |||||
| "AND u.staffNo IN (:staffNos)" | "AND u.staffNo IN (:staffNos)" | ||||
| } | } | ||||
| } else "" | } else "" | ||||
| val storeSql = when { | |||||
| storeIdNull == true -> "AND dop.storeId IS NULL" | |||||
| !storeId.isNullOrBlank() -> { | |||||
| args["filterStoreId"] = storeId.trim() | |||||
| "AND dop.storeId = :filterStoreId" | |||||
| } | |||||
| else -> "" | |||||
| } | |||||
| val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank() | |||||
| val fromClause = if (useStoreFilter) { | |||||
| "FROM delivery_order_pick_order dop" | |||||
| } else { | |||||
| "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)" | |||||
| } | |||||
| val sql = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| DATE_FORMAT(dpor.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||||
| COALESCE(u.name, dpor.handler_name, 'Unknown') AS staffName, | |||||
| COUNT(dpor.id) AS orderCount, | |||||
| DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date, | |||||
| COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName, | |||||
| COUNT(dop.id) AS orderCount, | |||||
| COALESCE(SUM( | COALESCE(SUM( | ||||
| CASE | CASE | ||||
| WHEN dpor.ticket_release_time IS NOT NULL AND dpor.ticketCompleteDateTime IS NOT NULL | |||||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dpor.ticket_release_time, dpor.ticketCompleteDateTime)) | |||||
| WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL | |||||
| THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime)) | |||||
| ELSE 0 | ELSE 0 | ||||
| END | END | ||||
| ), 0) AS totalMinutes | ), 0) AS totalMinutes | ||||
| FROM do_pick_order_record dpor | |||||
| LEFT JOIN user u ON dpor.handled_by = u.id AND u.deleted = 0 | |||||
| WHERE dpor.deleted = 0 | |||||
| AND dpor.ticket_status = 'completed' | |||||
| AND dpor.ticketCompleteDateTime IS NOT NULL | |||||
| $startSql $endSql $staffSql | |||||
| GROUP BY DATE(dpor.ticketCompleteDateTime), dpor.handled_by, u.name, dpor.handler_name | |||||
| $fromClause | |||||
| LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 | |||||
| WHERE dop.deleted = 0 | |||||
| AND dop.ticketStatus = 'completed' | |||||
| AND dop.ticketCompleteDateTime IS NOT NULL | |||||
| $startSql $endSql $staffSql $storeSql | |||||
| GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'), | |||||
| dop.handledBy, u.name, dop.handlerName | |||||
| ORDER BY date, orderCount DESC | ORDER BY date, orderCount DESC | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -1572,4 +1590,56 @@ open class ChartService( | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| /** Half-open [start, end+1 day) on a DATE/DATETIME column (no DATE() wrapper). */ | |||||
| private fun localDateRangeSql( | |||||
| args: MutableMap<String, Any>, | |||||
| column: String, | |||||
| startDate: LocalDate?, | |||||
| endDate: LocalDate?, | |||||
| startArg: String = "chartRangeStart", | |||||
| endArg: String = "chartRangeEndExclusive", | |||||
| ): String = buildString { | |||||
| if (startDate != null) { | |||||
| args[startArg] = startDate | |||||
| append(" AND $column >= :$startArg") | |||||
| } | |||||
| if (endDate != null) { | |||||
| args[endArg] = endDate.plusDays(1) | |||||
| append(" AND $column < :$endArg") | |||||
| } | |||||
| } | |||||
| /** Half-open range on stock_ledger.date (DATETIME). */ | |||||
| private fun ledgerDateTimeRangeSql( | |||||
| args: MutableMap<String, Any>, | |||||
| column: String, | |||||
| startDate: LocalDate?, | |||||
| endDate: LocalDate?, | |||||
| startArg: String = "ledgerRangeStart", | |||||
| endArg: String = "ledgerRangeEndExclusive", | |||||
| ): String = buildString { | |||||
| if (startDate != null) { | |||||
| args[startArg] = startDate.atStartOfDay() | |||||
| append(" AND $column >= :$startArg") | |||||
| } | |||||
| if (endDate != null) { | |||||
| args[endArg] = endDate.plusDays(1).atStartOfDay() | |||||
| append(" AND $column < :$endArg") | |||||
| } | |||||
| } | |||||
| /** COALESCE datetime expression; args [inOutRangeStart] / [inOutRangeEndExclusive] must already be in map when non-null. */ | |||||
| private fun stockInOutCoalescedDateRangeSql( | |||||
| coalescedExpr: String, | |||||
| rangeStart: LocalDateTime?, | |||||
| rangeEndExclusive: LocalDateTime?, | |||||
| ): String = buildString { | |||||
| if (rangeStart != null) { | |||||
| append(" AND $coalescedExpr >= :inOutRangeStart") | |||||
| } | |||||
| if (rangeEndExclusive != null) { | |||||
| append(" AND $coalescedExpr < :inOutRangeEndExclusive") | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -26,7 +26,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/delivery-order-by-date?startDate=&endDate= | * GET /chart/delivery-order-by-date?startDate=&endDate= | ||||
| * Returns [{ date, orderCount, totalQty }] | |||||
| * Returns [{ date, orderCount, totalQty }]. Date axis: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/delivery-order-by-date") | @GetMapping("/delivery-order-by-date") | ||||
| fun getDeliveryOrderByDate( | fun getDeliveryOrderByDate( | ||||
| @@ -129,7 +129,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/stock-in-out-by-date?startDate=&endDate= | * GET /chart/stock-in-out-by-date?startDate=&endDate= | ||||
| * Returns [{ date, inQty, outQty }] | |||||
| * Returns [{ date, inQty, outQty }]. Date range pushed into each UNION branch; si/so read before lines. | |||||
| */ | */ | ||||
| @GetMapping("/stock-in-out-by-date") | @GetMapping("/stock-in-out-by-date") | ||||
| fun getStockInOutByDate( | fun getStockInOutByDate( | ||||
| @@ -140,6 +140,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/top-delivery-items-item-options?startDate=&endDate= | * GET /chart/top-delivery-items-item-options?startDate=&endDate= | ||||
| * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). | ||||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/top-delivery-items-item-options") | @GetMapping("/top-delivery-items-item-options") | ||||
| fun getTopDeliveryItemsItemOptions( | fun getTopDeliveryItemsItemOptions( | ||||
| @@ -150,6 +151,7 @@ class ChartController( | |||||
| /** | /** | ||||
| * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B | ||||
| * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). | ||||
| * Period: delivery_order.estimatedArrivalDate only (null ETA excluded). | |||||
| */ | */ | ||||
| @GetMapping("/top-delivery-items") | @GetMapping("/top-delivery-items") | ||||
| fun getTopDeliveryItems( | fun getTopDeliveryItems( | ||||
| @@ -192,16 +194,20 @@ class ChartController( | |||||
| chartService.getStaffDeliveryPerformanceHandlers() | chartService.getStaffDeliveryPerformanceHandlers() | ||||
| /** | /** | ||||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002 | |||||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from do_pick_order_record (handled_by), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||||
| * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true | |||||
| * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order | |||||
| * (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). | |||||
| * Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId). | |||||
| */ | */ | ||||
| @GetMapping("/staff-delivery-performance") | @GetMapping("/staff-delivery-performance") | ||||
| fun getStaffDeliveryPerformance( | fun getStaffDeliveryPerformance( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | ||||
| @RequestParam(required = false) staffNo: List<String>?, | @RequestParam(required = false) staffNo: List<String>?, | ||||
| @RequestParam(required = false) storeId: String?, | |||||
| @RequestParam(required = false) storeIdNull: Boolean?, | |||||
| ): List<Map<String, Any>> = | ): List<Map<String, Any>> = | ||||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) | |||||
| chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo, storeId, storeIdNull) | |||||
| // ---------- Job order reports ---------- | // ---------- Job order reports ---------- | ||||
| @@ -28,8 +28,13 @@ public abstract class SettingNames { | |||||
| public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; | public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; | ||||
| /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ | /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ | ||||
| public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; | public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; | ||||
| /** Comma-separated dDates (yyyy-MM-dd) of completed one-time DO1 catch-ups ([scheduler.do1CatchUp]). */ | |||||
| public static final String SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE = "SCHEDULE.m18.do1.catchup.doneDDate"; | |||||
| public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2"; | public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2"; | ||||
| /** Daily push FPSMS BOMs → M18 udfBomForShop (default 23:00; requires [M18_BOM_SHOP_SYNC_ENABLED] and scheduler.m18Sync.enabled). */ | |||||
| public static final String SCHEDULE_M18_BOM_SHOP = "SCHEDULE.m18.bom.shop"; | |||||
| public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; | public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; | ||||
| /** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */ | /** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */ | ||||
| @@ -41,6 +46,11 @@ public abstract class SettingNames { | |||||
| */ | */ | ||||
| public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; | ||||
| /** | |||||
| * When "true", FPSMS may push BOM header + materials to M18 udfBomForShop. | |||||
| */ | |||||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | |||||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | ||||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | ||||
| @@ -52,6 +62,11 @@ public abstract class SettingNames { | |||||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | ||||
| /** | |||||
| * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | |||||
| */ | |||||
| public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | |||||
| /* | /* | ||||
| * Mail settings | * Mail settings | ||||
| */ | */ | ||||
| @@ -1,6 +1,5 @@ | |||||
| package com.ffii.fpsms.modules.common.scheduler.service | package com.ffii.fpsms.modules.common.scheduler.service | ||||
| import com.ffii.core.utils.JwtTokenUtil | |||||
| import com.ffii.fpsms.m18.service.M18DeliveryOrderService | import com.ffii.fpsms.m18.service.M18DeliveryOrderService | ||||
| import com.ffii.fpsms.m18.service.M18GrnCodeSyncService | import com.ffii.fpsms.m18.service.M18GrnCodeSyncService | ||||
| import com.ffii.fpsms.m18.service.M18MasterDataService | import com.ffii.fpsms.m18.service.M18MasterDataService | ||||
| @@ -10,6 +9,8 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | ||||
| import com.ffii.fpsms.m18.model.SyncResult | import com.ffii.fpsms.m18.model.SyncResult | ||||
| import com.ffii.fpsms.modules.common.SettingNames | import com.ffii.fpsms.modules.common.SettingNames | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | |||||
| import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | |||||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | import com.ffii.fpsms.modules.master.service.ProductionScheduleService | ||||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | ||||
| @@ -25,6 +26,7 @@ import org.springframework.stereotype.Service | |||||
| import java.time.DayOfWeek | import java.time.DayOfWeek | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.ZoneId | |||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import java.util.HashMap | import java.util.HashMap | ||||
| import java.util.concurrent.ScheduledFuture | import java.util.concurrent.ScheduledFuture | ||||
| @@ -42,6 +44,15 @@ open class SchedulerService( | |||||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | ||||
| /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | ||||
| @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | ||||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp.enabled:false}") val do1CatchUpEnabled: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp.dDate:}") val do1CatchUpDDate: String, | |||||
| @Value("\${scheduler.do1CatchUp.runAt:}") val do1CatchUpRunAt: String, | |||||
| @Value("\${scheduler.do1CatchUp.skipExistingDo:true}") val do1CatchUpSkipExistingDo: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp2.enabled:false}") val do1CatchUp2Enabled: Boolean, | |||||
| @Value("\${scheduler.do1CatchUp2.dDate:}") val do1CatchUp2DDate: String, | |||||
| @Value("\${scheduler.do1CatchUp2.runAt:}") val do1CatchUp2RunAt: String, | |||||
| @Value("\${scheduler.do1CatchUp2.skipExistingDo:true}") val do1CatchUp2SkipExistingDo: Boolean, | |||||
| val settingsService: SettingsService, | val settingsService: SettingsService, | ||||
| /** | /** | ||||
| * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, | ||||
| @@ -56,8 +67,22 @@ open class SchedulerService( | |||||
| val searchCompletedDnService: SearchCompletedDnService, | val searchCompletedDnService: SearchCompletedDnService, | ||||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | val m18GrnCodeSyncService: M18GrnCodeSyncService, | ||||
| val inventoryLotLineService: InventoryLotLineService, | val inventoryLotLineService: InventoryLotLineService, | ||||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | |||||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | |||||
| companion object { | |||||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||||
| const val DO2_MODIFIED_TO_HOUR: Int = 13 | |||||
| const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" | |||||
| /** Default 23:00 daily — BOM → M18 udfBomForShop for all BOMs ([SettingNames.SCHEDULE_M18_BOM_SHOP]). */ | |||||
| const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" | |||||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | |||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | |||||
| } | |||||
| /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ | |||||
| private val logger: Logger = LoggerFactory.getLogger(SchedulerService::class.java) | |||||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | val dateTimeStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") | ||||
| val defaultCronExpression = "0 0 2 31 2 *"; | val defaultCronExpression = "0 0 2 31 2 *"; | ||||
| @@ -70,6 +95,8 @@ open class SchedulerService( | |||||
| var scheduledM18Do1Sat: ScheduledFuture<*>? = null | var scheduledM18Do1Sat: ScheduledFuture<*>? = null | ||||
| var scheduledM18Do2: ScheduledFuture<*>? = null | var scheduledM18Do2: ScheduledFuture<*>? = null | ||||
| var scheduledM18BomShop: ScheduledFuture<*>? = null | |||||
| @Volatile | @Volatile | ||||
| var scheduledM18Master: ScheduledFuture<*>? = null | var scheduledM18Master: ScheduledFuture<*>? = null | ||||
| @@ -80,6 +107,11 @@ open class SchedulerService( | |||||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | var scheduledGrnCodeSync: ScheduledFuture<*>? = null | ||||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | ||||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | |||||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | |||||
| var scheduledDo1CatchUp2: ScheduledFuture<*>? = null | |||||
| //@Volatile | //@Volatile | ||||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | //var scheduledRoughProd: ScheduledFuture<*>? = null | ||||
| @@ -165,14 +197,166 @@ open class SchedulerService( | |||||
| scheduleM18Po(); | scheduleM18Po(); | ||||
| scheduleM18Do1(); | scheduleM18Do1(); | ||||
| scheduleM18Do2(); | scheduleM18Do2(); | ||||
| scheduleM18BomShop(); | |||||
| scheduleM18MasterData(); | scheduleM18MasterData(); | ||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | scheduleGrnCodeSync(); | ||||
| scheduleInventoryLotExpiry(); | scheduleInventoryLotExpiry(); | ||||
| scheduleJobOrderPlanStartAuto(); | |||||
| scheduleDo1CatchUpOnce(); | |||||
| //scheduleRoughProd(); | //scheduleRoughProd(); | ||||
| //scheduleDetailedProd(); | //scheduleDetailedProd(); | ||||
| } | } | ||||
| /** | |||||
| * One-time DO1 catch-up jobs for fixed dDates (e.g. missed 15/6 → dDate 17/6, 16/6 → dDate 18/6). | |||||
| * Requires [m18SyncEnabled] (production only). Config: scheduler.do1CatchUp / do1CatchUp2 in application-prod.yml. | |||||
| * Completed dDates are stored comma-separated in [SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE]. | |||||
| */ | |||||
| fun scheduleDo1CatchUpOnce() { | |||||
| scheduledDo1CatchUp?.cancel(false) | |||||
| scheduledDo1CatchUp = null | |||||
| scheduledDo1CatchUp2?.cancel(false) | |||||
| scheduledDo1CatchUp2 = null | |||||
| if (!m18SyncEnabled) { | |||||
| logger.info("DO1 catch-up schedulers disabled (scheduler.m18Sync.enabled=false; production only)") | |||||
| return | |||||
| } | |||||
| scheduledDo1CatchUp = scheduleOneDo1CatchUp( | |||||
| scheduledDo1CatchUp, | |||||
| do1CatchUpEnabled, | |||||
| do1CatchUpDDate, | |||||
| do1CatchUpRunAt, | |||||
| do1CatchUpSkipExistingDo, | |||||
| "do1CatchUp", | |||||
| ) | |||||
| scheduledDo1CatchUp2 = scheduleOneDo1CatchUp( | |||||
| scheduledDo1CatchUp2, | |||||
| do1CatchUp2Enabled, | |||||
| do1CatchUp2DDate, | |||||
| do1CatchUp2RunAt, | |||||
| do1CatchUp2SkipExistingDo, | |||||
| "do1CatchUp2", | |||||
| ) | |||||
| } | |||||
| private fun scheduleOneDo1CatchUp( | |||||
| existing: ScheduledFuture<*>?, | |||||
| enabled: Boolean, | |||||
| dDateRaw: String, | |||||
| runAtRaw: String, | |||||
| skipExistingDo: Boolean, | |||||
| configKey: String, | |||||
| ): ScheduledFuture<*>? { | |||||
| existing?.cancel(false) | |||||
| if (!enabled) { | |||||
| return null | |||||
| } | |||||
| val dDateStr = dDateRaw.trim() | |||||
| val runAtStr = runAtRaw.trim() | |||||
| if (dDateStr.isEmpty() || runAtStr.isEmpty()) { | |||||
| logger.warn("{} enabled but dDate or runAt is blank — skipped", configKey) | |||||
| return null | |||||
| } | |||||
| val dDate = try { | |||||
| LocalDate.parse(dDateStr) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Invalid scheduler.{}.dDate={}", configKey, dDateStr) | |||||
| return null | |||||
| } | |||||
| val runAt = try { | |||||
| LocalDateTime.parse(runAtStr) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Invalid scheduler.{}.runAt={}", configKey, runAtStr) | |||||
| return null | |||||
| } | |||||
| if (isDo1CatchUpAlreadyDone(dDate)) { | |||||
| logger.info("DO1 catch-up ({}) already completed for dDate={}", configKey, dDate) | |||||
| return null | |||||
| } | |||||
| val now = LocalDateTime.now() | |||||
| if (!runAt.isAfter(now)) { | |||||
| logger.warn( | |||||
| "DO1 catch-up ({}) runAt={} is not in the future (now={}); use GET /scheduler/trigger/do1-catchup?dDate={}", | |||||
| configKey, | |||||
| runAt, | |||||
| now, | |||||
| dDate, | |||||
| ) | |||||
| return null | |||||
| } | |||||
| val scheduled = taskScheduler.schedule( | |||||
| { runDo1CatchUp(dDate, skipExistingDo) }, | |||||
| runAt.atZone(ZoneId.systemDefault()).toInstant(), | |||||
| ) | |||||
| logger.info( | |||||
| "Scheduled one-time DO1 catch-up ({}) for dDate={} at {} skipExistingDo={}", | |||||
| configKey, | |||||
| dDate, | |||||
| runAt, | |||||
| skipExistingDo, | |||||
| ) | |||||
| return scheduled | |||||
| } | |||||
| private fun getDo1CatchUpDoneDDateSet(): Set<String> { | |||||
| val done = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE).getOrNull()?.value | |||||
| ?: return emptySet() | |||||
| return done.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() | |||||
| } | |||||
| private fun isDo1CatchUpAlreadyDone(dDate: LocalDate): Boolean { | |||||
| return dDate.toString() in getDo1CatchUpDoneDDateSet() | |||||
| } | |||||
| private fun markDo1CatchUpDone(dDate: LocalDate) { | |||||
| try { | |||||
| val name = SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE | |||||
| val updated = (getDo1CatchUpDoneDDateSet() + dDate.toString()).sorted().joinToString(",") | |||||
| val existing = settingsService.findByName(name).orElse(null) | |||||
| if (existing != null) { | |||||
| settingsService.update(name, updated) | |||||
| } else { | |||||
| val setting = Settings() | |||||
| setting.name = name | |||||
| setting.value = updated | |||||
| setting.category = "SCHEDULE" | |||||
| setting.type = Settings.TYPE_STRING | |||||
| settingsService.save(setting) | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| logger.error("Failed to persist DO1 catch-up done marker for dDate={}: {}", dDate, e.message, e) | |||||
| } | |||||
| } | |||||
| open fun runDo1CatchUp(dDate: LocalDate, skipExistingDo: Boolean = true) { | |||||
| if (!m18SyncEnabled) { | |||||
| logger.warn( | |||||
| "DO1 catch-up refused for dDate={}: production only (scheduler.m18Sync.enabled=false)", | |||||
| dDate, | |||||
| ) | |||||
| return | |||||
| } | |||||
| if (isDo1CatchUpAlreadyDone(dDate)) { | |||||
| logger.info("DO1 catch-up already completed for dDate={}", dDate) | |||||
| return | |||||
| } | |||||
| try { | |||||
| getM18Dos1ForDDate(dDate, syncType = "DO1_CATCHUP", skipExistingDo = skipExistingDo) | |||||
| } catch (e: Exception) { | |||||
| logger.error("DO1 catch-up sync failed for dDate={}: {}", dDate, e.message, e) | |||||
| return | |||||
| } | |||||
| markDo1CatchUpDone(dDate) | |||||
| logger.info("DO1 catch-up completed for dDate={}", dDate) | |||||
| } | |||||
| // Scheduler | // Scheduler | ||||
| // --------------------------- FP-MTMS --------------------------- // | // --------------------------- FP-MTMS --------------------------- // | ||||
| //fun scheduleRoughProd() { | //fun scheduleRoughProd() { | ||||
| @@ -206,7 +390,19 @@ open class SchedulerService( | |||||
| logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | logger.info("M18 DO2 scheduler disabled (scheduler.m18Sync.enabled=false)") | ||||
| return | return | ||||
| } | } | ||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, ::getM18Dos2) | |||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | |||||
| } | |||||
| /** Daily push FPSMS BOMs → M18; cron from settings [SettingNames.SCHEDULE_M18_BOM_SHOP] ([M18_BOM_SHOP_DEFAULT_CRON]); requires scheduler.m18Sync.enabled. */ | |||||
| fun scheduleM18BomShop() { | |||||
| if (!m18SyncEnabled) { | |||||
| scheduledM18BomShop?.cancel(false) | |||||
| scheduledM18BomShop = null | |||||
| logger.info("M18 BOM Shop scheduler disabled (scheduler.m18Sync.enabled=false)") | |||||
| return | |||||
| } | |||||
| scheduledM18BomShop = | |||||
| commonSchedule(scheduledM18BomShop, SettingNames.SCHEDULE_M18_BOM_SHOP, M18_BOM_SHOP_DEFAULT_CRON, ::getM18BomShopPushAllBoms) | |||||
| } | } | ||||
| fun scheduleM18MasterData() { | fun scheduleM18MasterData() { | ||||
| @@ -286,6 +482,42 @@ open class SchedulerService( | |||||
| ) | ) | ||||
| } | } | ||||
| /** | |||||
| * Job order plan-start batch at 00:00:15 daily (yesterday plan day). | |||||
| * Set scheduler.jo.planStart.enabled=false to disable. | |||||
| */ | |||||
| fun scheduleJobOrderPlanStartAuto() { | |||||
| if (!jobOrderPlanStartAutoEnabled) { | |||||
| scheduledJobOrderPlanStart?.cancel(false) | |||||
| scheduledJobOrderPlanStart = null | |||||
| logger.info("Job order plan-start auto scheduler disabled (scheduler.jo.planStart.enabled=false)") | |||||
| return | |||||
| } | |||||
| scheduledJobOrderPlanStart = commonSchedule( | |||||
| scheduledJobOrderPlanStart, | |||||
| SettingNames.SCHEDULE_JO_PLAN_START, | |||||
| JO_PLAN_START_DEFAULT_CRON, | |||||
| ::runJobOrderPlanStartAuto, | |||||
| ) | |||||
| logger.info("Scheduled job order plan-start auto (default cron={})", JO_PLAN_START_DEFAULT_CRON) | |||||
| } | |||||
| open fun runJobOrderPlanStartAuto() { | |||||
| try { | |||||
| val report = jobOrderPlanStartAutoService.runAutoProcess(LocalDateTime.now()) | |||||
| logger.info( | |||||
| "Scheduler - Job order plan-start auto: candidates={}, hidden={}, rescheduled={}, skipped={}, errors={}", | |||||
| report.candidates, | |||||
| report.hidden, | |||||
| report.rescheduled, | |||||
| report.skipped, | |||||
| report.errors, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Scheduler - Job order plan-start auto failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ | ||||
| fun scheduleInventoryLotExpiry() { | fun scheduleInventoryLotExpiry() { | ||||
| if (!inventoryLotExpiryEnabled) { | if (!inventoryLotExpiryEnabled) { | ||||
| @@ -410,24 +642,42 @@ open class SchedulerService( | |||||
| open fun getM18Dos1() { | open fun getM18Dos1() { | ||||
| logger.info("DO Scheduler 1 - DO") | logger.info("DO Scheduler 1 - DO") | ||||
| val currentTime = LocalDateTime.now() | |||||
| val today = currentTime.toLocalDate().atStartOfDay() | |||||
| val twoDaysLater = today.plusDays(2L) | |||||
| var requestDO = M18CommonRequest( | |||||
| dDateTo = twoDaysLater.format(dateTimeStringFormat), | |||||
| dDateFrom = twoDaysLater.format(dateTimeStringFormat) | |||||
| ) | |||||
| val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO); | |||||
| val today = LocalDateTime.now().toLocalDate().atStartOfDay() | |||||
| val dDate = today.plusDays(2L).toLocalDate() | |||||
| getM18Dos1ForDDate(dDate, syncType = "DO1") | |||||
| } | |||||
| saveSyncLog( | |||||
| type = "DO1", | |||||
| status = "SUCCESS", | |||||
| result = result, | |||||
| start = currentTime | |||||
| /** DO1 sync for an explicit delivery date (normal DO1 uses run-day + 2 days). */ | |||||
| open fun getM18Dos1ForDDate( | |||||
| dDate: LocalDate, | |||||
| syncType: String = "DO1", | |||||
| skipExistingDo: Boolean = syncType == "DO1_CATCHUP", | |||||
| ) { | |||||
| logger.info("{} sync for dDate={} skipExistingDo={}", syncType, dDate, skipExistingDo) | |||||
| val currentTime = LocalDateTime.now() | |||||
| val dDateStart = dDate.atStartOfDay() | |||||
| val requestDO = M18CommonRequest( | |||||
| dDateTo = dDateStart.format(dateTimeStringFormat), | |||||
| dDateFrom = dDateStart.format(dateTimeStringFormat), | |||||
| ) | ) | ||||
| try { | |||||
| val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO, skipExistingDo = skipExistingDo) | |||||
| saveSyncLog( | |||||
| type = syncType, | |||||
| status = "SUCCESS", | |||||
| result = result?.copy(query = "dDate=$dDate ${result.query}".trim()), | |||||
| start = currentTime, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| logger.error("{} sync failed for dDate={}: {}", syncType, dDate, e.message, e) | |||||
| saveSyncLog( | |||||
| type = syncType, | |||||
| status = "FAILED", | |||||
| error = e.message, | |||||
| start = currentTime, | |||||
| ) | |||||
| throw e | |||||
| } | |||||
| } | } | ||||
| private fun saveSyncLog(type: String, status: String, result: SyncResult? = null, error: String? = null, start: LocalDateTime) { | private fun saveSyncLog(type: String, status: String, result: SyncResult? = null, error: String? = null, start: LocalDateTime) { | ||||
| @@ -455,7 +705,7 @@ open class SchedulerService( | |||||
| val ysd = today.minusDays(1L) | val ysd = today.minusDays(1L) | ||||
| val tmr = today.plusDays(1L) | val tmr = today.plusDays(1L) | ||||
| // Default: lastModified from yesterday 19:00 (aligns with nightly DO2 expectation). | |||||
| // Default: lastModified from yesterday 19:00 through today's DO2 run hour (1pm; aligns with SCHEDULE.m18.do2). | |||||
| // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | // On Sunday, yesterday is Saturday: use 03:00 instead so we include DO changed after Sat 03:10 DO1 | ||||
| // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | // (otherwise Sat 03:00–18:59 would be skipped until a much later sync). | ||||
| val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | val isSundayDo2 = runDate.dayOfWeek == DayOfWeek.SUNDAY | ||||
| @@ -465,21 +715,21 @@ open class SchedulerService( | |||||
| ysd.withHour(19).withMinute(0).withSecond(0) | ysd.withHour(19).withMinute(0).withSecond(0) | ||||
| } | } | ||||
| // Set to 11:00:00 of today | |||||
| val todayEleven = today.withHour(11).withMinute(0).withSecond(0) | |||||
| val modifiedDateToEnd = | |||||
| today.withHour(DO2_MODIFIED_TO_HOUR).withMinute(0).withSecond(0) | |||||
| logger.info( | logger.info( | ||||
| "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | "DO2 modifiedDateFrom={} ({}), modifiedDateTo={}", | ||||
| modifiedFromStart.format(dateTimeStringFormat), | modifiedFromStart.format(dateTimeStringFormat), | ||||
| if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | if (isSundayDo2) "Sunday window from Sat 03:00" else "from yesterday 19:00", | ||||
| todayEleven.format(dateTimeStringFormat), | |||||
| modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| ) | ) | ||||
| val requestDO = M18CommonRequest( | val requestDO = M18CommonRequest( | ||||
| // These will now produce "yyyy-MM-dd HH:mm:ss" | // These will now produce "yyyy-MM-dd HH:mm:ss" | ||||
| dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateTo = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | dDateFrom = tmr.format(dateTimeStringFormat), // e.g. 2026-01-19 00:00:00 | ||||
| modifiedDateTo = todayEleven.format(dateTimeStringFormat), // 2026-01-18 11:00:00 | |||||
| modifiedDateTo = modifiedDateToEnd.format(dateTimeStringFormat), | |||||
| modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | modifiedDateFrom = modifiedFromStart.format(dateTimeStringFormat), | ||||
| ) | ) | ||||
| @@ -493,6 +743,44 @@ open class SchedulerService( | |||||
| ) | ) | ||||
| } | } | ||||
| open fun getM18BomShopPushAllBoms() { | |||||
| val currentTime = LocalDateTime.now() | |||||
| try { | |||||
| val summary = bomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed() | |||||
| val status = if (summary.skippedBecauseFeatureDisabled) "SKIPPED" else "SUCCESS" | |||||
| saveSyncLog( | |||||
| type = "M18_BOM_SHOP", | |||||
| status = status, | |||||
| result = | |||||
| SyncResult( | |||||
| totalProcessed = summary.totalProcessed, | |||||
| totalSuccess = summary.synced, | |||||
| totalFail = summary.notSynced, | |||||
| query = summary.toLogQuery(), | |||||
| ), | |||||
| start = currentTime, | |||||
| ) | |||||
| if (summary.skippedBecauseFeatureDisabled) { | |||||
| logger.debug( | |||||
| "M18 BOM Shop bulk skipped ({}) — set {}={} to run pushes", | |||||
| summary.toLogQuery(), | |||||
| SettingNames.M18_BOM_SHOP_SYNC_ENABLED, | |||||
| Settings.VALUE_BOOLEAN_TRUE, | |||||
| ) | |||||
| } else { | |||||
| logger.info("M18 BOM Shop batch done: {}", summary.toLogQuery()) | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| logger.error("M18 BOM Shop batch failed: ${e.message}", e) | |||||
| saveSyncLog( | |||||
| type = "M18_BOM_SHOP", | |||||
| status = "FAILED", | |||||
| error = e.message, | |||||
| start = currentTime, | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun getPostCompletedDnAndProcessGrn( | open fun getPostCompletedDnAndProcessGrn( | ||||
| receiptDate: java.time.LocalDate? = null, | receiptDate: java.time.LocalDate? = null, | ||||
| skipFirst: Int = 0, | skipFirst: Int = 0, | ||||
| @@ -43,12 +43,35 @@ class SchedulerController( | |||||
| return "M18 DO1 Sync Triggered Successfully" | return "M18 DO1 Sync Triggered Successfully" | ||||
| } | } | ||||
| /** Manual DO1 catch-up for a fixed dDate (production only). Skips existing local DOs by default. */ | |||||
| @GetMapping("/trigger/do1-catchup") | |||||
| fun triggerDo1CatchUp( | |||||
| @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) dDate: LocalDate, | |||||
| @RequestParam(required = false, defaultValue = "true") skipExistingDo: Boolean = true, | |||||
| ): String { | |||||
| schedulerService.runDo1CatchUp(dDate, skipExistingDo = skipExistingDo) | |||||
| return "M18 DO1 catch-up triggered for dDate=$dDate skipExistingDo=$skipExistingDo" | |||||
| } | |||||
| @GetMapping("/trigger/do2") | @GetMapping("/trigger/do2") | ||||
| fun triggerDo2(): String { | fun triggerDo2(): String { | ||||
| schedulerService.getM18Dos2() | schedulerService.getM18Dos2() | ||||
| return "M18 DO2 Sync Triggered Successfully" | return "M18 DO2 Sync Triggered Successfully" | ||||
| } | } | ||||
| /** Manual test: push all FPSMS BOMs to M18 udfBomForShop ([SettingNames.M18_BOM_SHOP_SYNC_ENABLED] must still be true). */ | |||||
| @GetMapping("/trigger/bom-shop-sync-all") | |||||
| fun triggerBomShopSyncAll(): String { | |||||
| schedulerService.getM18BomShopPushAllBoms() | |||||
| return "M18 BOM Shop (all BOMs) sync triggered (see scheduler_sync_log type M18_BOM_SHOP)" | |||||
| } | |||||
| @GetMapping("/updateSetting/bomShopCron") | |||||
| fun scheduleBomShop(@RequestParam @Valid newCron: String) { | |||||
| settingsService.update(SettingNames.SCHEDULE_M18_BOM_SHOP, newCron) | |||||
| schedulerService.scheduleM18BomShop() | |||||
| } | |||||
| @GetMapping("/trigger/master-data") | @GetMapping("/trigger/master-data") | ||||
| fun triggerMasterData(): String { | fun triggerMasterData(): String { | ||||
| schedulerService.getM18MasterData() | schedulerService.getM18MasterData() | ||||
| @@ -88,4 +111,9 @@ class SchedulerController( | |||||
| schedulerService.init() | schedulerService.init() | ||||
| return "Cron Schedules Refreshed from Database" | return "Cron Schedules Refreshed from Database" | ||||
| } | } | ||||
| @GetMapping("/trigger/jo-plan-start") | |||||
| fun triggerJoPlanStart(): String { | |||||
| schedulerService.runJobOrderPlanStartAuto() | |||||
| return "Job order plan-start auto triggered" | |||||
| } | |||||
| } | } | ||||
| @@ -62,4 +62,8 @@ open class DeliveryOrder: BaseEntity<Long>() { | |||||
| @Column(name = "m18BeId") | @Column(name = "m18BeId") | ||||
| open var m18BeId: Long? = null | open var m18BeId: Long? = null | ||||
| /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ | |||||
| @Column(name = "isExtra", nullable = false) | |||||
| open var isExtra: Boolean = false | |||||
| } | } | ||||
| @@ -61,6 +61,10 @@ class DeliveryOrderPickOrder { | |||||
| @Column(name = "cartonQty") | @Column(name = "cartonQty") | ||||
| var cartonQty: Int? = null | var cartonQty: Int? = null | ||||
| /** Merge lineage: equals own [id] until soft-deleted into a successor [TI-M] header. */ | |||||
| @Column(name = "relationshipId") | |||||
| var relationshipId: Long? = null | |||||
| @CreationTimestamp | @CreationTimestamp | ||||
| @Column(name = "created") | @Column(name = "created") | ||||
| var created: LocalDateTime? = null | var created: LocalDateTime? = null | ||||
| @@ -15,6 +15,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.* | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.* | import com.ffii.fpsms.modules.deliveryOrder.entity.models.* | ||||
| @Repository | @Repository | ||||
| interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> { | interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> { | ||||
| fun existsByCodeAndDeletedIsFalse(code: String): Boolean | |||||
| @Query(""" | @Query(""" | ||||
| select d from DeliveryOrder d | select d from DeliveryOrder d | ||||
| where d.deleted = false | where d.deleted = false | ||||
| @@ -109,6 +111,7 @@ fun searchDoLite( | |||||
| and (:status is null or d.status = :status) | and (:status is null or d.status = :status) | ||||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | ||||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | ||||
| and (:isExtra is null or d.isExtra = :isExtra) | |||||
| order by d.id desc | order by d.id desc | ||||
| """) | """) | ||||
| fun searchDoLitePage( | fun searchDoLitePage( | ||||
| @@ -117,6 +120,7 @@ fun searchDoLitePage( | |||||
| @Param("status") status: DeliveryOrderStatus?, | @Param("status") status: DeliveryOrderStatus?, | ||||
| @Param("etaStart") etaStart: LocalDateTime?, | @Param("etaStart") etaStart: LocalDateTime?, | ||||
| @Param("etaEnd") etaEnd: LocalDateTime?, | @Param("etaEnd") etaEnd: LocalDateTime?, | ||||
| @Param("isExtra") isExtra: Boolean?, | |||||
| pageable: Pageable | pageable: Pageable | ||||
| ): Page<DeliveryOrderInfoLite> | ): Page<DeliveryOrderInfoLite> | ||||
| @@ -132,6 +136,7 @@ fun searchDoLitePage( | |||||
| and (:status is null or d.status = :status) | and (:status is null or d.status = :status) | ||||
| and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) | ||||
| and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) | ||||
| and (:isExtra is null or d.isExtra = :isExtra) | |||||
| and d.supplier is not null | and d.supplier is not null | ||||
| and d.supplier.code in :allowedSupplierCodes | and d.supplier.code in :allowedSupplierCodes | ||||
| order by d.id desc | order by d.id desc | ||||
| @@ -143,6 +148,7 @@ fun searchDoLitePageWithSupplierCodes( | |||||
| @Param("status") status: DeliveryOrderStatus?, | @Param("status") status: DeliveryOrderStatus?, | ||||
| @Param("etaStart") etaStart: LocalDateTime?, | @Param("etaStart") etaStart: LocalDateTime?, | ||||
| @Param("etaEnd") etaEnd: LocalDateTime?, | @Param("etaEnd") etaEnd: LocalDateTime?, | ||||
| @Param("isExtra") isExtra: Boolean?, | |||||
| @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | @Param("allowedSupplierCodes") allowedSupplierCodes: List<String>, | ||||
| pageable: Pageable, | pageable: Pageable, | ||||
| ): Page<DeliveryOrderInfoLite> | ): Page<DeliveryOrderInfoLite> | ||||
| @@ -16,6 +16,8 @@ import java.time.LocalDate | |||||
| @Repository | @Repository | ||||
| interface DoPickOrderRecordRepository : JpaRepository<DoPickOrderRecord, Long> { | interface DoPickOrderRecordRepository : JpaRepository<DoPickOrderRecord, Long> { | ||||
| fun findByPickOrderId(pickOrderId: Long): List<DoPickOrderRecord> | fun findByPickOrderId(pickOrderId: Long): List<DoPickOrderRecord> | ||||
| fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrderRecord> | |||||
| fun findByTicketNoStartingWith(ticketPrefix: String): List<DoPickOrderRecord> | fun findByTicketNoStartingWith(ticketPrefix: String): List<DoPickOrderRecord> | ||||
| @Query(""" | @Query(""" | ||||
| @@ -21,6 +21,8 @@ interface DoPickOrderRepository : JpaRepository<DoPickOrder, Long> { | |||||
| ): List<DoPickOrder> | ): List<DoPickOrder> | ||||
| fun findByPickOrderId(pickOrderId: Long): List<DoPickOrder> | fun findByPickOrderId(pickOrderId: Long): List<DoPickOrder> | ||||
| fun findByDoOrderIdAndDeletedFalse(doOrderId: Long): List<DoPickOrder> | |||||
| fun findByTicketStatusIn(statuses: List<DoPickOrderStatus>): List<DoPickOrder> | fun findByTicketStatusIn(statuses: List<DoPickOrderStatus>): List<DoPickOrder> | ||||
| // 在 DoPickOrderRepository 中添加这个方法 | // 在 DoPickOrderRepository 中添加这个方法 | ||||
| fun findByHandledByAndTicketStatusIn(handledBy: Long, status: List<DoPickOrderStatus>): List<DoPickOrder> | fun findByHandledByAndTicketStatusIn(handledBy: Long, status: List<DoPickOrderStatus>): List<DoPickOrder> | ||||
| @@ -0,0 +1,106 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| @Entity | |||||
| @Table(name = "do_replenishment") | |||||
| open class DoReplenishment : BaseEntity<Long>() { | |||||
| @Size(max = 100) | |||||
| @NotNull | |||||
| @Column(name = "code", nullable = false, length = 100) | |||||
| open var code: String? = null | |||||
| @NotNull | |||||
| @Column(name = "deliveryDate", nullable = false) | |||||
| open var deliveryDate: LocalDate? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceDoId", nullable = false) | |||||
| open var sourceDoId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "sourceDoCode", length = 100) | |||||
| open var sourceDoCode: String? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceDoLineId", nullable = false) | |||||
| open var sourceDoLineId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceM18DataLogId", nullable = false) | |||||
| open var sourceM18DataLogId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceM18Id", nullable = false) | |||||
| open var sourceM18Id: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "itemId", nullable = false) | |||||
| open var itemId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "itemNo", length = 100) | |||||
| open var itemNo: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "itemName", length = 255) | |||||
| open var itemName: String? = null | |||||
| @NotNull | |||||
| @Column(name = "replenishQty", nullable = false, precision = 14, scale = 2) | |||||
| open var replenishQty: BigDecimal? = null | |||||
| @Column(name = "uomId") | |||||
| open var uomId: Long? = null | |||||
| @Column(name = "shopId") | |||||
| open var shopId: Long? = null | |||||
| @Size(max = 50) | |||||
| @Column(name = "shopCode", length = 50) | |||||
| open var shopCode: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "shopName", length = 255) | |||||
| open var shopName: String? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "truckLaneCode", length = 100) | |||||
| open var truckLaneCode: String? = null | |||||
| @Column(name = "targetDoId") | |||||
| open var targetDoId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "targetDoCode", length = 100) | |||||
| open var targetDoCode: String? = null | |||||
| @Column(name = "pickOrderLineId") | |||||
| open var pickOrderLineId: Long? = null | |||||
| @Column(name = "deliveryOrderPickOrderId") | |||||
| open var deliveryOrderPickOrderId: Long? = null | |||||
| @NotNull | |||||
| @Size(max = 20) | |||||
| @Column(name = "status", nullable = false, length = 20) | |||||
| open var status: String = STATUS_PENDING | |||||
| @Size(max = 500) | |||||
| @Column(name = "reason", length = 500) | |||||
| open var reason: String? = null | |||||
| companion object { | |||||
| const val STATUS_PENDING = "pending" | |||||
| const val STATUS_PROCESSING = "processing" | |||||
| const val STATUS_COMPLETED = "completed" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,84 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | |||||
| import java.time.LocalDate | |||||
| @Repository | |||||
| interface DoReplenishmentRepository : AbstractRepository<DoReplenishment, Long> { | |||||
| fun findByCodeAndDeletedIsFalse(code: String): DoReplenishment? | |||||
| fun existsBySourceDoLineIdAndStatusAndDeletedIsFalse(sourceDoLineId: Long, status: String): Boolean | |||||
| fun findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( | |||||
| sourceDoLineId: Long, | |||||
| status: String, | |||||
| ): DoReplenishment? | |||||
| fun findByTargetDoIdInAndDeletedIsFalse(targetDoIds: Collection<Long>): List<DoReplenishment> | |||||
| fun findFirstByPickOrderLineIdAndStatusAndDeletedIsFalse( | |||||
| pickOrderLineId: Long, | |||||
| status: String, | |||||
| ): DoReplenishment? | |||||
| @Query( | |||||
| """ | |||||
| SELECT r FROM DoReplenishment r | |||||
| LEFT JOIN DeliveryOrder targetDo ON targetDo.id = r.targetDoId AND targetDo.deleted = false | |||||
| WHERE r.deleted = false | |||||
| AND ( | |||||
| :deliveryDate IS NULL | |||||
| OR ( | |||||
| targetDo.id IS NOT NULL | |||||
| AND DATE(targetDo.estimatedArrivalDate) = :deliveryDate | |||||
| ) | |||||
| OR ( | |||||
| targetDo.id IS NULL | |||||
| AND r.deliveryDate = :deliveryDate | |||||
| ) | |||||
| ) | |||||
| AND (:status IS NULL OR r.status = :status) | |||||
| ORDER BY r.created DESC, r.id DESC | |||||
| """, | |||||
| ) | |||||
| fun search( | |||||
| @Param("deliveryDate") deliveryDate: LocalDate?, | |||||
| @Param("status") status: String?, | |||||
| ): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r FROM DoReplenishment r | |||||
| WHERE r.deleted = false | |||||
| AND r.status = :status | |||||
| AND ( | |||||
| :truckLaneCode IS NULL OR :truckLaneCode = '' | |||||
| OR LOWER(COALESCE(r.truckLaneCode, '')) LIKE LOWER(CONCAT('%', :truckLaneCode, '%')) | |||||
| ) | |||||
| AND ( | |||||
| :shopName IS NULL OR :shopName = '' | |||||
| OR LOWER(COALESCE(r.shopName, '')) LIKE LOWER(CONCAT('%', :shopName, '%')) | |||||
| OR LOWER(COALESCE(r.shopCode, '')) LIKE LOWER(CONCAT('%', :shopName, '%')) | |||||
| ) | |||||
| ORDER BY r.shopName, r.shopCode, r.code | |||||
| """, | |||||
| ) | |||||
| fun searchForBatchRelease( | |||||
| @Param("status") status: String, | |||||
| @Param("truckLaneCode") truckLaneCode: String?, | |||||
| @Param("shopName") shopName: String?, | |||||
| ): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r.code FROM DoReplenishment r | |||||
| WHERE r.deleted = false | |||||
| AND r.code LIKE CONCAT(:codePrefix, '%') | |||||
| """, | |||||
| ) | |||||
| fun findCodesByPrefix(@Param("codePrefix") codePrefix: String): List<String> | |||||
| } | |||||
| @@ -47,6 +47,9 @@ interface DeliveryOrderInfoLite { | |||||
| val supplierCode: String? | val supplierCode: String? | ||||
| @get:Value("#{target.shop?.addr3}") | @get:Value("#{target.shop?.addr3}") | ||||
| val shopAddress: String? | val shopAddress: String? | ||||
| @get:Value("#{target.isExtra}") | |||||
| val isExtra: Boolean | |||||
| } | } | ||||
| data class DeliveryOrderInfoLiteDto( | data class DeliveryOrderInfoLiteDto( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -57,5 +60,6 @@ data class DeliveryOrderInfoLiteDto( | |||||
| val shopName: String?, | val shopName: String?, | ||||
| val supplierName: String?, | val supplierName: String?, | ||||
| val shopAddress: String?, | val shopAddress: String?, | ||||
| val truckLanceCode: String? | |||||
| val truckLanceCode: String?, | |||||
| val isExtra: Boolean = false, | |||||
| ) | ) | ||||
| @@ -0,0 +1,95 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import com.ffii.fpsms.modules.settings.entity.SettingsRepository | |||||
| import org.springframework.stereotype.Service | |||||
| import java.util.Locale | |||||
| /** 供 DO 搜索/車線/報表 SQL 等共用的 2F/4F 供應商代碼(來自 `settings` CSV)。 */ | |||||
| @Service | |||||
| open class DoFloorSupplierSettingsService( | |||||
| private val settingsRepository: SettingsRepository, | |||||
| ) { | |||||
| companion object { | |||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F" | |||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F" | |||||
| private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y") | |||||
| private val DEFAULT_SUPPLIERS_4F = listOf("P06B") | |||||
| } | |||||
| open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> { | |||||
| val raw = settingsRepository.findByName(settingName).map { it.value }.orElse(null) | |||||
| ?.trim() | |||||
| .orEmpty() | |||||
| if (raw.isEmpty()) return defaultList | |||||
| val parsed = raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct() | |||||
| return parsed.ifEmpty { defaultList } | |||||
| } | |||||
| open fun loadDoFloorSupplierLists(): Pair<List<String>, List<String>> { | |||||
| val suppliers2F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_2F, DEFAULT_SUPPLIERS_2F) | |||||
| val suppliers4F = supplierCodesFromSetting(SETTING_DO_FLOOR_SUPPLIERS_4F, DEFAULT_SUPPLIERS_4F) | |||||
| return suppliers2F to suppliers4F | |||||
| } | |||||
| open fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||||
| val (codes2F, codes4F) = loadDoFloorSupplierLists() | |||||
| return when { | |||||
| f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() | |||||
| f == "2F" -> codes2F | |||||
| f == "4F" -> codes4F | |||||
| else -> (codes2F + codes4F).distinct() | |||||
| } | |||||
| } | |||||
| /** 4F 清單優先;其餘預設 2F(與既有 DO 車線邏輯一致)。 */ | |||||
| open fun preferredStoreFloorForSupplier( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String { | |||||
| val code = supplierCode?.trim().orEmpty() | |||||
| if (code.isEmpty()) return "2F" | |||||
| if (suppliers4F.contains(code)) return "4F" | |||||
| if (suppliers2F.contains(code)) return "2F" | |||||
| return "2F" | |||||
| } | |||||
| /** DO 揀貨建議:名單外供應商不限制 2F/4F。 */ | |||||
| open fun preferredFloorForPickLotOrNull( | |||||
| supplierCode: String?, | |||||
| suppliers2F: List<String>, | |||||
| suppliers4F: List<String>, | |||||
| ): String? { | |||||
| val code = supplierCode?.trim().orEmpty() | |||||
| if (code.isEmpty()) return null | |||||
| if (suppliers4F.contains(code)) return "4F" | |||||
| if (suppliers2F.contains(code)) return "2F" | |||||
| return null | |||||
| } | |||||
| data class SqlPreferredFloorCases( | |||||
| /** 例如 `CASE WHEN s.code IN (...) THEN '4F' ... END`(單行,可嵌入原生 SQL) */ | |||||
| val floorStringCase: String, | |||||
| val storeIdNumericCase: String, | |||||
| ) | |||||
| /** | |||||
| * 依目前 settings 產生原生 SQL CASE(供 JDBC 字串拼接)。 | |||||
| * @param codeExpr 已加別名的欄位,如 `s.code`、`supplier.code` | |||||
| */ | |||||
| open fun sqlPreferredFloorCases(codeExpr: String = "s.code"): SqlPreferredFloorCases { | |||||
| val (s2f, s4f) = loadDoFloorSupplierLists() | |||||
| val in4 = joinSqlInList(s4f) | |||||
| val in2 = joinSqlInList(s2f) | |||||
| val floor = | |||||
| "CASE WHEN $codeExpr IN ($in4) THEN '4F' WHEN $codeExpr IN ($in2) THEN '2F' ELSE NULL END" | |||||
| val storeId = | |||||
| "CASE WHEN $codeExpr IN ($in4) THEN 4 WHEN $codeExpr IN ($in2) THEN 2 ELSE NULL END" | |||||
| return SqlPreferredFloorCases(floorStringCase = floor, storeIdNumericCase = storeId) | |||||
| } | |||||
| private fun joinSqlInList(codes: List<String>): String = | |||||
| codes.joinToString(", ") { "'" + it.replace("'", "''") + "'" } | |||||
| } | |||||
| @@ -847,139 +847,90 @@ open class DoPickOrderService( | |||||
| * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | ||||
| */ | */ | ||||
| open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | ||||
| // Fetch all active DoPickOrders for the target date | |||||
| val doPickOrders = doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "2/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed) | |||||
| ) + doPickOrderRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "4/F", targetDate, listOf(DoPickOrderStatus.pending, DoPickOrderStatus.released, DoPickOrderStatus.completed) | |||||
| ) | |||||
| // Fetch all DoPickOrderRecords for the target date (completed records) | |||||
| val doPickOrderRecords = doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "2/F", targetDate, listOf(DoPickOrderStatus.completed) | |||||
| ) + doPickOrderRecordRepository.findByStoreIdAndRequiredDeliveryDateAndTicketStatusIn( | |||||
| "4/F", targetDate, listOf(DoPickOrderStatus.completed) | |||||
| ) | |||||
| // Combine both types into a unified data structure for aggregation | |||||
| data class TicketData( | |||||
| val storeId: String?, | |||||
| val truckId: Long?, | |||||
| val truckLanceCode: String?, | |||||
| val truckDepartureTime: java.time.LocalTime?, | |||||
| val shopId: Long?, | |||||
| val shopCode: String?, | |||||
| val ticketNo: String?, | |||||
| val ticketReleaseTime: LocalDateTime?, | |||||
| val ticketCompleteDateTime: LocalDateTime?, | |||||
| val ticketStatus: DoPickOrderStatus?, | |||||
| val doPickOrderId: Long?, | |||||
| val isRecord: Boolean | |||||
| ) | |||||
| val allTickets = mutableListOf<TicketData>() | |||||
| doPickOrders.forEach { dpo -> | |||||
| allTickets.add(TicketData( | |||||
| storeId = dpo.storeId, | |||||
| truckId = dpo.truckId, | |||||
| truckLanceCode = dpo.truckLanceCode, | |||||
| truckDepartureTime = dpo.truckDepartureTime, | |||||
| shopId = dpo.shopId, | |||||
| shopCode = dpo.shopCode, | |||||
| ticketNo = dpo.ticketNo, | |||||
| ticketReleaseTime = dpo.ticketReleaseTime, | |||||
| ticketCompleteDateTime = dpo.ticketCompleteDateTime, | |||||
| ticketStatus = dpo.ticketStatus, | |||||
| doPickOrderId = dpo.id, | |||||
| isRecord = false | |||||
| )) | |||||
| } | |||||
| doPickOrderRecords.forEach { record -> | |||||
| allTickets.add(TicketData( | |||||
| storeId = record.storeId, | |||||
| truckId = record.truckId, | |||||
| truckLanceCode = record.truckLanceCode, | |||||
| truckDepartureTime = record.truckDepartureTime, | |||||
| shopId = record.shopId, | |||||
| shopCode = record.shopCode, | |||||
| ticketNo = record.ticketNo, | |||||
| ticketReleaseTime = record.ticketReleaseTime, | |||||
| ticketCompleteDateTime = record.ticketCompleteDateTime, | |||||
| ticketStatus = record.ticketStatus, | |||||
| doPickOrderId = record.recordId, | |||||
| isRecord = true | |||||
| )) | |||||
| } | |||||
| // Group by storeId, truckLanceCode, truckDepartureTime | |||||
| val grouped = allTickets.groupBy { | |||||
| Triple(it.storeId, it.truckLanceCode, it.truckDepartureTime) | |||||
| } | |||||
| return grouped.map { (key, tickets) -> | |||||
| val (storeId, truckLanceCode, truckDepartureTime) = key | |||||
| // Count distinct shops | |||||
| val distinctShops = tickets.mapNotNull { it.shopId ?: it.shopCode?.hashCode()?.toLong() }.distinct().size | |||||
| // Count distinct tickets | |||||
| val distinctTickets = tickets.mapNotNull { it.ticketNo }.distinct().size | |||||
| // Calculate total items to pick | |||||
| var totalItems = 0 | |||||
| tickets.forEach { ticket -> | |||||
| if (ticket.doPickOrderId != null) { | |||||
| if (ticket.isRecord) { | |||||
| totalItems += countFGItemsFromRecordById(ticket.doPickOrderId) | |||||
| } else { | |||||
| totalItems += countFGItemsById(ticket.doPickOrderId) | |||||
| } | |||||
| } | |||||
| // Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line) | |||||
| // | |||||
| // NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途, | |||||
| // 回傳 null 讓前端保持相容即可。 | |||||
| val sql = """ | |||||
| SELECT | |||||
| dop.storeId AS storeId, | |||||
| dop.truckLanceCode AS truckLanceCode, | |||||
| dop.truckDepartureTime AS truckDepartureTime, | |||||
| COUNT(DISTINCT dop.shopCode) AS numberOfShopsToServe, | |||||
| COUNT(DISTINCT dop.ticketNo) AS numberOfPickTickets, | |||||
| COALESCE(SUM(pol_cnt.cnt), 0) AS totalItemsToPick, | |||||
| SUM(CASE WHEN dop.ticketReleaseTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsReleased, | |||||
| MIN(dop.ticketReleaseTime) AS firstTicketStartTime, | |||||
| SUM(CASE WHEN dop.ticketCompleteDateTime IS NOT NULL THEN 1 ELSE 0 END) AS numberOfTicketsCompleted, | |||||
| MAX(dop.ticketCompleteDateTime) AS lastTicketEndTime | |||||
| FROM fpsmsdb.delivery_order_pick_order dop | |||||
| LEFT JOIN ( | |||||
| SELECT | |||||
| po.deliveryOrderPickOrderId AS dopId, | |||||
| COUNT(pol.id) AS cnt | |||||
| FROM fpsmsdb.pick_order po | |||||
| INNER JOIN fpsmsdb.pick_order_line pol | |||||
| ON pol.poId = po.id | |||||
| AND pol.deleted = 0 | |||||
| WHERE po.deleted = 0 | |||||
| AND po.deliveryOrderPickOrderId IS NOT NULL | |||||
| GROUP BY po.deliveryOrderPickOrderId | |||||
| ) pol_cnt | |||||
| ON pol_cnt.dopId = dop.id | |||||
| WHERE dop.deleted = 0 | |||||
| AND dop.requiredDeliveryDate = :targetDate | |||||
| AND dop.ticketStatus IN ('pending', 'released', 'completed') | |||||
| GROUP BY dop.storeId, dop.truckLanceCode, dop.truckDepartureTime | |||||
| ORDER BY dop.storeId, dop.truckDepartureTime | |||||
| """.trimIndent() | |||||
| val rows = jdbcDao.queryForList(sql, mapOf("targetDate" to targetDate)) | |||||
| fun str(row: Map<String, Any?>, key: String): String? = row[key]?.toString() | |||||
| fun intVal(row: Map<String, Any?>, key: String): Int = | |||||
| when (val v = row[key]) { | |||||
| null -> 0 | |||||
| is Number -> v.toInt() | |||||
| else -> v.toString().toBigDecimalOrNull()?.toInt() ?: 0 | |||||
| } | } | ||||
| // Count released tickets (ticketReleaseTime is not null) | |||||
| val releasedTickets = tickets.count { it.ticketReleaseTime != null } | |||||
| // Find first ticket start time (earliest ticketReleaseTime) | |||||
| val firstTicketStartTime = tickets | |||||
| .mapNotNull { it.ticketReleaseTime } | |||||
| .minOrNull() | |||||
| // Count completed tickets (ticketCompleteDateTime is not null) | |||||
| val completedTickets = tickets.count { it.ticketCompleteDateTime != null } | |||||
| // Find last ticket end time (latest ticketCompleteDateTime) | |||||
| val lastTicketEndTime = tickets | |||||
| .mapNotNull { it.ticketCompleteDateTime } | |||||
| .maxOrNull() | |||||
| // Calculate pick time taken in minutes | |||||
| val pickTimeTakenMinutes = if (firstTicketStartTime != null && lastTicketEndTime != null) { | |||||
| ChronoUnit.MINUTES.between(firstTicketStartTime, lastTicketEndTime) | |||||
| } else { | |||||
| null | |||||
| fun timeVal(row: Map<String, Any?>, key: String): java.time.LocalTime? = | |||||
| when (val v = row[key]) { | |||||
| null -> null | |||||
| is java.time.LocalTime -> v | |||||
| is java.sql.Time -> v.toLocalTime() | |||||
| is java.time.OffsetTime -> v.toLocalTime() | |||||
| is String -> runCatching { java.time.LocalTime.parse(v) }.getOrNull() | |||||
| else -> null | |||||
| } | } | ||||
| // Get truck ID (use first non-null) | |||||
| val truckId = tickets.firstOrNull { it.truckId != null }?.truckId | |||||
| fun dtVal(row: Map<String, Any?>, key: String): LocalDateTime? = | |||||
| when (val v = row[key]) { | |||||
| null -> null | |||||
| is LocalDateTime -> v | |||||
| is java.sql.Timestamp -> v.toLocalDateTime() | |||||
| is String -> runCatching { LocalDateTime.parse(v) }.getOrNull() | |||||
| else -> null | |||||
| } | |||||
| return rows.map { row -> | |||||
| val first = dtVal(row, "firstTicketStartTime") | |||||
| val last = dtVal(row, "lastTicketEndTime") | |||||
| val minutes = if (first != null && last != null) ChronoUnit.MINUTES.between(first, last) else null | |||||
| TruckScheduleDashboardResponse( | TruckScheduleDashboardResponse( | ||||
| storeId = storeId, | |||||
| truckId = truckId, | |||||
| truckLanceCode = truckLanceCode, | |||||
| truckDepartureTime = truckDepartureTime, | |||||
| numberOfShopsToServe = distinctShops, | |||||
| numberOfPickTickets = distinctTickets, | |||||
| totalItemsToPick = totalItems, | |||||
| numberOfTicketsReleased = releasedTickets, | |||||
| firstTicketStartTime = firstTicketStartTime, | |||||
| numberOfTicketsCompleted = completedTickets, | |||||
| lastTicketEndTime = lastTicketEndTime, | |||||
| pickTimeTakenMinutes = pickTimeTakenMinutes | |||||
| storeId = str(row, "storeId"), | |||||
| truckId = null, | |||||
| truckLanceCode = str(row, "truckLanceCode"), | |||||
| truckDepartureTime = timeVal(row, "truckDepartureTime"), | |||||
| numberOfShopsToServe = intVal(row, "numberOfShopsToServe"), | |||||
| numberOfPickTickets = intVal(row, "numberOfPickTickets"), | |||||
| totalItemsToPick = intVal(row, "totalItemsToPick"), | |||||
| numberOfTicketsReleased = intVal(row, "numberOfTicketsReleased"), | |||||
| firstTicketStartTime = first, | |||||
| numberOfTicketsCompleted = intVal(row, "numberOfTicketsCompleted"), | |||||
| lastTicketEndTime = last, | |||||
| pickTimeTakenMinutes = minutes, | |||||
| ) | ) | ||||
| }.sortedWith(compareBy({ it.storeId }, { it.truckDepartureTime })) | |||||
| } | |||||
| } | } | ||||
| private fun countFGItemsById(doPickOrderId: Long): Int { | private fun countFGItemsById(doPickOrderId: Long): Int { | ||||
| @@ -103,6 +103,7 @@ class DoReleaseCoordinatorService( | |||||
| private val userRepository: UserRepository, | private val userRepository: UserRepository, | ||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||||
| ) { | ) { | ||||
| private val poolSize = Runtime.getRuntime().availableProcessors() | private val poolSize = Runtime.getRuntime().availableProcessors() | ||||
| private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | private val executor = Executors.newFixedThreadPool(min(poolSize, 4)) | ||||
| @@ -140,22 +141,15 @@ class DoReleaseCoordinatorService( | |||||
| private fun updateBatchTicketNumbers() { | private fun updateBatchTicketNumbers() { | ||||
| try { | try { | ||||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | ||||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||||
| val updateSql = """ | val updateSql = """ | ||||
| UPDATE fpsmsdb.do_pick_order dpo | UPDATE fpsmsdb.do_pick_order dpo | ||||
| INNER JOIN ( | INNER JOIN ( | ||||
| WITH PreferredFloor AS ( | WITH PreferredFloor AS ( | ||||
| SELECT | SELECT | ||||
| do.id AS deliveryOrderId, | do.id AS deliveryOrderId, | ||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN '4F' | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||||
| ELSE NULL | |||||
| END AS preferred_floor, | |||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN 4 | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||||
| ELSE NULL | |||||
| END AS preferred_store_id | |||||
| ${pfCases.floorStringCase} AS preferred_floor, | |||||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||||
| FROM fpsmsdb.delivery_order do | FROM fpsmsdb.delivery_order do | ||||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | ||||
| WHERE do.deleted = 0 | WHERE do.deleted = 0 | ||||
| @@ -307,20 +301,13 @@ class DoReleaseCoordinatorService( | |||||
| println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | println(" DEBUG: Getting ordered IDs for ${ids.size} orders") | ||||
| println(" DEBUG: First 5 IDs: ${ids.take(5)}") | println(" DEBUG: First 5 IDs: ${ids.take(5)}") | ||||
| val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | val dayOfWeekSql = getDayOfWeekAbbrSql("do.estimatedArrivalDate") | ||||
| val pfCases = doFloorSupplierSettingsService.sqlPreferredFloorCases("s.code") | |||||
| val sql = """ | val sql = """ | ||||
| WITH PreferredFloor AS ( | WITH PreferredFloor AS ( | ||||
| SELECT | SELECT | ||||
| do.id AS deliveryOrderId, | do.id AS deliveryOrderId, | ||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN '4F' | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN '2F' | |||||
| ELSE NULL | |||||
| END AS preferred_floor, | |||||
| CASE | |||||
| WHEN s.code = 'P06B' THEN 4 | |||||
| WHEN s.code = 'P07' OR s.code = 'P06D' THEN 2 | |||||
| ELSE NULL | |||||
| END AS preferred_store_id | |||||
| ${pfCases.floorStringCase} AS preferred_floor, | |||||
| ${pfCases.storeIdNumericCase} AS preferred_store_id | |||||
| FROM fpsmsdb.delivery_order do | FROM fpsmsdb.delivery_order do | ||||
| LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | LEFT JOIN fpsmsdb.shop s ON s.id = do.supplierId AND s.deleted = 0 | ||||
| WHERE do.id IN (${ids.joinToString(",")}) | WHERE do.id IN (${ids.joinToString(",")}) | ||||
| @@ -0,0 +1,556 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import com.ffii.core.support.JdbcDao | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishment | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishmentRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderLineRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoResult | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentLineRequest | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest | |||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| import java.time.format.DateTimeFormatter | |||||
| @Service | |||||
| open class DoReplenishmentService( | |||||
| private val doReplenishmentRepository: DoReplenishmentRepository, | |||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||||
| private val deliveryOrderLineRepository: DeliveryOrderLineRepository, | |||||
| private val doPickOrderRepository: DoPickOrderRepository, | |||||
| private val doPickOrderLineRepository: DoPickOrderLineRepository, | |||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | |||||
| private val stockOutLIneRepository: StockOutLIneRepository, | |||||
| private val uomConversionRepository: UomConversionRepository, | |||||
| private val pickOrderRepository: PickOrderRepository, | |||||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||||
| private val itemsRepository: ItemsRepository, | |||||
| private val jdbcDao: JdbcDao, | |||||
| ) { | |||||
| @Transactional | |||||
| open fun submit(request: SubmitDoReplenishmentRequest): List<DoReplenishmentResponse> { | |||||
| if (request.lines.isEmpty()) { | |||||
| throw IllegalArgumentException("No replenishment lines to submit") | |||||
| } | |||||
| val nextSeqByDate = mutableMapOf<LocalDate, Int>() | |||||
| val created = mutableListOf<DoReplenishment>() | |||||
| val mergedLines = mergeSubmitLines(request.lines) | |||||
| for (lineReq in mergedLines) { | |||||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(lineReq.sourceDoId) | |||||
| ?: throw IllegalArgumentException("Source delivery order not found: ${lineReq.sourceDoId}") | |||||
| if (deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { | |||||
| throw IllegalArgumentException("Source delivery order must be completed: ${deliveryOrder.code}") | |||||
| } | |||||
| val doLine = deliveryOrderLineRepository.findById(lineReq.sourceDoLineId).orElse(null) | |||||
| ?: throw IllegalArgumentException("Source delivery order line not found: ${lineReq.sourceDoLineId}") | |||||
| if (doLine.deleted == true || doLine.deliveryOrder?.id != lineReq.sourceDoId) { | |||||
| throw IllegalArgumentException("Source line does not belong to delivery order ${lineReq.sourceDoId}") | |||||
| } | |||||
| val existingPending = doReplenishmentRepository.findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( | |||||
| lineReq.sourceDoLineId, | |||||
| DoReplenishment.STATUS_PENDING, | |||||
| ) | |||||
| if (existingPending != null) { | |||||
| existingPending.replenishQty = | |||||
| (existingPending.replenishQty ?: BigDecimal.ZERO).add(lineReq.replenishQty) | |||||
| if (existingPending.truckLaneCode.isNullOrBlank()) { | |||||
| existingPending.truckLaneCode = | |||||
| resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) | |||||
| } | |||||
| lineReq.reason?.trim()?.takeIf { it.isNotEmpty() }?.let { existingPending.reason = it } | |||||
| created += doReplenishmentRepository.save(existingPending) | |||||
| continue | |||||
| } | |||||
| val m18DataLog = doLine.m18DataLog | |||||
| ?: throw IllegalArgumentException("Source line missing M18 data log") | |||||
| val m18Id = m18DataLog.m18Id | |||||
| ?: throw IllegalArgumentException("Source line missing M18 id") | |||||
| val item = doLine.item | |||||
| ?: throw IllegalArgumentException("Source line missing item") | |||||
| val seq = nextSeqByDate.getOrPut(lineReq.deliveryDate) { | |||||
| nextCodeSequence(lineReq.deliveryDate) | |||||
| } | |||||
| nextSeqByDate[lineReq.deliveryDate] = seq + 1 | |||||
| val code = formatReplenishmentCode(lineReq.deliveryDate, seq) | |||||
| val shop = deliveryOrder.shop | |||||
| val entity = DoReplenishment().apply { | |||||
| this.code = code | |||||
| deliveryDate = lineReq.deliveryDate | |||||
| sourceDoId = lineReq.sourceDoId | |||||
| sourceDoCode = deliveryOrder.code | |||||
| sourceDoLineId = lineReq.sourceDoLineId | |||||
| sourceM18DataLogId = m18DataLog.id | |||||
| sourceM18Id = m18Id | |||||
| itemId = item.id | |||||
| itemNo = doLine.itemNo ?: item.code | |||||
| itemName = item.name | |||||
| replenishQty = lineReq.replenishQty | |||||
| uomId = doLine.uom?.id | |||||
| shopId = shop?.id | |||||
| shopCode = shop?.code | |||||
| shopName = shop?.name | |||||
| truckLaneCode = resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) | |||||
| status = DoReplenishment.STATUS_PENDING | |||||
| reason = lineReq.reason?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| created += doReplenishmentRepository.save(entity) | |||||
| } | |||||
| return toResponses(created) | |||||
| } | |||||
| open fun list(deliveryDate: LocalDate?, status: String?): List<DoReplenishmentResponse> { | |||||
| val normalizedStatus = status?.trim()?.takeIf { it.isNotEmpty() && it != "all" } | |||||
| val rows = doReplenishmentRepository.search(deliveryDate, normalizedStatus) | |||||
| return toResponses(rows) | |||||
| } | |||||
| open fun listForBatchRelease( | |||||
| truckLaneCode: String?, | |||||
| shopName: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| val truck = truckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val shop = shopName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| if (truck == null && shop == null) { | |||||
| return emptyList() | |||||
| } | |||||
| val rows = doReplenishmentRepository.searchForBatchRelease( | |||||
| status = DoReplenishment.STATUS_PENDING, | |||||
| truckLaneCode = truck, | |||||
| shopName = shop, | |||||
| ) | |||||
| return toResponses(rows) | |||||
| } | |||||
| open fun findReplenishmentsByTargetDoIds(targetDoIds: Collection<Long>): List<DoReplenishment> { | |||||
| if (targetDoIds.isEmpty()) return emptyList() | |||||
| return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(targetDoIds) | |||||
| } | |||||
| data class ReplenishPdfIndex( | |||||
| private val targetDoItemKeys: Set<Pair<Long, Long>>, | |||||
| private val pickOrderLineIds: Set<Long>, | |||||
| ) { | |||||
| fun matches(deliveryOrderId: Long, itemId: Long?, pickOrderLineId: Long?): Boolean { | |||||
| if (pickOrderLineId != null && pickOrderLineId in pickOrderLineIds) return true | |||||
| val resolvedItemId = itemId ?: return false | |||||
| return deliveryOrderId to resolvedItemId in targetDoItemKeys | |||||
| } | |||||
| companion object { | |||||
| val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) | |||||
| } | |||||
| } | |||||
| open fun buildReplenishPdfIndex(deliveryOrderIds: Collection<Long>): ReplenishPdfIndex { | |||||
| if (deliveryOrderIds.isEmpty()) return ReplenishPdfIndex.EMPTY | |||||
| val records = doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(deliveryOrderIds) | |||||
| .filter { it.pickOrderLineId != null } | |||||
| if (records.isEmpty()) return ReplenishPdfIndex.EMPTY | |||||
| return ReplenishPdfIndex( | |||||
| targetDoItemKeys = records.mapNotNull { row -> | |||||
| val targetDoId = row.targetDoId | |||||
| val itemId = row.itemId | |||||
| if (targetDoId != null && itemId != null) targetDoId to itemId else null | |||||
| }.toSet(), | |||||
| pickOrderLineIds = records.mapNotNull { it.pickOrderLineId }.toSet(), | |||||
| ) | |||||
| } | |||||
| /** Replenishment POL rows with no matching DOL on the target DO — append as extra DN lines. */ | |||||
| open fun replenishmentsWithoutDeliveryOrderLine( | |||||
| deliveryOrderIds: Collection<Long>, | |||||
| exportLines: List<DeliveryOrderService.DeliveryNoteExportLine>, | |||||
| ): List<DoReplenishment> { | |||||
| if (deliveryOrderIds.isEmpty()) return emptyList() | |||||
| val dolItemKeys = exportLines.mapNotNull { row -> | |||||
| row.line.itemId?.let { row.deliveryOrderId to it } | |||||
| }.toSet() | |||||
| return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(deliveryOrderIds) | |||||
| .filter { replenishment -> | |||||
| val polId = replenishment.pickOrderLineId ?: return@filter false | |||||
| val targetDoId = replenishment.targetDoId ?: return@filter false | |||||
| val itemId = replenishment.itemId ?: return@filter false | |||||
| polId > 0 && (targetDoId to itemId) !in dolItemKeys | |||||
| } | |||||
| } | |||||
| @Transactional | |||||
| open fun completeByPickOrderLineId(pickOrderLineId: Long) { | |||||
| val row = doReplenishmentRepository.findFirstByPickOrderLineIdAndStatusAndDeletedIsFalse( | |||||
| pickOrderLineId, | |||||
| DoReplenishment.STATUS_PROCESSING, | |||||
| ) ?: return | |||||
| row.status = DoReplenishment.STATUS_COMPLETED | |||||
| doReplenishmentRepository.save(row) | |||||
| } | |||||
| /** | |||||
| * After workbench batch release links pick orders to a ticket, create replenishment POL rows | |||||
| * (no DOL), assign target DO / ticket FKs, and move status pending → processing. | |||||
| * | |||||
| * @return pick order ids that received new replenishment lines (for V1 downstream rebuild) | |||||
| */ | |||||
| @Transactional(rollbackFor = [Exception::class]) | |||||
| open fun releasePendingReplenishmentsForWorkbenchBatch( | |||||
| releasedResults: List<ReleaseDoResult>, | |||||
| ): Set<Long> { | |||||
| if (releasedResults.isEmpty()) return emptySet() | |||||
| val pending = findPendingReplenishmentsForReleasedResults(releasedResults) | |||||
| if (pending.isEmpty()) return emptySet() | |||||
| val affectedPickOrderIds = mutableSetOf<Long>() | |||||
| for (replenishment in pending) { | |||||
| val matchingResults = releasedResults.filter { replenishmentMatchesResult(replenishment, it) } | |||||
| if (matchingResults.isEmpty()) continue | |||||
| val targetResult = matchingResults.minByOrNull { it.deliveryOrderId } | |||||
| ?: continue | |||||
| val dopoId = resolveDeliveryOrderPickOrderId(targetResult.pickOrderId) | |||||
| ?: continue | |||||
| val ticketPickOrderIds = pickOrderRepository.findIdsByDeliveryOrderPickOrderId(dopoId) | |||||
| if (ticketPickOrderIds.isEmpty()) continue | |||||
| val itemId = replenishment.itemId ?: continue | |||||
| val targetPickOrderId = resolveTargetPickOrderId(ticketPickOrderIds, itemId) | |||||
| val pickOrder = pickOrderRepository.findById(targetPickOrderId).orElse(null) ?: continue | |||||
| val item = itemsRepository.findById(itemId).orElse(null) ?: continue | |||||
| val uom = replenishment.uomId?.let { uomConversionRepository.findById(it).orElse(null) } | |||||
| ?: continue | |||||
| val pol = PickOrderLine().apply { | |||||
| this.pickOrder = pickOrder | |||||
| this.item = item | |||||
| this.qty = replenishment.replenishQty | |||||
| this.uom = uom | |||||
| this.status = PickOrderLineStatus.PENDING | |||||
| } | |||||
| val savedPol = pickOrderLineRepository.save(pol) | |||||
| pickOrder.totalLines = (pickOrder.totalLines ?: 0) + 1 | |||||
| pickOrderRepository.save(pickOrder) | |||||
| replenishment.targetDoId = targetResult.deliveryOrderId | |||||
| replenishment.targetDoCode = targetResult.deliveryOrderCode | |||||
| replenishment.pickOrderLineId = savedPol.id | |||||
| replenishment.deliveryOrderPickOrderId = dopoId | |||||
| replenishment.status = DoReplenishment.STATUS_PROCESSING | |||||
| doReplenishmentRepository.save(replenishment) | |||||
| affectedPickOrderIds += targetPickOrderId | |||||
| } | |||||
| return affectedPickOrderIds | |||||
| } | |||||
| private fun findPendingReplenishmentsForReleasedResults( | |||||
| releasedResults: List<ReleaseDoResult>, | |||||
| ): List<DoReplenishment> { | |||||
| val pairs = releasedResults | |||||
| .map { result -> | |||||
| (result.shopName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: result.shopCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: "") to (result.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() } ?: "") | |||||
| } | |||||
| .distinct() | |||||
| .filter { (shop, truck) -> shop.isNotEmpty() || truck.isNotEmpty() } | |||||
| if (pairs.isEmpty()) return emptyList() | |||||
| val byId = linkedMapOf<Long, DoReplenishment>() | |||||
| for ((shop, truck) in pairs) { | |||||
| val rows = doReplenishmentRepository.searchForBatchRelease( | |||||
| status = DoReplenishment.STATUS_PENDING, | |||||
| truckLaneCode = truck.takeIf { it.isNotEmpty() }, | |||||
| shopName = shop.takeIf { it.isNotEmpty() }, | |||||
| ) | |||||
| for (row in rows) { | |||||
| val id = row.id ?: continue | |||||
| if (releasedResults.any { replenishmentMatchesResult(row, it) }) { | |||||
| byId[id] = row | |||||
| } | |||||
| } | |||||
| } | |||||
| return byId.values.toList() | |||||
| } | |||||
| private fun replenishmentMatchesResult( | |||||
| replenishment: DoReplenishment, | |||||
| result: ReleaseDoResult, | |||||
| ): Boolean { | |||||
| val doTruck = normalizeText(result.truckLanceCode) | |||||
| val recordTruck = normalizeText(replenishment.truckLaneCode) | |||||
| if (doTruck.isNotEmpty()) { | |||||
| if (recordTruck.isEmpty() || recordTruck != doTruck) return false | |||||
| } | |||||
| val doShopToken = shopTokenFromResult(result) | |||||
| if (doShopToken.isEmpty()) return false | |||||
| val recordShopCode = normalizeText(replenishment.shopCode) | |||||
| val recordShopName = normalizeText(replenishment.shopName) | |||||
| return recordShopCode == doShopToken || | |||||
| recordShopName.startsWith(doShopToken) || | |||||
| (recordShopCode.isNotEmpty() && doShopToken.startsWith(recordShopCode)) || | |||||
| (recordShopCode.isNotEmpty() && recordShopCode.startsWith(doShopToken)) | |||||
| } | |||||
| private fun shopTokenFromResult(result: ReleaseDoResult): String { | |||||
| val raw = result.shopCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: result.shopName?.trim() | |||||
| ?: "" | |||||
| if (raw.isEmpty()) return "" | |||||
| return normalizeText(raw.split(" - ").firstOrNull() ?: raw) | |||||
| } | |||||
| private fun normalizeText(value: String?): String = value?.trim()?.lowercase() ?: "" | |||||
| private fun resolveDeliveryOrderPickOrderId(pickOrderId: Long): Long? { | |||||
| return jdbcDao.queryForList( | |||||
| """ | |||||
| SELECT deliveryOrderPickOrderId AS dopoId | |||||
| FROM fpsmsdb.pick_order | |||||
| WHERE id = :pickOrderId AND deleted = 0 | |||||
| """.trimIndent(), | |||||
| mapOf("pickOrderId" to pickOrderId), | |||||
| ).firstOrNull() | |||||
| ?.get("dopoId") | |||||
| ?.let { (it as Number).toLong() } | |||||
| } | |||||
| /** Prefer pick_order that already has the same item on this ticket; else smallest pick_order.id. */ | |||||
| private fun resolveTargetPickOrderId(ticketPickOrderIds: List<Long>, itemId: Long): Long { | |||||
| val sortedIds = ticketPickOrderIds.sorted() | |||||
| val lines = pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(sortedIds) | |||||
| val pickOrderWithItem = lines | |||||
| .filter { it.item?.id == itemId } | |||||
| .mapNotNull { it.pickOrder?.id } | |||||
| .minOrNull() | |||||
| return pickOrderWithItem ?: sortedIds.first() | |||||
| } | |||||
| private fun nextCodeSequence(deliveryDate: LocalDate): Int { | |||||
| val prefix = codePrefix(deliveryDate) | |||||
| val suffixPattern = Regex("^${Regex.escape(prefix)}(\\d+)$") | |||||
| var maxSeq = 0 | |||||
| for (code in doReplenishmentRepository.findCodesByPrefix(prefix)) { | |||||
| suffixPattern.find(code)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { n -> | |||||
| if (n > maxSeq) maxSeq = n | |||||
| } | |||||
| } | |||||
| return maxSeq + 1 | |||||
| } | |||||
| private fun formatReplenishmentCode(deliveryDate: LocalDate, sequence: Int): String { | |||||
| return "${codePrefix(deliveryDate)}${sequence.toString().padStart(3, '0')}" | |||||
| } | |||||
| private fun codePrefix(deliveryDate: LocalDate): String { | |||||
| val ymd = deliveryDate.format(DateTimeFormatter.BASIC_ISO_DATE) | |||||
| return "RP-$ymd-" | |||||
| } | |||||
| /** 同一批次內相同來源行合併補貨數量。 */ | |||||
| private fun mergeSubmitLines( | |||||
| lines: List<SubmitDoReplenishmentLineRequest>, | |||||
| ): List<SubmitDoReplenishmentLineRequest> { | |||||
| if (lines.size <= 1) return lines | |||||
| val merged = linkedMapOf<String, SubmitDoReplenishmentLineRequest>() | |||||
| for (line in lines) { | |||||
| val key = "${line.sourceDoId}:${line.sourceDoLineId}" | |||||
| val existing = merged[key] | |||||
| if (existing == null) { | |||||
| merged[key] = line | |||||
| } else { | |||||
| merged[key] = existing.copy( | |||||
| replenishQty = existing.replenishQty.add(line.replenishQty), | |||||
| truckLaneCode = existing.truckLaneCode?.takeIf { it.isNotBlank() } ?: line.truckLaneCode, | |||||
| reason = existing.reason?.takeIf { it.isNotBlank() } ?: line.reason, | |||||
| ) | |||||
| } | |||||
| } | |||||
| return merged.values.toList() | |||||
| } | |||||
| /** 來源 DO 車線:優先 do_pick_order / do_pick_order_record,其次請求帶入值。 */ | |||||
| private fun resolveSourceDoTruckLaneCode( | |||||
| deliveryOrder: DeliveryOrder, | |||||
| requestTruckLaneCode: String?, | |||||
| ): String? { | |||||
| val sourceDoId = deliveryOrder.id ?: return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| doPickOrderRepository.findByDoOrderIdAndDeletedFalse(sourceDoId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(sourceDoId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| /** | |||||
| * Actual shipped qty per item on a completed source DO: sum of [stock_out_line.qty] | |||||
| * for the linked pick order line. Falls back to [fallbackQtyByItemId] when no pick link exists | |||||
| * (same rule as delivery note PDF). | |||||
| */ | |||||
| open fun resolveActualShippedQtyForDeliveryOrder( | |||||
| doId: Long, | |||||
| fallbackQtyByItemId: Map<Long, BigDecimal> = emptyMap(), | |||||
| ): Map<Long, BigDecimal> { | |||||
| val keys = fallbackQtyByItemId.keys.map { doId to it } | |||||
| if (keys.isEmpty()) { | |||||
| return emptyMap() | |||||
| } | |||||
| return resolveActualShippedQtyBySourceKeys(keys, keys.associateWith { fallbackQtyByItemId[it.second]!! }) | |||||
| .mapKeys { it.key.second } | |||||
| } | |||||
| private fun resolveActualShippedQtyBySourceKeys( | |||||
| keys: List<Pair<Long, Long>>, | |||||
| fallbackQtyByKey: Map<Pair<Long, Long>, BigDecimal>, | |||||
| ): Map<Pair<Long, Long>, BigDecimal> { | |||||
| if (keys.isEmpty()) { | |||||
| return emptyMap() | |||||
| } | |||||
| val doIds = keys.map { it.first }.distinct() | |||||
| val pickOrderIdByDoId = doIds.mapNotNull { doId -> | |||||
| resolvePickOrderIdForDo(doId)?.let { doId to it } | |||||
| }.toMap() | |||||
| val pickOrderIds = pickOrderIdByDoId.values.distinct() | |||||
| val pickOrderLines = if (pickOrderIds.isEmpty()) { | |||||
| emptyList() | |||||
| } else { | |||||
| pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(pickOrderIds) | |||||
| } | |||||
| val pickOrderLinesByPoId = pickOrderLines.groupBy { it.pickOrder?.id } | |||||
| val polIds = pickOrderLines.mapNotNull { it.id } | |||||
| val stockOutQtyByPolId = if (polIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(polIds) | |||||
| .groupBy { it.pickOrderLine?.id } | |||||
| .mapValues { (_, lines) -> | |||||
| lines.fold(BigDecimal.ZERO) { acc, line -> | |||||
| acc.add(BigDecimal.valueOf(line.qty ?: 0.0)) | |||||
| } | |||||
| } | |||||
| } | |||||
| return keys.associateWith { (doId, itemId) -> | |||||
| val pickOrderId = pickOrderIdByDoId[doId] | |||||
| val polId = pickOrderId?.let { poId -> | |||||
| pickOrderLinesByPoId[poId]?.firstOrNull { it.item?.id == itemId }?.id | |||||
| } | |||||
| if (polId != null) { | |||||
| stockOutQtyByPolId[polId] ?: BigDecimal.ZERO | |||||
| } else { | |||||
| fallbackQtyByKey[doId to itemId] ?: BigDecimal.ZERO | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun resolvePickOrderIdForDo(doId: Long): Long? { | |||||
| pickOrderRepository.findByDeliveryOrderId(doId).firstOrNull()?.id?.let { return it } | |||||
| return doPickOrderLineRepository.findByDoOrderIdAndDeletedFalse(doId) | |||||
| .mapNotNull { it.pickOrderId } | |||||
| .firstOrNull() | |||||
| } | |||||
| private fun toResponses(entities: List<DoReplenishment>): List<DoReplenishmentResponse> { | |||||
| val uomIds = entities.mapNotNull { it.uomId }.distinct() | |||||
| val shortUomById = if (uomIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| uomConversionRepository.findAllById(uomIds).associate { uom -> | |||||
| uom.id!! to (uom.udfShortDesc?.takeIf { it.isNotBlank() } ?: uom.code) | |||||
| } | |||||
| } | |||||
| val sourceLineIds = entities.mapNotNull { it.sourceDoLineId }.distinct() | |||||
| val sourceLineQtyById = if (sourceLineIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| deliveryOrderLineRepository.findAllById(sourceLineIds).associate { line -> | |||||
| line.id!! to line.qty | |||||
| } | |||||
| } | |||||
| val targetDoIds = entities.mapNotNull { it.targetDoId }.distinct() | |||||
| val targetDoEtaById = if (targetDoIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| deliveryOrderRepository.findAllById(targetDoIds).associate { deliveryOrder -> | |||||
| deliveryOrder.id!! to deliveryOrder.estimatedArrivalDate?.toLocalDate() | |||||
| } | |||||
| } | |||||
| val sourceKeys = entities.map { it.sourceDoId!! to it.itemId!! }.distinct() | |||||
| val fallbackQtyBySourceKey = entities.associate { row -> | |||||
| (row.sourceDoId!! to row.itemId!!) to ( | |||||
| row.sourceDoLineId?.let { sourceLineQtyById[it] } ?: BigDecimal.ZERO | |||||
| ) | |||||
| } | |||||
| val actualShippedQtyBySourceKey = resolveActualShippedQtyBySourceKeys( | |||||
| keys = sourceKeys, | |||||
| fallbackQtyByKey = fallbackQtyBySourceKey, | |||||
| ) | |||||
| return entities.map { row -> | |||||
| DoReplenishmentResponse( | |||||
| id = row.id!!, | |||||
| code = row.code!!, | |||||
| deliveryDate = row.deliveryDate!!, | |||||
| sourceDoId = row.sourceDoId!!, | |||||
| sourceDoCode = row.sourceDoCode, | |||||
| sourceDoLineId = row.sourceDoLineId!!, | |||||
| itemId = row.itemId!!, | |||||
| itemNo = row.itemNo, | |||||
| itemName = row.itemName, | |||||
| originalQty = actualShippedQtyBySourceKey[row.sourceDoId!! to row.itemId!!], | |||||
| replenishQty = row.replenishQty!!, | |||||
| shortUom = row.uomId?.let { shortUomById[it] }, | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| truckLaneCode = row.truckLaneCode, | |||||
| targetDoId = row.targetDoId, | |||||
| targetDoCode = row.targetDoCode, | |||||
| targetDoEstimatedArrivalDate = row.targetDoId?.let { targetDoEtaById[it] }, | |||||
| pickOrderLineId = row.pickOrderLineId, | |||||
| deliveryOrderPickOrderId = row.deliveryOrderPickOrderId, | |||||
| status = row.status, | |||||
| reason = row.reason, | |||||
| created = row.created, | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -110,7 +110,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| "4/F" -> "4/F" | "4/F" -> "4/F" | ||||
| else -> request.storeId | else -> request.storeId | ||||
| } | } | ||||
| println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") | |||||
| println(" DEBUG: assignByLaneForWorkbench storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}") | |||||
| val params = mutableMapOf<String, Any>( | val params = mutableMapOf<String, Any>( | ||||
| "storeId" to actualStoreId, | "storeId" to actualStoreId, | ||||
| @@ -140,12 +140,26 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | sql.append(" AND dop.truckDepartureTime = :depTime ") | ||||
| params["depTime"] = depSqlTime | params["depTime"] = depSqlTime | ||||
| } | } | ||||
| if (request.loadingSequence != null) { | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||||
| params["loadingSequence"] = request.loadingSequence | |||||
| } | |||||
| if (isisExtraReleaseType(request.releaseType)) { | |||||
| sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql()) | |||||
| } else { | |||||
| sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) | |||||
| } | |||||
| // Fetch a batch of candidates and try atomic-assign sequentially. | // Fetch a batch of candidates and try atomic-assign sequentially. | ||||
| // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | // This avoids forcing the frontend to refresh when a single picked candidate is concurrently assigned. | ||||
| val candidateLimit = 50 | val candidateLimit = 50 | ||||
| val maxRounds = 3 | val maxRounds = 3 | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| val shouldOrderBySequence = actualStoreId == "2/F" && request.loadingSequence == null | |||||
| if (shouldOrderBySequence) { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| } else { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT $candidateLimit ") | |||||
| } | |||||
| fun extractIds(rows: List<Map<String, Any?>>): List<Long> { | fun extractIds(rows: List<Map<String, Any?>>): List<Long> { | ||||
| if (rows.isEmpty()) return emptyList() | if (rows.isEmpty()) return emptyList() | ||||
| @@ -205,7 +219,7 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| "4/F" -> "4/F" | "4/F" -> "4/F" | ||||
| else -> request.storeId | else -> request.storeId | ||||
| } | } | ||||
| println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime}") | |||||
| println(" DEBUG: assignByLaneForWorkbenchV1 storeId=$actualStoreId date=${request.requiredDate} lane=${request.truckLanceCode} dep=${request.truckDepartureTime} seq=${request.loadingSequence}") | |||||
| val params = mutableMapOf<String, Any>( | val params = mutableMapOf<String, Any>( | ||||
| "storeId" to actualStoreId, | "storeId" to actualStoreId, | ||||
| @@ -234,7 +248,21 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| sql.append(" AND dop.truckDepartureTime = :depTime ") | sql.append(" AND dop.truckDepartureTime = :depTime ") | ||||
| params["depTime"] = depSqlTime | params["depTime"] = depSqlTime | ||||
| } | } | ||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") | |||||
| if (request.loadingSequence != null) { | |||||
| sql.append(" AND dop.loadingSequence = :loadingSequence ") | |||||
| params["loadingSequence"] = request.loadingSequence | |||||
| } | |||||
| if (isisExtraReleaseType(request.releaseType)) { | |||||
| sql.append(WorkbenchReleaseTypeSupport.legacyIsExtraSql()) | |||||
| } else { | |||||
| sql.append(WorkbenchReleaseTypeSupport.assignFilterSql(request.releaseType)) | |||||
| } | |||||
| val shouldOrderBySequenceV1 = actualStoreId == "2/F" && request.loadingSequence == null | |||||
| if (shouldOrderBySequenceV1) { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.loadingSequence ASC, dop.id ASC LIMIT 1 ") | |||||
| } else { | |||||
| sql.append(" ORDER BY dop.requiredDeliveryDate ASC, dop.truckDepartureTime ASC, dop.id ASC LIMIT 1 ") | |||||
| } | |||||
| val candidates = try { | val candidates = try { | ||||
| jdbcDao.queryForList(sql.toString(), params) | jdbcDao.queryForList(sql.toString(), params) | ||||
| @@ -283,6 +311,11 @@ open class DoWorkbenchDopoAssignmentService( | |||||
| } else null | } else null | ||||
| } | } | ||||
| private fun isisExtraReleaseType(releaseType: String?): Boolean { | |||||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||||
| return n == "isExtra" | |||||
| } | |||||
| private fun parseDepartureTimeToSql(raw: String?): Time? { | private fun parseDepartureTimeToSql(raw: String?): Time? { | ||||
| if (raw.isNullOrBlank()) return null | if (raw.isNullOrBlank()) return null | ||||
| val s = raw.trim() | val s = raw.trim() | ||||
| @@ -0,0 +1,55 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import java.util.Locale | |||||
| /** | |||||
| * 車線搜索正規化(search-do-lite-v2)。 | |||||
| * 實際 [com.ffii.fpsms.modules.pickOrder.entity.Truck] 編碼主要為 `車線-…` 或 `P06B_…`;未指派預設列為 `車線-X`(shopId 可為 null)。 | |||||
| */ | |||||
| object TruckLaneSearchSpec { | |||||
| const val UNASSIGNED_LANE_LABEL: String = "車線-X" | |||||
| sealed interface Mode { | |||||
| data object NoFilter : Mode | |||||
| /** 僅未指派:推算為 null/空白/字面量 車線-X(與預設車列一致) */ | |||||
| data object UnassignedOnly : Mode | |||||
| /** | |||||
| * 一般關鍵字:以 [needleLower](trim + [Locale.ROOT] lowercase)對推算車線做 [String.contains]。 | |||||
| * 不再因「`車線-` 開頭」額外併入未指派,避免搜 `車線-待1` 卻出現 `車線-X`;廣義條件(無車線欄位)仍由 [NoFilter] 帶出含 X 的列。 | |||||
| */ | |||||
| data class Keyword( | |||||
| val needleLower: String, | |||||
| ) : Mode | |||||
| } | |||||
| fun parse(raw: String?): Mode { | |||||
| val trimmed = raw?.trim().orEmpty() | |||||
| if (trimmed.isEmpty()) return Mode.NoFilter | |||||
| if (isUnassignedSearchToken(trimmed)) return Mode.UnassignedOnly | |||||
| return Mode.Keyword( | |||||
| needleLower = trimmed.lowercase(Locale.ROOT), | |||||
| ) | |||||
| } | |||||
| private fun isUnassignedSearchToken(trimmed: String): Boolean { | |||||
| if (trimmed.length == 1 && trimmed.equals("x", ignoreCase = true)) return true | |||||
| val normalized = trimmed.lowercase(Locale.ROOT).replace("车线", "車線") | |||||
| return normalized == "車線-x" | |||||
| } | |||||
| fun isUnassignedResolvedLane(calculated: String?): Boolean { | |||||
| if (calculated.isNullOrBlank()) return true | |||||
| return calculated.trim().equals(UNASSIGNED_LANE_LABEL, ignoreCase = true) | |||||
| } | |||||
| fun matches(mode: Mode, resolvedTruckLanceCode: String?): Boolean { | |||||
| when (mode) { | |||||
| Mode.NoFilter -> return true | |||||
| Mode.UnassignedOnly -> return isUnassignedResolvedLane(resolvedTruckLanceCode) | |||||
| is Mode.Keyword -> { | |||||
| val lane = resolvedTruckLanceCode?.trim()?.lowercase(Locale.ROOT).orEmpty() | |||||
| return lane.contains(mode.needleLower) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| /** | |||||
| * Workbench [delivery_order_pick_order.releaseType] values and SQL filters. | |||||
| * Legacy `isExtra` tickets are excluded from merge; only used for legacy Etra views. | |||||
| */ | |||||
| object WorkbenchReleaseTypeSupport { | |||||
| const val BATCH = "batch" | |||||
| const val SINGLE = "single" | |||||
| const val IS_EXTRA_BATCH = "isExtrabatch" | |||||
| const val IS_EXTRA_SINGLE = "isExtrasingle" | |||||
| const val LEGACY_IS_EXTRA = "isExtra" | |||||
| fun batchFamilyTypes(): List<String> = listOf(BATCH, IS_EXTRA_BATCH) | |||||
| fun singleFamilyTypes(): List<String> = listOf(SINGLE, IS_EXTRA_SINGLE) | |||||
| fun summaryFilterSql(releaseType: String, column: String = "dop.releaseType"): String = | |||||
| when (releaseType.trim().lowercase()) { | |||||
| "batch" -> batchFamilySql(column) | |||||
| "single" -> singleFamilySql(column) | |||||
| "isextra" -> legacyIsExtraSql(column) | |||||
| else -> "" | |||||
| } | |||||
| fun assignFilterSql(releaseType: String?, column: String = "dop.releaseType"): String { | |||||
| val n = releaseType?.trim()?.lowercase().orEmpty() | |||||
| return when (n) { | |||||
| "batch" -> batchFamilySql(column) | |||||
| "single" -> singleFamilySql(column) | |||||
| "isextra" -> legacyIsExtraSql(column) | |||||
| else -> "" | |||||
| } | |||||
| } | |||||
| fun batchFamilySql(column: String = "dop.releaseType"): String = | |||||
| " AND LOWER(COALESCE($column, '')) IN ('batch', 'isextrabatch') " | |||||
| fun singleFamilySql(column: String = "dop.releaseType"): String = | |||||
| " AND LOWER(COALESCE($column, '')) IN ('single', 'isextrasingle') " | |||||
| fun legacyIsExtraSql(column: String = "dop.releaseType"): String = | |||||
| " AND LOWER(COALESCE($column, '')) = 'isextra' " | |||||
| fun newHeaderReleaseType(isExtraRelease: Boolean, isSingleRelease: Boolean): String = when { | |||||
| isExtraRelease && isSingleRelease -> IS_EXTRA_SINGLE | |||||
| isExtraRelease -> IS_EXTRA_BATCH | |||||
| isSingleRelease -> SINGLE | |||||
| else -> BATCH | |||||
| } | |||||
| /** [TI-M] merged workbench ticket release type (batch-family merge). */ | |||||
| fun mergeTicketReleaseType(isSingleRelease: Boolean): String = | |||||
| if (isSingleRelease) IS_EXTRA_SINGLE else IS_EXTRA_BATCH | |||||
| fun upgradedReleaseTypeIfNeeded(currentType: String?, isExtraRelease: Boolean, isSingleRelease: Boolean): String? { | |||||
| if (!isExtraRelease) return null | |||||
| val cur = currentType?.trim()?.lowercase().orEmpty() | |||||
| return when { | |||||
| isSingleRelease && cur == SINGLE.lowercase() -> IS_EXTRA_SINGLE | |||||
| !isSingleRelease && cur == BATCH.lowercase() -> IS_EXTRA_BATCH | |||||
| else -> null | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -4,6 +4,7 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo | ||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest | ||||
| @@ -44,7 +45,10 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.Check4FTruckBatchResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest | |||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import java.time.LocalDate | |||||
| @RequestMapping("/do") | @RequestMapping("/do") | ||||
| @RestController | @RestController | ||||
| @@ -52,7 +56,7 @@ class DeliveryOrderController( | |||||
| private val deliveryOrderService: DeliveryOrderService, | private val deliveryOrderService: DeliveryOrderService, | ||||
| private val stockInLineService: StockInLineService, | private val stockInLineService: StockInLineService, | ||||
| private val doPickOrderService: DoPickOrderService, | private val doPickOrderService: DoPickOrderService, | ||||
| private val doReplenishmentService: DoReplenishmentService, | |||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(javaClass) | private val log = LoggerFactory.getLogger(javaClass) | ||||
| @@ -70,7 +74,9 @@ class DeliveryOrderController( | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | estimatedArrivalDate = request.estimatedArrivalDate, | ||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| truckLanceCode = request.truckLanceCode | |||||
| truckLanceCode = request.truckLanceCode, | |||||
| floor = request.floor, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -86,6 +92,27 @@ class DeliveryOrderController( | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | estimatedArrivalDate = request.estimatedArrivalDate, | ||||
| pageNum = request.pageNum, | pageNum = request.pageNum, | ||||
| pageSize = request.pageSize, | pageSize = request.pageSize, | ||||
| floor = request.floor, | |||||
| isExtra = request.isExtra, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * DO 輕量搜索 v2:車線關鍵字正規化(`車線-X`/`x`/`車線-` 前綴併入未指派)、 | |||||
| * 允許供應商條件下分批掃描,避免單次載入過大;請求體同 [searchDoLite]。 | |||||
| */ | |||||
| @PostMapping("/search-do-lite-v2") | |||||
| fun searchDoLiteV2(@RequestBody request: SearchDeliveryOrderInfoRequest): RecordsRes<DeliveryOrderInfoLiteDto> { | |||||
| return deliveryOrderService.searchDoLiteByPageV2( | |||||
| code = request.code, | |||||
| shopName = request.shopName, | |||||
| status = request.status, | |||||
| estimatedArrivalDate = request.estimatedArrivalDate, | |||||
| pageNum = request.pageNum, | |||||
| pageSize = request.pageSize, | |||||
| truckLanceCode = request.truckLanceCode, | |||||
| floor = request.floor, | |||||
| isExtra = request.isExtra, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -99,6 +126,29 @@ class DeliveryOrderController( | |||||
| return deliveryOrderService.getDetailedDo(id); | return deliveryOrderService.getDetailedDo(id); | ||||
| } | } | ||||
| @PostMapping("/replenishment") | |||||
| fun submitReplenishment( | |||||
| @Valid @RequestBody request: SubmitDoReplenishmentRequest, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.submit(request) | |||||
| } | |||||
| @GetMapping("/replenishment") | |||||
| fun listReplenishment( | |||||
| @RequestParam(required = false) deliveryDate: LocalDate?, | |||||
| @RequestParam(required = false) status: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.list(deliveryDate, status) | |||||
| } | |||||
| @GetMapping("/replenishment/for-batch-release") | |||||
| fun listReplenishmentForBatchRelease( | |||||
| @RequestParam(required = false) truckLaneCode: String?, | |||||
| @RequestParam(required = false) shopName: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.listForBatchRelease(truckLaneCode, shopName) | |||||
| } | |||||
| @GetMapping("/search-code/{code}") | @GetMapping("/search-code/{code}") | ||||
| fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | ||||
| return deliveryOrderService.searchByCode(code); | return deliveryOrderService.searchByCode(code); | ||||
| @@ -24,6 +24,11 @@ import org.springframework.http.HttpStatus | |||||
| import org.springframework.http.MediaType | import org.springframework.http.MediaType | ||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import jakarta.validation.Valid | |||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperPrint | |||||
| import java.io.OutputStream | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/doPickOrder/workbench") | @RequestMapping("/doPickOrder/workbench") | ||||
| class DoWorkbenchController( | class DoWorkbenchController( | ||||
| @@ -96,23 +101,44 @@ class DoWorkbenchController( | |||||
| ) | ) | ||||
| } | } | ||||
| /** All Etra workbench tickets for a day, grouped by shop → truck (see [DoWorkbenchMainService.getWorkbenchEtraLaneSummary]). */ | |||||
| @GetMapping("/summary-is-etra") | |||||
| fun getWorkbenchEtraSummary( | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||||
| ): List<WorkbenchEtraShopLaneGroup> = | |||||
| doWorkbenchMainService.getWorkbenchEtraLaneSummary(requiredDate) | |||||
| /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | /** Past-date backlog tickets from `delivery_order_pick_order` (not `do_pick_order`). */ | ||||
| @GetMapping("/released") | @GetMapping("/released") | ||||
| fun getWorkbenchReleasedDoPickOrders( | fun getWorkbenchReleasedDoPickOrders( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String? | |||||
| @RequestParam(required = false) truck: String?, | |||||
| @RequestParam(required = false) releaseType: String?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(shopName, storeId, truck) | |||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelection( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| releaseTypeFilter = releaseType, | |||||
| ) | |||||
| } | } | ||||
| @GetMapping("/released-today") | @GetMapping("/released-today") | ||||
| fun getWorkbenchReleasedDoPickOrdersToday( | fun getWorkbenchReleasedDoPickOrdersToday( | ||||
| @RequestParam(required = false) shopName: String?, | @RequestParam(required = false) shopName: String?, | ||||
| @RequestParam(required = false) storeId: String?, | @RequestParam(required = false) storeId: String?, | ||||
| @RequestParam(required = false) truck: String? | |||||
| @RequestParam(required = false) truck: String?, | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate?, | |||||
| @RequestParam(required = false) releaseType: String?, | |||||
| ): List<ReleasedDoPickOrderListItem> { | ): List<ReleasedDoPickOrderListItem> { | ||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(shopName, storeId, truck) | |||||
| return doWorkbenchMainService.findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday( | |||||
| shopName, | |||||
| storeId, | |||||
| truck, | |||||
| requiredDeliveryDate = requiredDate, | |||||
| releaseTypeFilter = releaseType, | |||||
| ) | |||||
| } | } | ||||
| @PostMapping("/assign-by-delivery-order-pick-order-id") | @PostMapping("/assign-by-delivery-order-pick-order-id") | ||||
| @@ -152,19 +178,40 @@ class DoWorkbenchController( | |||||
| */ | */ | ||||
| @PostMapping("/batch-release/async-v2") | @PostMapping("/batch-release/async-v2") | ||||
| fun startWorkbenchBatchReleaseAsyncV2( | fun startWorkbenchBatchReleaseAsyncV2( | ||||
| @RequestBody ids: List<Long>, | |||||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||||
| @RequestParam(defaultValue = "1") userId: Long | |||||
| ): MessageResponse { | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2( | |||||
| request.ids, | |||||
| userId, | |||||
| request.mergeExtraIntoLaneTicket, | |||||
| ) | |||||
| } | |||||
| /** | |||||
| * One delivery order, same release pipeline as [startWorkbenchBatchReleaseAsyncV2], but | |||||
| * [delivery_order_pick_order.releaseType] = `single` and ticket prefix `TI-S-` (not batch / `TI-B-`). | |||||
| * Body: JSON number (mirrors [DoPickOrderController.startBatchReleaseAsyncSingle]). | |||||
| */ | |||||
| @PostMapping("/batch-release/async-single-v2") | |||||
| fun startWorkbenchBatchReleaseAsyncSingleV2( | |||||
| @RequestBody doId: Long, | |||||
| @RequestParam(defaultValue = "1") userId: Long | @RequestParam(defaultValue = "1") userId: Long | ||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncSingleV2(listOf(doId), userId) | |||||
| } | } | ||||
| /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | ||||
| @PostMapping("/batch-release/sync-v2") | @PostMapping("/batch-release/sync-v2") | ||||
| fun workbenchBatchReleaseSyncV2( | fun workbenchBatchReleaseSyncV2( | ||||
| @RequestBody ids: List<Long>, | |||||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||||
| @RequestParam(defaultValue = "1") userId: Long | @RequestParam(defaultValue = "1") userId: Long | ||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return doWorkbenchReleaseService.releaseBatchV2(ids, userId) | |||||
| return doWorkbenchReleaseService.releaseBatchV2( | |||||
| request.ids, | |||||
| userId, | |||||
| request.mergeExtraIntoLaneTicket, | |||||
| ) | |||||
| } | } | ||||
| @GetMapping("/batch-release/progress/{jobId}") | @GetMapping("/batch-release/progress/{jobId}") | ||||
| @@ -172,6 +219,22 @@ class DoWorkbenchController( | |||||
| return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) | return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) | ||||
| } | } | ||||
| /** Case 3: unassigned plain batch/single + isExtra tickets on the same lane (for merge UI). */ | |||||
| @GetMapping("/merge-ticket-candidates") | |||||
| fun getWorkbenchMergeTicketCandidates( | |||||
| @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate, | |||||
| @RequestParam(required = false) shopSearch: String?, | |||||
| ): WorkbenchMergeTicketCandidatesResponse = | |||||
| doWorkbenchReleaseService.getMergeTicketCandidates(requiredDate, shopSearch) | |||||
| /** Case 3 / 3b: merge batch/single or existing [TI-M] + isExtra into [TI-M]. */ | |||||
| @PostMapping("/merge-tickets") | |||||
| fun mergeWorkbenchTickets(@RequestBody request: WorkbenchMergeTicketsRequest): MessageResponse = | |||||
| doWorkbenchReleaseService.mergeTicketsCase3( | |||||
| request.batchOrSingleDopoId, | |||||
| request.isExtraDopoId, | |||||
| ) | |||||
| @GetMapping("/ticket-release-table/{startDate}&{endDate}") | @GetMapping("/ticket-release-table/{startDate}&{endDate}") | ||||
| fun getWorkbenchTicketReleaseTable( | fun getWorkbenchTicketReleaseTable( | ||||
| @PathVariable startDate: LocalDate, | @PathVariable startDate: LocalDate, | ||||
| @@ -203,6 +266,20 @@ class DoWorkbenchController( | |||||
| doWorkbenchMainService.printDeliveryNoteWorkbench(request) | doWorkbenchMainService.printDeliveryNoteWorkbench(request) | ||||
| } | } | ||||
| @PostMapping("/DN") | |||||
| fun downloadWorkbenchDN( | |||||
| @Valid @RequestBody request: ExportDeliveryNoteRequest, | |||||
| response: HttpServletResponse, | |||||
| ) { | |||||
| response.characterEncoding = "utf-8" | |||||
| response.contentType = "application/pdf" | |||||
| val out: OutputStream = response.outputStream | |||||
| val pdf = doWorkbenchMainService.exportDeliveryNoteWorkbench(request) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| response.addHeader("filename", "${pdf["filename"]}.pdf") | |||||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | |||||
| } | |||||
| @GetMapping("/print-DNLabels") | @GetMapping("/print-DNLabels") | ||||
| fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { | fun printWorkbenchDNLabels(@ModelAttribute request: PrintDNLabelsRequest) { | ||||
| doWorkbenchMainService.printDNLabelsWorkbench(request) | doWorkbenchMainService.printDNLabelsWorkbench(request) | ||||
| @@ -18,6 +18,12 @@ data class DoDetailResponse( | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | @JsonFormat(pattern = "yyyy-MM-dd") | ||||
| val completeDate: LocalDateTime?, | val completeDate: LocalDateTime?, | ||||
| val status: String?, | val status: String?, | ||||
| /** 加單 DO(M18 加單專用同步) */ | |||||
| val isExtra: Boolean = false, | |||||
| /** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */ | |||||
| val handlerName: String? = null, | |||||
| /** 來源 DO 車線(do_pick_order / delivery_order_pick_order) */ | |||||
| val truckLaneCode: String? = null, | |||||
| val deliveryOrderLines: List<DoDetailLineResponse> | val deliveryOrderLines: List<DoDetailLineResponse> | ||||
| ) | ) | ||||
| @@ -25,12 +31,18 @@ data class DoDetailLineResponse( | |||||
| val id: Long, | val id: Long, | ||||
| val itemNo: String?, | val itemNo: String?, | ||||
| val qty: java.math.BigDecimal?, | val qty: java.math.BigDecimal?, | ||||
| /** Sum of stock_out_line.qty for the linked pick order line; falls back to [qty] when unavailable. */ | |||||
| val actualShippedQty: java.math.BigDecimal?, | |||||
| val price: java.math.BigDecimal?, | val price: java.math.BigDecimal?, | ||||
| val status: String?, | val status: String?, | ||||
| val itemName: String?, | val itemName: String?, | ||||
| val uom: String?, | val uom: String?, | ||||
| val uomCode: String?, | val uomCode: String?, | ||||
| val shortUom: String?, | val shortUom: String?, | ||||
| /** Sum of (inQty - outQty - holdQty) on AVAILABLE lot lines for this item. */ | |||||
| val stockQty: java.math.BigDecimal?, | |||||
| /** `available` when stockQty >= qty, else `insufficient`. */ | |||||
| val availableStatus: String?, | |||||
| ) | ) | ||||
| data class StoreLaneSummary( | data class StoreLaneSummary( | ||||
| val storeId: String, | val storeId: String, | ||||
| @@ -49,7 +61,18 @@ data class LaneBtn( | |||||
| val unassigned: Int, | val unassigned: Int, | ||||
| val total: Int, | val total: Int, | ||||
| // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | // 同一 truckLanceCode + loadingSequence 的 handler 去重后逗号拼接 | ||||
| val handlerName: String? = null | |||||
| val handlerName: String? = null, | |||||
| /** Workbench Etra lane: `delivery_order_pick_order.storeId` (2/F, 4/F, …) for assign / modal scope */ | |||||
| val storeId: String? = null, | |||||
| /** Workbench Etra / lane row: `truckDepartureTime` as ISO local time string for assign-by-lane */ | |||||
| val truckDepartureTime: String? = null, | |||||
| ) | |||||
| /** All Etra (`releaseType=isExtra`) tickets for a day, grouped by shop then truck (no 2F/4F split in UI). */ | |||||
| data class WorkbenchEtraShopLaneGroup( | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val lanes: List<LaneBtn>, | |||||
| ) | ) | ||||
| data class AssignByLaneRequest( | data class AssignByLaneRequest( | ||||
| val userId: Long, | val userId: Long, | ||||
| @@ -57,9 +80,12 @@ data class AssignByLaneRequest( | |||||
| val truckDepartureTime: String?, // 可选:限定出车时间 | val truckDepartureTime: String?, // 可选:限定出车时间 | ||||
| val truckLanceCode: String , | val truckLanceCode: String , | ||||
| val loadingSequence: Int? = null, | val loadingSequence: Int? = null, | ||||
| val requiredDate: LocalDate? // 必填:车道编号 | |||||
| val requiredDate: LocalDate?, // 必填:车道编号 | |||||
| /** When `isExtra`, assignment candidates are limited to `releaseType = isExtra` rows. */ | |||||
| val releaseType: String? = null, | |||||
| ) | ) | ||||
| data class DoPickOrderSummaryItem( | data class DoPickOrderSummaryItem( | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: java.time.LocalTime?, | val truckDepartureTime: java.time.LocalTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| @@ -101,11 +127,13 @@ interface DoSearchRowProjection { | |||||
| } | } | ||||
| data class ReleasedDoPickOrderListItem( | data class ReleasedDoPickOrderListItem( | ||||
| val id: Long, // doPickOrderId,用於 assign | val id: Long, // doPickOrderId,用於 assign | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, // Date 欄 | val requiredDeliveryDate: LocalDate?, // Date 欄 | ||||
| val shopCode: String?, // Shop | val shopCode: String?, // Shop | ||||
| val shopName: String?, // Shop | val shopName: String?, // Shop | ||||
| val storeId: String?, // 2/F or 4/F | val storeId: String?, // 2/F or 4/F | ||||
| val truckLanceCode: String?, // Truck (Lane) | val truckLanceCode: String?, // Truck (Lane) | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, // Truck 時間 | val truckDepartureTime: LocalTime?, // Truck 時間 | ||||
| val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | ||||
| ) | ) | ||||
| @@ -0,0 +1,60 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import jakarta.validation.Valid | |||||
| import jakarta.validation.constraints.NotEmpty | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Positive | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | |||||
| data class SubmitDoReplenishmentLineRequest( | |||||
| @field:NotNull | |||||
| @field:JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val deliveryDate: LocalDate, | |||||
| @field:NotNull | |||||
| val sourceDoId: Long, | |||||
| @field:NotNull | |||||
| val sourceDoLineId: Long, | |||||
| @field:NotNull | |||||
| @field:Positive | |||||
| val replenishQty: BigDecimal, | |||||
| val truckLaneCode: String? = null, | |||||
| val reason: String? = null, | |||||
| ) | |||||
| data class SubmitDoReplenishmentRequest( | |||||
| @field:NotEmpty | |||||
| @field:Valid | |||||
| val lines: List<SubmitDoReplenishmentLineRequest>, | |||||
| ) | |||||
| data class DoReplenishmentResponse( | |||||
| val id: Long, | |||||
| val code: String, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val deliveryDate: LocalDate, | |||||
| val sourceDoId: Long, | |||||
| val sourceDoCode: String?, | |||||
| val sourceDoLineId: Long, | |||||
| val itemId: Long, | |||||
| val itemNo: String?, | |||||
| val itemName: String?, | |||||
| val originalQty: BigDecimal?, | |||||
| val replenishQty: BigDecimal, | |||||
| val shortUom: String?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val truckLaneCode: String?, | |||||
| val targetDoId: Long?, | |||||
| val targetDoCode: String?, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val targetDoEstimatedArrivalDate: LocalDate?, | |||||
| val pickOrderLineId: Long?, | |||||
| val deliveryOrderPickOrderId: Long?, | |||||
| val status: String, | |||||
| val reason: String?, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | |||||
| val created: LocalDateTime?, | |||||
| ) | |||||
| @@ -3,4 +3,5 @@ package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| data class ExportDNLabelsRequest ( | data class ExportDNLabelsRequest ( | ||||
| val doPickOrderId: Long, | val doPickOrderId: Long, | ||||
| val numOfCarton: Int, | val numOfCarton: Int, | ||||
| val blankCartonNumber: Boolean? = false, | |||||
| ) | ) | ||||
| @@ -4,5 +4,6 @@ data class PrintDNLabelsRequest ( | |||||
| val doPickOrderId: Long, | val doPickOrderId: Long, | ||||
| val printerId: Long, | val printerId: Long, | ||||
| val printQty: Int?, | val printQty: Int?, | ||||
| val numOfCarton: Int | |||||
| val numOfCarton: Int, | |||||
| val blankCartonNumber: Boolean? = false, | |||||
| ) | ) | ||||
| @@ -21,7 +21,8 @@ data class ReleaseDoResult( | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val loadingSequence: Int? | |||||
| val loadingSequence: Int?, | |||||
| val isExtra: Boolean = false, | |||||
| ) | ) | ||||
| data class SearchDeliveryOrderInfoRequest( | data class SearchDeliveryOrderInfoRequest( | ||||
| val code: String?, | val code: String?, | ||||
| @@ -30,5 +31,9 @@ data class SearchDeliveryOrderInfoRequest( | |||||
| val estimatedArrivalDate: LocalDateTime?, | val estimatedArrivalDate: LocalDateTime?, | ||||
| val pageSize: Int?, | val pageSize: Int?, | ||||
| val pageNum: Int?, | val pageNum: Int?, | ||||
| val truckLanceCode: String? | |||||
| val truckLanceCode: String?, | |||||
| /** `ALL`/`All`/null:P06B+P07+P06D+P06Y;`2F`:P07+P06D+P06Y ;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ | |||||
| val floor: String? = null, | |||||
| /** null:不篩 isExtra;true/false:只顯示加單或非加單 DO */ | |||||
| val isExtra: Boolean? = null, | |||||
| ) | ) | ||||
| @@ -20,6 +20,7 @@ data class SaveDeliveryOrderRequest( | |||||
| val handlerId: Long?, | val handlerId: Long?, | ||||
| val m18BeId: Long?, | val m18BeId: Long?, | ||||
| val deleted: Boolean? = false, | val deleted: Boolean? = false, | ||||
| val isExtra: Boolean? = false, | |||||
| ) | ) | ||||
| data class SaveDeliveryOrderStatusRequest( | data class SaveDeliveryOrderStatusRequest( | ||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | package com.ffii.fpsms.modules.deliveryOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -15,14 +16,18 @@ data class TicketReleaseTableResponse( | |||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| val ticketStatus: String?, | val ticketStatus: String?, | ||||
| val truckId: Long?, | val truckId: Long?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val shopId: Long?, | val shopId: Long?, | ||||
| val handledBy: Long?, | val handledBy: Long?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketReleaseTime: LocalDateTime?, | val ticketReleaseTime: LocalDateTime?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketCompleteDateTime: LocalDateTime?, | val ticketCompleteDateTime: LocalDateTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val shopCode: String?, | val shopCode: String?, | ||||
| val shopName: String?, | val shopName: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, | val requiredDeliveryDate: LocalDate?, | ||||
| val handlerName: String?, | val handlerName: String?, | ||||
| val numberOfFGItems: Int = 0, | val numberOfFGItems: Int = 0, | ||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | package com.ffii.fpsms.modules.deliveryOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -7,13 +8,16 @@ data class TruckScheduleDashboardResponse( | |||||
| val storeId: String?, | val storeId: String?, | ||||
| val truckId: Long?, | val truckId: Long?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val numberOfShopsToServe: Int, | val numberOfShopsToServe: Int, | ||||
| val numberOfPickTickets: Int, | val numberOfPickTickets: Int, | ||||
| val totalItemsToPick: Int, | val totalItemsToPick: Int, | ||||
| val numberOfTicketsReleased: Int, | val numberOfTicketsReleased: Int, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val firstTicketStartTime: LocalDateTime?, | val firstTicketStartTime: LocalDateTime?, | ||||
| val numberOfTicketsCompleted: Int, | val numberOfTicketsCompleted: Int, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val lastTicketEndTime: LocalDateTime?, | val lastTicketEndTime: LocalDateTime?, | ||||
| val pickTimeTakenMinutes: Long? | val pickTimeTakenMinutes: Long? | ||||
| ) | ) | ||||
| @@ -0,0 +1,11 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| /** | |||||
| * Workbench batch release body (async-v2 / sync-v2). | |||||
| * [mergeExtraIntoLaneTicket]: when true, isExtra DOs join batch/single family (isExtrabatch / isExtrasingle); | |||||
| * when false, standalone `releaseType=isExtra` tickets with `TI-E-` prefix. | |||||
| */ | |||||
| data class WorkbenchBatchReleaseRequest( | |||||
| val ids: List<Long> = emptyList(), | |||||
| val mergeExtraIntoLaneTicket: Boolean = true, | |||||
| ) | |||||
| @@ -0,0 +1,35 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalTime | |||||
| data class WorkbenchMergeTicketCandidate( | |||||
| val id: Long, | |||||
| val ticketNo: String?, | |||||
| val releaseType: String?, | |||||
| val shopId: Long?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val storeId: String?, | |||||
| val truckId: Long?, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, | |||||
| val truckLanceCode: String?, | |||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | |||||
| val loadingSequence: Int?, | |||||
| val deliveryOrderCodes: List<String>, | |||||
| /** Stable lane identity for same-truck merge matching (2/F, 4/F, truck-X). */ | |||||
| val laneKey: String, | |||||
| ) | |||||
| data class WorkbenchMergeTicketCandidatesResponse( | |||||
| val batchFamilyTickets: List<WorkbenchMergeTicketCandidate>, | |||||
| val isExtraTickets: List<WorkbenchMergeTicketCandidate>, | |||||
| ) | |||||
| data class WorkbenchMergeTicketsRequest( | |||||
| val batchOrSingleDopoId: Long, | |||||
| val isExtraDopoId: Long, | |||||
| ) | |||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | package com.ffii.fpsms.modules.deliveryOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -10,13 +11,17 @@ data class WorkbenchTicketReleaseTableResponse( | |||||
| val ticketNo: String?, | val ticketNo: String?, | ||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| val ticketStatus: String?, | val ticketStatus: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val handledBy: Long?, | val handledBy: Long?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketReleaseTime: LocalDateTime?, | val ticketReleaseTime: LocalDateTime?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketCompleteDateTime: LocalDateTime?, | val ticketCompleteDateTime: LocalDateTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val shopCode: String?, | val shopCode: String?, | ||||
| val shopName: String?, | val shopName: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, | val requiredDeliveryDate: LocalDate?, | ||||
| val handlerName: String?, | val handlerName: String?, | ||||
| val numberOfFGItems: Int = 0, | val numberOfFGItems: Int = 0, | ||||
| @@ -25,16 +25,10 @@ class LaserBag2AutoSendScheduler( | |||||
| return | return | ||||
| } | } | ||||
| try { | try { | ||||
| val report = laserBag2AutoSendService.runAutoSend( | |||||
| laserBag2AutoSendService.runAutoSend( | |||||
| planStart = LocalDate.now(), | planStart = LocalDate.now(), | ||||
| limitPerRun = limitPerRun, | limitPerRun = limitPerRun, | ||||
| ) | ) | ||||
| logger.info( | |||||
| "Laser Bag2 scheduler: processed {}/{} job orders for {}", | |||||
| report.jobOrdersProcessed, | |||||
| report.jobOrdersFound, | |||||
| report.planStart, | |||||
| ) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("Laser Bag2 scheduler failed", e) | logger.error("Laser Bag2 scheduler failed", e) | ||||
| } | } | ||||
| @@ -51,6 +51,7 @@ import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus | |||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.MaterialPickStatusItem | import com.ffii.fpsms.modules.jobOrder.web.model.MaterialPickStatusItem | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PlasticBoxCartonQtyDashboardRecord | |||||
| @Service | @Service | ||||
| open class JoPickOrderService( | open class JoPickOrderService( | ||||
| private val joPickOrderRepository: JoPickOrderRepository, | private val joPickOrderRepository: JoPickOrderRepository, | ||||
| @@ -1688,6 +1689,9 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| "secondScanCompleted" to secondScanCompleted, | "secondScanCompleted" to secondScanCompleted, | ||||
| "totalItems" to joPickOrders.size, | "totalItems" to joPickOrders.size, | ||||
| "completedItems" to joPickOrders.count { it.matchStatus == JoPickOrderStatus.completed }, | "completedItems" to joPickOrders.count { it.matchStatus == JoPickOrderStatus.completed }, | ||||
| "plasticBoxCartonQty2f" to pickOrder.plasticBoxCartonQty2f, | |||||
| "plasticBoxCartonQty3f" to pickOrder.plasticBoxCartonQty3f, | |||||
| "plasticBoxCartonQty4f" to pickOrder.plasticBoxCartonQty4f, | |||||
| ) | ) | ||||
| } else { | } else { | ||||
| println("❌ Pick order ${pickOrder.id} has no job order, skipping.") | println("❌ Pick order ${pickOrder.id} has no job order, skipping.") | ||||
| @@ -1703,6 +1707,32 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||||
| emptyList() | emptyList() | ||||
| } | } | ||||
| } | } | ||||
| open fun getPlasticBoxCartonQtyDashboard( | |||||
| from: LocalDate, | |||||
| to: LocalDate, | |||||
| ): List<PlasticBoxCartonQtyDashboardRecord> { | |||||
| val fromDt = from.atStartOfDay() | |||||
| val toExclusive = to.plusDays(1).atStartOfDay() | |||||
| return pickOrderRepository | |||||
| .findCompletedWithPlasticBoxCartonQtyInPlanStartRange( | |||||
| PickOrderStatus.COMPLETED, | |||||
| fromDt, | |||||
| toExclusive, | |||||
| ) | |||||
| .mapNotNull { pickOrder -> | |||||
| val planStart = pickOrder.jobOrder?.planStart ?: return@mapNotNull null | |||||
| val statLocalDate = planStart.toLocalDate() | |||||
| PlasticBoxCartonQtyDashboardRecord( | |||||
| pickOrderId = pickOrder.id, | |||||
| statDate = "${statLocalDate.year}-${"%02d".format(statLocalDate.monthValue)}-${"%02d".format(statLocalDate.dayOfMonth)}", | |||||
| plasticBoxCartonQty2f = pickOrder.plasticBoxCartonQty2f, | |||||
| plasticBoxCartonQty3f = pickOrder.plasticBoxCartonQty3f, | |||||
| plasticBoxCartonQty4f = pickOrder.plasticBoxCartonQty4f, | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun getJobOrderPickOrders(date: LocalDate?, status: PickOrderStatus?): List<Map<String, Any?>> { | open fun getJobOrderPickOrders(date: LocalDate?, status: PickOrderStatus?): List<Map<String, Any?>> { | ||||
| println("=== getJobOrderPickOrders ===") | println("=== getJobOrderPickOrders ===") | ||||
| println("date: $date, status: $status") | println("date: $date, status: $status") | ||||
| @@ -3,11 +3,12 @@ package com.ffii.fpsms.modules.jobOrder.service | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderLotsHierarchicalWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse | import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse | import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsWorkbenchResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse | import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | import com.ffii.fpsms.modules.master.web.models.MessageResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | ||||
| @@ -53,6 +54,30 @@ open class JoWorkbenchMainService( | |||||
| private val workbenchDefaultExcludeWarehouseCodes: Set<String> = setOf( | private val workbenchDefaultExcludeWarehouseCodes: Set<String> = setOf( | ||||
| //"2F-W202-01-00", | //"2F-W202-01-00", | ||||
| //"2F-W200-#A-00", | //"2F-W200-#A-00", | ||||
| "4F-W402-01-00", | |||||
| "4F-W402-02-00", | |||||
| "4F-W402-03-00", | |||||
| "4F-W402-04-00", | |||||
| "4F-W402-05-00", | |||||
| "4F-W402-#A-00", | |||||
| "4F-W402-#B-00", | |||||
| "4F-W402-#C-00", | |||||
| "4F-W402-#D-00", | |||||
| "4F-W402-#E-00", | |||||
| "4F-W402-#F-00", | |||||
| "4F-W402-#G-00", | |||||
| "4F-W402-#H-00", | |||||
| "4F-W402-#I-00", | |||||
| "4F-W402-#J-00", | |||||
| "4F-W402-#K-00", | |||||
| "4F-W402-#L-00", | |||||
| "4F-W402-#M-00", | |||||
| "4F-W402-#N-00", | |||||
| "4F-W402-#O-00", | |||||
| "4F-W402-#P-00", | |||||
| "4F-W402-#Q-00", | |||||
| "4F-W402-#R-00", | |||||
| "4F-W402-#S-00" | |||||
| ) | ) | ||||
| private fun debugPrintSuggestionNullReasons(pickOrderId: Long) { | private fun debugPrintSuggestionNullReasons(pickOrderId: Long) { | ||||
| @@ -194,7 +219,7 @@ open class JoWorkbenchMainService( | |||||
| /** | /** | ||||
| * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | ||||
| */ | */ | ||||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | ||||
| println("pickOrderId: $pickOrderId") | println("pickOrderId: $pickOrderId") | ||||
| @@ -299,8 +324,8 @@ open class JoWorkbenchMainService( | |||||
| } | } | ||||
| val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) | val joPickOrders = joPickOrderRepository.findByPickOrderId(pickOrder.id!!) | ||||
| val pickOrderInfo = PickOrderInfoResponse( | |||||
| val pickOrderInfo = PickOrderInfoWorkbenchResponse( | |||||
| id = pickOrder.id, | id = pickOrder.id, | ||||
| code = pickOrder.code, | code = pickOrder.code, | ||||
| consoCode = pickOrder.consoCode, | consoCode = pickOrder.consoCode, | ||||
| @@ -310,10 +335,12 @@ open class JoWorkbenchMainService( | |||||
| type = pickOrder.type?.value, | type = pickOrder.type?.value, | ||||
| status = pickOrder.status?.value, | status = pickOrder.status?.value, | ||||
| assignTo = pickOrder.assignTo?.id, | assignTo = pickOrder.assignTo?.id, | ||||
| jobOrder = JobOrderBasicInfoResponse( | |||||
| jobOrder = JobOrderBasicInfoWorkbenchResponse( | |||||
| id = jobOrder.id!!, | id = jobOrder.id!!, | ||||
| code = jobOrder.code ?: "", | code = jobOrder.code ?: "", | ||||
| name = "Job Order ${jobOrder.code}" | |||||
| name = "Job Order ${jobOrder.code}", | |||||
| itemCode = jobOrder.bom?.code, | |||||
| itemName = jobOrder.bom?.name, | |||||
| ) | ) | ||||
| ) | ) | ||||
| @@ -342,7 +369,7 @@ open class JoWorkbenchMainService( | |||||
| val handlerNameInner = jpoInner?.handledBy?.let { uid -> | val handlerNameInner = jpoInner?.handledBy?.let { uid -> | ||||
| userService.find(uid).orElse(null)?.name | userService.find(uid).orElse(null)?.name | ||||
| } | } | ||||
| println("handlerName: $handlerNameInner") | |||||
| //println("handlerName: $handlerNameInner") | |||||
| val availableQty = if (sol?.status == "rejected") { | val availableQty = if (sol?.status == "rejected") { | ||||
| null | null | ||||
| } else { | } else { | ||||
| @@ -429,7 +456,7 @@ open class JoWorkbenchMainService( | |||||
| ) | ) | ||||
| } | } | ||||
| PickOrderLineWithLotsResponse( | |||||
| PickOrderLineWithLotsWorkbenchResponse( | |||||
| id = pol.id!!, | id = pol.id!!, | ||||
| itemId = item?.id, | itemId = item?.id, | ||||
| itemCode = item?.code, | itemCode = item?.code, | ||||
| @@ -445,7 +472,7 @@ open class JoWorkbenchMainService( | |||||
| ) | ) | ||||
| } | } | ||||
| JobOrderLotsHierarchicalResponse( | |||||
| JobOrderLotsHierarchicalWorkbenchResponse( | |||||
| pickOrder = pickOrderInfo, | pickOrder = pickOrderInfo, | ||||
| pickOrderLines = pickOrderLinesResult | pickOrderLines = pickOrderLinesResult | ||||
| ) | ) | ||||
| @@ -456,10 +483,10 @@ open class JoWorkbenchMainService( | |||||
| } | } | ||||
| } | } | ||||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalResponse { | |||||
| private fun emptyHierarchical(message: String): JobOrderLotsHierarchicalWorkbenchResponse { | |||||
| println("❌ $message") | println("❌ $message") | ||||
| return JobOrderLotsHierarchicalResponse( | |||||
| pickOrder = PickOrderInfoResponse( | |||||
| return JobOrderLotsHierarchicalWorkbenchResponse( | |||||
| pickOrder = PickOrderInfoWorkbenchResponse( | |||||
| id = null, | id = null, | ||||
| code = null, | code = null, | ||||
| consoCode = null, | consoCode = null, | ||||
| @@ -467,7 +494,7 @@ open class JoWorkbenchMainService( | |||||
| type = null, | type = null, | ||||
| status = null, | status = null, | ||||
| assignTo = null, | assignTo = null, | ||||
| jobOrder = JobOrderBasicInfoResponse(0, "", "") | |||||
| jobOrder = JobOrderBasicInfoWorkbenchResponse(0, "", "",null,null) | |||||
| ), | ), | ||||
| pickOrderLines = emptyList() | pickOrderLines = emptyList() | ||||
| ) | ) | ||||
| @@ -0,0 +1,243 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.service | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrder | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrder | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcess | |||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | |||||
| import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus | |||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.support.TransactionTemplate | |||||
| import java.time.LocalDateTime | |||||
| import java.util.concurrent.atomic.AtomicBoolean | |||||
| /** | |||||
| * Daily batch after plan day ends: at run time (default 00:00:15), process job orders whose | |||||
| * [JobOrder.planStart] fell on the previous calendar day. | |||||
| * | |||||
| * - Branch A: no pick submitted, product process pending → hide job order. | |||||
| * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. | |||||
| */ | |||||
| @Service | |||||
| open class JobOrderPlanStartAutoService( | |||||
| private val jobOrderRepository: JobOrderRepository, | |||||
| private val pickOrderRepository: PickOrderRepository, | |||||
| private val productProcessRepository: ProductProcessRepository, | |||||
| private val stockInLineRepository: StockInLineRepository, | |||||
| private val jobOrderService: JobOrderService, | |||||
| private val stockInLineService: StockInLineService, | |||||
| private val transactionTemplate: TransactionTemplate, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(javaClass) | |||||
| private val inFlight = AtomicBoolean(false) | |||||
| data class JobOrderPlanStartAutoReport( | |||||
| val runAt: LocalDateTime, | |||||
| val targetPlanDayFrom: LocalDateTime, | |||||
| val targetPlanDayToExclusive: LocalDateTime, | |||||
| val candidates: Int = 0, | |||||
| val hidden: Int = 0, | |||||
| val rescheduled: Int = 0, | |||||
| val skipped: Int = 0, | |||||
| val errors: Int = 0, | |||||
| ) | |||||
| open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { | |||||
| if (!inFlight.compareAndSet(false, true)) { | |||||
| logger.warn("Job order plan-start auto process skipped: previous run still in flight") | |||||
| val targetDay = runAt.toLocalDate().minusDays(1) | |||||
| return JobOrderPlanStartAutoReport( | |||||
| runAt = runAt, | |||||
| targetPlanDayFrom = targetDay.atStartOfDay(), | |||||
| targetPlanDayToExclusive = targetDay.plusDays(1).atStartOfDay(), | |||||
| ) | |||||
| } | |||||
| try { | |||||
| return runAutoProcessInternal(runAt) | |||||
| } finally { | |||||
| inFlight.set(false) | |||||
| } | |||||
| } | |||||
| private fun runAutoProcessInternal(runAt: LocalDateTime): JobOrderPlanStartAutoReport { | |||||
| val targetDay = runAt.toLocalDate().minusDays(1) | |||||
| val from = targetDay.atStartOfDay() | |||||
| val toExclusive = targetDay.plusDays(1).atStartOfDay() | |||||
| val newPlanStart = runAt.toLocalDate().atStartOfDay() | |||||
| var hidden = 0 | |||||
| var rescheduled = 0 | |||||
| var skipped = 0 | |||||
| var errors = 0 | |||||
| val jobOrders = jobOrderRepository | |||||
| .findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(from, toExclusive) | |||||
| .filter { isEligibleCandidate(it) } | |||||
| val joIds = jobOrders.mapNotNull { it.id } | |||||
| val pickOrdersByJoId = loadPickOrdersByJobOrderId(joIds) | |||||
| val productProcessesByJoId = loadProductProcessesByJobOrderId(joIds) | |||||
| logger.info( | |||||
| "Job order plan-start auto: runAt={}, targetPlanDay=[{}, {}), candidates={}", | |||||
| runAt, | |||||
| from, | |||||
| toExclusive, | |||||
| jobOrders.size, | |||||
| ) | |||||
| for (jo in jobOrders) { | |||||
| val joId = jo.id ?: continue | |||||
| try { | |||||
| when ( | |||||
| classify( | |||||
| jo, | |||||
| pickOrdersByJoId[joId].orEmpty(), | |||||
| productProcessesByJoId[joId], | |||||
| ) | |||||
| ) { | |||||
| Branch.HIDE -> { | |||||
| transactionTemplate.executeWithoutResult { | |||||
| applyHide(jo, runAt) | |||||
| } | |||||
| hidden++ | |||||
| } | |||||
| Branch.RESCHEDULE -> { | |||||
| transactionTemplate.executeWithoutResult { | |||||
| applyReschedule(jo, productProcessesByJoId[joId], newPlanStart, runAt) | |||||
| } | |||||
| rescheduled++ | |||||
| } | |||||
| Branch.SKIP -> skipped++ | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| errors++ | |||||
| logger.error("Job order plan-start auto failed for joId={} code={}: {}", joId, jo.code, e.message, e) | |||||
| } | |||||
| } | |||||
| val report = JobOrderPlanStartAutoReport( | |||||
| runAt = runAt, | |||||
| targetPlanDayFrom = from, | |||||
| targetPlanDayToExclusive = toExclusive, | |||||
| candidates = jobOrders.size, | |||||
| hidden = hidden, | |||||
| rescheduled = rescheduled, | |||||
| skipped = skipped, | |||||
| errors = errors, | |||||
| ) | |||||
| logger.info("Job order plan-start auto finished: {}", report) | |||||
| return report | |||||
| } | |||||
| private fun isEligibleCandidate(jo: JobOrder): Boolean { | |||||
| if (jo.isHidden == true) return false | |||||
| if (jo.status == JobOrderStatus.COMPLETED) return false | |||||
| return true | |||||
| } | |||||
| private enum class Branch { | |||||
| HIDE, | |||||
| RESCHEDULE, | |||||
| SKIP, | |||||
| } | |||||
| private fun classify( | |||||
| jo: JobOrder, | |||||
| pickOrders: List<PickOrder>, | |||||
| productProcess: ProductProcess?, | |||||
| ): Branch { | |||||
| if (!isProductProcessPendingNotStarted(productProcess)) { | |||||
| return Branch.SKIP | |||||
| } | |||||
| val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 | |||||
| return when { | |||||
| maxSubmittedLines == 0 -> Branch.HIDE | |||||
| maxSubmittedLines > 0 -> Branch.RESCHEDULE | |||||
| else -> Branch.SKIP | |||||
| } | |||||
| } | |||||
| private fun isProductProcessPendingNotStarted(productProcess: ProductProcess?): Boolean { | |||||
| if (productProcess == null) return false | |||||
| if (productProcess.deleted) return false | |||||
| if (productProcess.status != ProductProcessStatus.PENDING) return false | |||||
| if (productProcess.startTime != null) return false | |||||
| return true | |||||
| } | |||||
| private fun loadPickOrdersByJobOrderId(jobOrderIds: List<Long>): Map<Long, List<PickOrder>> { | |||||
| if (jobOrderIds.isEmpty()) return emptyMap() | |||||
| return pickOrderRepository | |||||
| .findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds) | |||||
| .groupBy { it.jobOrder?.id ?: -1L } | |||||
| .filterKeys { it > 0L } | |||||
| } | |||||
| private fun loadProductProcessesByJobOrderId(jobOrderIds: List<Long>): Map<Long, ProductProcess> { | |||||
| if (jobOrderIds.isEmpty()) return emptyMap() | |||||
| return productProcessRepository | |||||
| .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) | |||||
| .mapNotNull { pp -> pp.jobOrder?.id?.let { it to pp } } | |||||
| .groupBy { it.first } | |||||
| .mapValues { (_, entries) -> | |||||
| entries.map { it.second }.firstOrNull { isProductProcessPendingNotStarted(it) } | |||||
| ?: entries.map { it.second }.first() | |||||
| } | |||||
| .filterValues { it != null } | |||||
| .mapValues { it.value!! } | |||||
| } | |||||
| private fun applyHide(jo: JobOrder, runAt: LocalDateTime) { | |||||
| jo.isHidden = true | |||||
| appendRemarks(jo, "[auto ${runAt.toLocalDate()}] hidden: overdue plan day, no pick submitted, process pending") | |||||
| jobOrderRepository.save(jo) | |||||
| logger.info("Job order plan-start auto hid joId={} code={}", jo.id, jo.code) | |||||
| } | |||||
| private fun applyReschedule( | |||||
| jo: JobOrder, | |||||
| productProcess: ProductProcess?, | |||||
| newPlanStart: LocalDateTime, | |||||
| runAt: LocalDateTime, | |||||
| ) { | |||||
| val pp = productProcess?.takeIf { isProductProcessPendingNotStarted(it) } | |||||
| ?: throw IllegalStateException("Product process not pending for reschedule, joId=${jo.id}") | |||||
| val newCode = jobOrderService.assignJobNo(newPlanStart) | |||||
| jo.planStart = newPlanStart | |||||
| jo.code = newCode | |||||
| appendRemarks( | |||||
| jo, | |||||
| "[auto ${runAt.toLocalDate()}] rescheduled from overdue plan day; pick started, process pending", | |||||
| ) | |||||
| jobOrderRepository.save(jo) | |||||
| pp.date = newPlanStart.toLocalDate() | |||||
| productProcessRepository.save(pp) | |||||
| val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||||
| if (sil != null) { | |||||
| sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) | |||||
| sil.productLotNo = newCode | |||||
| stockInLineRepository.save(sil) | |||||
| } | |||||
| logger.info( | |||||
| "Job order plan-start auto rescheduled joId={} newCode={} newPlanStart={}", | |||||
| jo.id, | |||||
| newCode, | |||||
| newPlanStart, | |||||
| ) | |||||
| } | |||||
| private fun appendRemarks(jo: JobOrder, snippet: String) { | |||||
| val existing = jo.remarks?.trim().orEmpty() | |||||
| jo.remarks = if (existing.isEmpty()) snippet else "$existing | $snippet" | |||||
| } | |||||
| } | |||||
| @@ -737,6 +737,92 @@ open class JobOrderService( | |||||
| } | } | ||||
| //Pick Record | //Pick Record | ||||
| private fun validatePickRecordFloor(floor: String?): String { | |||||
| val normalizedFloor = floor?.trim()?.uppercase() | |||||
| ?: throw BadRequestException("floor is required for pick record print") | |||||
| if (normalizedFloor !in setOf("2F", "3F", "4F", "ALL")) { | |||||
| throw BadRequestException("floor must be one of 2F, 3F, 4F, ALL") | |||||
| } | |||||
| return normalizedFloor | |||||
| } | |||||
| private fun validatePlasticBoxCartonQty(qty: Int?): Int { | |||||
| val value = qty ?: throw BadRequestException("plasticBoxCartonQty is required") | |||||
| if (value < 1) { | |||||
| throw BadRequestException("plasticBoxCartonQty must be at least 1") | |||||
| } | |||||
| return value | |||||
| } | |||||
| private data class AllFloorsPlasticBoxCartonQty( | |||||
| val qty2f: Int, | |||||
| val qty3f: Int, | |||||
| val qty4f: Int, | |||||
| val sum: Int, | |||||
| ) | |||||
| private fun normalizeFloorPlasticBoxCartonQty(qty: Int?): Int { | |||||
| if (qty == null) return 0 | |||||
| if (qty < 0) { | |||||
| throw BadRequestException("plastic box carton qty cannot be negative") | |||||
| } | |||||
| return qty | |||||
| } | |||||
| private fun resolveAllFloorsPlasticBoxCartonQty( | |||||
| qty2f: Int?, | |||||
| qty3f: Int?, | |||||
| qty4f: Int?, | |||||
| ): AllFloorsPlasticBoxCartonQty { | |||||
| val q2 = normalizeFloorPlasticBoxCartonQty(qty2f) | |||||
| val q3 = normalizeFloorPlasticBoxCartonQty(qty3f) | |||||
| val q4 = normalizeFloorPlasticBoxCartonQty(qty4f) | |||||
| return AllFloorsPlasticBoxCartonQty(q2, q3, q4, q2 + q3 + q4) | |||||
| } | |||||
| private fun updatePickOrderPlasticBoxCartonQty(pickOrderId: Long, floor: String, qty: Int) { | |||||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return | |||||
| when (floor) { | |||||
| "2F" -> pickOrder.plasticBoxCartonQty2f = qty | |||||
| "3F" -> pickOrder.plasticBoxCartonQty3f = qty | |||||
| "4F" -> pickOrder.plasticBoxCartonQty4f = qty | |||||
| } | |||||
| pickOrderRepository.save(pickOrder) | |||||
| } | |||||
| private fun persistAllFloorsPlasticBoxCartonQty(pickOrderId: Long, all: AllFloorsPlasticBoxCartonQty) { | |||||
| updatePickOrderPlasticBoxCartonQty(pickOrderId, "2F", all.qty2f) | |||||
| updatePickOrderPlasticBoxCartonQty(pickOrderId, "3F", all.qty3f) | |||||
| updatePickOrderPlasticBoxCartonQty(pickOrderId, "4F", all.qty4f) | |||||
| } | |||||
| open fun getPickRecordPlasticBoxCartonQty(pickOrderId: Long): PickRecordPlasticBoxCartonQtyResponse { | |||||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElseThrow { | |||||
| NoSuchElementException("Pick order not found with ID: $pickOrderId") | |||||
| } | |||||
| return PickRecordPlasticBoxCartonQtyResponse( | |||||
| plasticBoxCartonQty2f = pickOrder.plasticBoxCartonQty2f, | |||||
| plasticBoxCartonQty3f = pickOrder.plasticBoxCartonQty3f, | |||||
| plasticBoxCartonQty4f = pickOrder.plasticBoxCartonQty4f, | |||||
| ) | |||||
| } | |||||
| private fun resolvePlasticBoxCartonQtyForPickRecord(request: ExportPickRecordRequest): Int { | |||||
| val floor = validatePickRecordFloor(request.floor) | |||||
| return if (floor == "ALL") { | |||||
| val all = resolveAllFloorsPlasticBoxCartonQty( | |||||
| request.plasticBoxCartonQty2f, | |||||
| request.plasticBoxCartonQty3f, | |||||
| request.plasticBoxCartonQty4f, | |||||
| ) | |||||
| persistAllFloorsPlasticBoxCartonQty(request.pickOrderIds, all) | |||||
| all.sum | |||||
| } else { | |||||
| request.plasticBoxCartonQty | |||||
| ?: throw BadRequestException("plasticBoxCartonQty is required") | |||||
| } | |||||
| } | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun exportPickRecord(request: ExportPickRecordRequest): Map<String, Any> { | open fun exportPickRecord(request: ExportPickRecordRequest): Map<String, Any> { | ||||
| @@ -821,6 +907,8 @@ open class JobOrderService( | |||||
| println("unit (from BOM): $unit")*/ | println("unit (from BOM): $unit")*/ | ||||
| params["unit"] = pickRecordInfo.firstOrNull()?.get("uomConversionDesc") as? String ?: "N/A" | params["unit"] = pickRecordInfo.firstOrNull()?.get("uomConversionDesc") as? String ?: "N/A" | ||||
| val plasticBoxCartonQtyForPdf = resolvePlasticBoxCartonQtyForPickRecord(request) | |||||
| params["PlasticBoxCartonQty"] = plasticBoxCartonQtyForPdf.toString() | |||||
| val pickOrderCode = pickRecordInfo.firstOrNull()?.get("pickOrderCode") as? String ?: "unknown" | val pickOrderCode = pickRecordInfo.firstOrNull()?.get("pickOrderCode") as? String ?: "unknown" | ||||
| return mapOf( | return mapOf( | ||||
| @@ -833,13 +921,26 @@ open class JobOrderService( | |||||
| @Transactional | @Transactional | ||||
| open fun printPickRecord(request: PrintPickRecordRequest){ | open fun printPickRecord(request: PrintPickRecordRequest){ | ||||
| val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | ||||
| val pdf = exportPickRecord( | |||||
| val floor = validatePickRecordFloor(request.floor) | |||||
| val exportRequest = if (floor == "ALL") { | |||||
| ExportPickRecordRequest( | ExportPickRecordRequest( | ||||
| pickOrderIds = request.pickOrderId, | pickOrderIds = request.pickOrderId, | ||||
| floor = request.floor, | floor = request.floor, | ||||
| plasticBoxCartonQty2f = request.plasticBoxCartonQty2f, | |||||
| plasticBoxCartonQty3f = request.plasticBoxCartonQty3f, | |||||
| plasticBoxCartonQty4f = request.plasticBoxCartonQty4f, | |||||
| ) | ) | ||||
| ) | |||||
| } else { | |||||
| val plasticBoxCartonQty = validatePlasticBoxCartonQty(request.plasticBoxCartonQty) | |||||
| updatePickOrderPlasticBoxCartonQty(request.pickOrderId, floor, plasticBoxCartonQty) | |||||
| ExportPickRecordRequest( | |||||
| pickOrderIds = request.pickOrderId, | |||||
| floor = request.floor, | |||||
| plasticBoxCartonQty = plasticBoxCartonQty, | |||||
| ) | |||||
| } | |||||
| val pdf = exportPickRecord(exportRequest) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | val jasperPrint = pdf["report"] as JasperPrint | ||||
| @@ -35,9 +35,8 @@ class LaserBag2AutoSendService( | |||||
| sendsPerJob: Int = defaultSendsPerJob, | sendsPerJob: Int = defaultSendsPerJob, | ||||
| delayBetweenSendsMs: Long = defaultDelayBetweenSendsMs, | delayBetweenSendsMs: Long = defaultDelayBetweenSendsMs, | ||||
| ): LaserBag2AutoSendReport { | ): LaserBag2AutoSendReport { | ||||
| val (reachable, laserIp, laserPort) = plasticBagPrinterService.probeLaserBag2Tcp() | |||||
| val (reachable, _, _) = plasticBagPrinterService.probeLaserBag2Tcp() | |||||
| if (!reachable) { | if (!reachable) { | ||||
| logger.warn("Connection failed to the laser print: {} / {}", laserIp, laserPort) | |||||
| return LaserBag2AutoSendReport( | return LaserBag2AutoSendReport( | ||||
| planStart = planStart, | planStart = planStart, | ||||
| jobOrdersFound = 0, | jobOrdersFound = 0, | ||||
| @@ -7,6 +7,13 @@ import org.springframework.stereotype.Service | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import org.apache.poi.ss.usermodel.FillPatternType | |||||
| import org.apache.poi.ss.usermodel.IndexedColors | |||||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| import java.io.ByteArrayOutputStream | |||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| @Service | @Service | ||||
| open class PSService( | open class PSService( | ||||
| @@ -160,6 +167,49 @@ open class PSService( | |||||
| } | } | ||||
| /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ | /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ | ||||
| /** | |||||
| * Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items. | |||||
| * Same aggregation as pick-issue manual inventory sync. | |||||
| */ | |||||
| fun refreshInventoryOnHandForFgBomItems(): Int { | |||||
| val sql = """ | |||||
| UPDATE inventory i | |||||
| INNER JOIN ( | |||||
| SELECT DISTINCT b.itemId | |||||
| FROM bom b | |||||
| WHERE b.deleted = 0 AND b.description = 'FG' | |||||
| ) bom_items ON bom_items.itemId = i.itemId | |||||
| LEFT JOIN ( | |||||
| SELECT | |||||
| il.itemId, | |||||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, | |||||
| SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty, | |||||
| SUM(CASE | |||||
| WHEN ill.status = 'unavailable' | |||||
| THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) | |||||
| ELSE 0 | |||||
| END) AS totalUnavailableQty | |||||
| FROM inventory_lot_line ill | |||||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||||
| WHERE ill.deleted = 0 | |||||
| GROUP BY il.itemId | |||||
| ) calc ON calc.itemId = i.itemId | |||||
| SET | |||||
| i.onHandQty = COALESCE(calc.totalOnHandQty, 0), | |||||
| i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0), | |||||
| i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), | |||||
| i.status = IF( | |||||
| COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, | |||||
| 'available', | |||||
| 'unavailable' | |||||
| ), | |||||
| i.modified = NOW(), | |||||
| i.modifiedBy = 'ps-refresh-onhand' | |||||
| WHERE i.deleted = 0 | |||||
| """.trimIndent() | |||||
| return jdbcDao.executeUpdate(sql, emptyMap<String, Any>()) | |||||
| } | |||||
| fun setCoffeeOrTea(itemCode: String, systemType: String, enabled: Boolean) { | fun setCoffeeOrTea(itemCode: String, systemType: String, enabled: Boolean) { | ||||
| val args = mapOf("itemCode" to itemCode, "systemType" to systemType) | val args = mapOf("itemCode" to itemCode, "systemType" to systemType) | ||||
| jdbcDao.executeUpdate( | jdbcDao.executeUpdate( | ||||
| @@ -214,4 +264,150 @@ open class PSService( | |||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| /** | |||||
| * Items with at least one BOM (deleted = 0), plus stock-unit label. | |||||
| */ | |||||
| fun listBomItemsWithStockUnit(): List<Map<String, Any>> { | |||||
| val sql = """ | |||||
| SELECT DISTINCT | |||||
| items.code AS itemCode, | |||||
| items.name AS itemName, | |||||
| uc_stock.udfudesc AS stockUnit | |||||
| FROM bom | |||||
| INNER JOIN items ON bom.itemId = items.id AND items.deleted = 0 | |||||
| LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 | |||||
| LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId | |||||
| WHERE bom.deleted = 0 | |||||
| ORDER BY items.code | |||||
| """.trimIndent() | |||||
| return jdbcDao.queryForList(sql, emptyMap<String, Any>()) | |||||
| } | |||||
| /** | |||||
| * Sum of [delivery_order_line.qty] (already stored in stock unit) by item and ETA date. | |||||
| * Only items that have a BOM. | |||||
| */ | |||||
| fun sumDeliveryOrderQtyByItemAndDate(fromDate: LocalDate, toDate: LocalDate): List<Map<String, Any>> { | |||||
| val args = mapOf( | |||||
| "fromDate" to fromDate.toString(), | |||||
| "toDate" to toDate.toString(), | |||||
| ) | |||||
| val sql = """ | |||||
| SELECT | |||||
| items.code AS itemCode, | |||||
| DATE(do.estimatedArrivalDate) AS shipDate, | |||||
| SUM(COALESCE(dol.qty, 0)) AS qtySum | |||||
| FROM delivery_order do | |||||
| INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||||
| INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0 | |||||
| WHERE do.deleted = 0 | |||||
| AND do.estimatedArrivalDate IS NOT NULL | |||||
| AND DATE(do.estimatedArrivalDate) >= :fromDate | |||||
| AND DATE(do.estimatedArrivalDate) <= :toDate | |||||
| AND EXISTS ( | |||||
| SELECT 1 FROM bom b | |||||
| WHERE b.itemId = items.id AND b.deleted = 0 | |||||
| ) | |||||
| GROUP BY items.code, DATE(do.estimatedArrivalDate) | |||||
| ORDER BY items.code, shipDate | |||||
| """.trimIndent() | |||||
| return jdbcDao.queryForList(sql, args) | |||||
| } | |||||
| fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray { | |||||
| require(!fromDate.isAfter(toDate)) { "fromDate must be on or before toDate" } | |||||
| val dayCount = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1 | |||||
| require(dayCount in 1..366) { "Date range must be between 1 and 366 days" } | |||||
| val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList() | |||||
| val items = listBomItemsWithStockUnit() | |||||
| val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate) | |||||
| val qtyByItemDate = mutableMapOf<String, MutableMap<LocalDate, BigDecimal>>() | |||||
| qtyRows.forEach { row -> | |||||
| val itemCode = row["itemCode"]?.toString() ?: return@forEach | |||||
| val shipDate = parseSqlDate(row["shipDate"]) ?: return@forEach | |||||
| val qty = toBigDecimal(row["qtySum"]) | |||||
| qtyByItemDate.getOrPut(itemCode) { mutableMapOf() }[shipDate] = qty | |||||
| } | |||||
| val workbook = XSSFWorkbook() | |||||
| val sheet = workbook.createSheet("DO Qty by Date") | |||||
| val headerStyle = workbook.createCellStyle().apply { | |||||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| val font = workbook.createFont() | |||||
| font.bold = true | |||||
| setFont(font) | |||||
| } | |||||
| val textStyle = workbook.createCellStyle().apply { | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| } | |||||
| val numberStyle = workbook.createCellStyle().apply { | |||||
| verticalAlignment = VerticalAlignment.CENTER | |||||
| dataFormat = workbook.createDataFormat().getFormat("#,##0") | |||||
| } | |||||
| val headerRow = sheet.createRow(0) | |||||
| val headers = mutableListOf("Item Code", "Item Name", "UOM") | |||||
| headers.addAll(dates.map { it.toString() }) | |||||
| headers.forEachIndexed { col, title -> | |||||
| headerRow.createCell(col).apply { | |||||
| setCellValue(title) | |||||
| cellStyle = headerStyle | |||||
| } | |||||
| } | |||||
| items.forEachIndexed { index, item -> | |||||
| val row = sheet.createRow(index + 1) | |||||
| val itemCode = item["itemCode"]?.toString() ?: "" | |||||
| val itemName = item["itemName"]?.toString() ?: "" | |||||
| val stockUnit = item["stockUnit"]?.toString() ?: "" | |||||
| row.createCell(0).apply { setCellValue(itemCode); cellStyle = textStyle } | |||||
| row.createCell(1).apply { setCellValue(itemName); cellStyle = textStyle } | |||||
| row.createCell(2).apply { setCellValue(stockUnit); cellStyle = textStyle } | |||||
| val dateQtyMap = qtyByItemDate[itemCode] | |||||
| dates.forEachIndexed { dateIdx, date -> | |||||
| val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO) | |||||
| .setScale(0, RoundingMode.HALF_UP) | |||||
| row.createCell(3 + dateIdx).apply { | |||||
| setCellValue(qty.toLong().toDouble()) | |||||
| cellStyle = numberStyle | |||||
| } | |||||
| } | |||||
| } | |||||
| for (col in 0 until headers.size) { | |||||
| sheet.autoSizeColumn(col) | |||||
| } | |||||
| ByteArrayOutputStream().use { out -> | |||||
| workbook.write(out) | |||||
| workbook.close() | |||||
| return out.toByteArray() | |||||
| } | |||||
| } | |||||
| private fun parseSqlDate(value: Any?): LocalDate? = when (value) { | |||||
| null -> null | |||||
| is LocalDate -> value | |||||
| is java.sql.Date -> value.toLocalDate() | |||||
| is java.time.LocalDateTime -> value.toLocalDate() | |||||
| else -> { | |||||
| val text = value.toString().trim() | |||||
| if (text.length >= 10) LocalDate.parse(text.substring(0, 10)) else null | |||||
| } | |||||
| } | |||||
| private fun toBigDecimal(value: Any?): BigDecimal = when (value) { | |||||
| null -> BigDecimal.ZERO | |||||
| is BigDecimal -> value | |||||
| is Number -> BigDecimal.valueOf(value.toDouble()) | |||||
| else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO | |||||
| } | |||||
| } | } | ||||
| @@ -177,7 +177,7 @@ class PlasticBagPrinterService( | |||||
| val ids = filtered.mapNotNull { it.id } | val ids = filtered.mapNotNull { it.id } | ||||
| val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids) | ||||
| return filtered.map { jo -> | return filtered.map { jo -> | ||||
| PyJobOrderListMapper.toListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||||
| PyJobOrderListMapper.toLaserListItem(jo, printed[jo.id!!], stockInLineRepository, itemUomService) | |||||
| } | } | ||||
| } | } | ||||
| @@ -1367,15 +1367,18 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| val qrValue = zplEscape(qrPayload) | val qrValue = zplEscape(qrPayload) | ||||
| // Must match python Bag2.py generate_zpl_dataflex() | |||||
| // Must match python Bag3.py generate_zpl_dataflex() field layout / fonts. | |||||
| val fontRegular = "E:STXihei.ttf" | val fontRegular = "E:STXihei.ttf" | ||||
| val fontBold = "E:STXihei.ttf" | val fontBold = "E:STXihei.ttf" | ||||
| // Match python Bag3.py DataFlex defaults: narrower ^PW so job preview is not mostly empty on the right (^A@R fields are tall, not wide). | |||||
| val labelPw = 400 | |||||
| val labelLl = 500 | |||||
| return """ | return """ | ||||
| ^XA | ^XA | ||||
| ^CI28 | ^CI28 | ||||
| ^PW700 | |||||
| ^LL500 | |||||
| ^PW$labelPw | |||||
| ^LL$labelLl | |||||
| ^PO N | ^PO N | ||||
| ^FO10,20 | ^FO10,20 | ||||
| ^BQN,2,4^FDQA,$qrValue^FS | ^BQN,2,4^FDQA,$qrValue^FS | ||||
| @@ -27,7 +27,6 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||||
| import org.springframework.format.annotation.DateTimeFormat | import org.springframework.format.annotation.DateTimeFormat | ||||
| import com.ffii.fpsms.modules.productProcess.service.ProductProcessService | import com.ffii.fpsms.modules.productProcess.service.ProductProcessService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.* | import com.ffii.fpsms.modules.jobOrder.web.model.* | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportPickRecordRequest | |||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintPickRecordRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintPickRecordRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanSubmitRequest | import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanSubmitRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanIssueRequest | import com.ffii.fpsms.modules.jobOrder.web.model.SecondScanIssueRequest | ||||
| @@ -224,9 +223,19 @@ fun recordSecondScanIssue( | |||||
| return joPickOrderService.getCompletedJobOrderPickOrderLotDetails(pickOrderId) | return joPickOrderService.getCompletedJobOrderPickOrderLotDetails(pickOrderId) | ||||
| } | } | ||||
| @GetMapping("/pick-record-plastic-box-carton-qty/{pickOrderId}") | |||||
| fun getPickRecordPlasticBoxCartonQty(@PathVariable pickOrderId: Long): PickRecordPlasticBoxCartonQtyResponse { | |||||
| return jobOrderService.getPickRecordPlasticBoxCartonQty(pickOrderId) | |||||
| } | |||||
| @GetMapping("/print-PickRecord") | |||||
| fun printPickRecord(@ModelAttribute request: PrintPickRecordRequest){ | |||||
| jobOrderService.printPickRecord(request) | |||||
| } | |||||
| @PostMapping("/PickRecord") | @PostMapping("/PickRecord") | ||||
| @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | ||||
| fun printPickRecord(@Valid @RequestBody request: ExportPickRecordRequest, response: HttpServletResponse){ | |||||
| fun exportPickRecord(@Valid @RequestBody request: ExportPickRecordRequest, response: HttpServletResponse) { | |||||
| response.characterEncoding = "utf-8" | response.characterEncoding = "utf-8" | ||||
| response.contentType = "application/pdf" | response.contentType = "application/pdf" | ||||
| val out: OutputStream = response.outputStream | val out: OutputStream = response.outputStream | ||||
| @@ -236,11 +245,6 @@ fun recordSecondScanIssue( | |||||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | out.write(JasperExportManager.exportReportToPdf(jasperPrint)) | ||||
| } | } | ||||
| @GetMapping("/print-PickRecord") | |||||
| fun printPickRecord(@ModelAttribute request: PrintPickRecordRequest){ | |||||
| jobOrderService.printPickRecord(request) | |||||
| } | |||||
| @PostMapping("/FGStockInLabel") | @PostMapping("/FGStockInLabel") | ||||
| @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | ||||
| fun exportFGStockInLabel(@Valid @RequestBody request: ExportFGStockInLabelRequest, response: HttpServletResponse){ | fun exportFGStockInLabel(@Valid @RequestBody request: ExportFGStockInLabelRequest, response: HttpServletResponse){ | ||||
| @@ -272,6 +276,18 @@ fun recordSecondScanIssue( | |||||
| ): List<Map<String, Any?>> { | ): List<Map<String, Any?>> { | ||||
| return joPickOrderService.getCompletedJobOrderPickOrders(completedDate) | return joPickOrderService.getCompletedJobOrderPickOrders(completedDate) | ||||
| } | } | ||||
| @GetMapping("/plastic-box-carton-qty-dashboard") | |||||
| fun getPlasticBoxCartonQtyDashboard( | |||||
| @RequestParam(name = "from") | |||||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |||||
| from: LocalDate, | |||||
| @RequestParam(name = "to") | |||||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) | |||||
| to: LocalDate, | |||||
| ): List<PlasticBoxCartonQtyDashboardRecord> { | |||||
| return joPickOrderService.getPlasticBoxCartonQtyDashboard(from, to) | |||||
| } | |||||
| @GetMapping("/job-order-pick-orders") | @GetMapping("/job-order-pick-orders") | ||||
| fun getJobOrderPickOrders( | fun getJobOrderPickOrders( | ||||
| @RequestParam(name = "date", required = false) | @RequestParam(name = "date", required = false) | ||||
| @@ -332,7 +348,7 @@ fun getJobOrderPickOrderLotDetails( | |||||
| /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ | /** Workbench: available qty uses in−out (matches scan-pick); stockouts include suggested SPL qty when matched. */ | ||||
| @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | @GetMapping("/all-lots-hierarchical-by-pick-order-workbench/{pickOrderId}") | ||||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalResponse { | |||||
| fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(@PathVariable pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||||
| return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | return joWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId) | ||||
| } | } | ||||
| @@ -4,10 +4,11 @@ import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService | |||||
| import com.ffii.fpsms.modules.jobOrder.service.PSService | import com.ffii.fpsms.modules.jobOrder.service.PSService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | ||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import org.springframework.http.HttpHeaders | import org.springframework.http.HttpHeaders | ||||
| import org.springframework.http.MediaType | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeParseException | |||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| @RestController | @RestController | ||||
| @@ -79,6 +80,13 @@ class PSController( | |||||
| return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "onHandQty" to (onHandQty ?: "deleted"))) | return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "onHandQty" to (onHandQty ?: "deleted"))) | ||||
| } | } | ||||
| /** Recalculate inventory on-hand from lot lines for FG BOM items (排期設定 刷新庫存). */ | |||||
| @PostMapping("/refresh-inventory-onhand") | |||||
| fun refreshInventoryOnHand(): ResponseEntity<Map<String, Any>> { | |||||
| val updated = psService.refreshInventoryOnHandForFgBomItems() | |||||
| return ResponseEntity.ok(mapOf("ok" to true, "updated" to updated)) | |||||
| } | |||||
| /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ | /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ | ||||
| @PostMapping("/setCoffeeOrTea") | @PostMapping("/setCoffeeOrTea") | ||||
| fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | ||||
| @@ -91,4 +99,37 @@ class PSController( | |||||
| psService.setCoffeeOrTea(itemCode, systemType, enabled) | psService.setCoffeeOrTea(itemCode, systemType, enabled) | ||||
| return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "systemType" to systemType, "enabled" to enabled)) | return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "systemType" to systemType, "enabled" to enabled)) | ||||
| } | } | ||||
| /** | |||||
| * Export delivery-order qty sums (stock unit) for BOM items, pivoted by ETA date. | |||||
| */ | |||||
| @GetMapping( | |||||
| value = ["/export-do-qty-by-date"], | |||||
| produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], | |||||
| ) | |||||
| fun exportDoQtyByDate( | |||||
| @RequestParam fromDate: String, | |||||
| @RequestParam toDate: String, | |||||
| ): ResponseEntity<Any> { | |||||
| val from = try { | |||||
| LocalDate.parse(fromDate) | |||||
| } catch (_: DateTimeParseException) { | |||||
| return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDate")) | |||||
| } | |||||
| val to = try { | |||||
| LocalDate.parse(toDate) | |||||
| } catch (_: DateTimeParseException) { | |||||
| return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDate")) | |||||
| } | |||||
| return try { | |||||
| val bytes = psService.exportDeliveryOrderQtyByDateExcel(from, to) | |||||
| val filename = "do_qty_${from}_to_${to}.xlsx" | |||||
| ResponseEntity.ok() | |||||
| .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=$filename") | |||||
| .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) | |||||
| .body(bytes) | |||||
| } catch (e: IllegalArgumentException) { | |||||
| ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "Invalid date range"))) | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -90,9 +90,45 @@ data class PickOrderInfoResponse( | |||||
| data class JobOrderBasicInfoResponse( | data class JobOrderBasicInfoResponse( | ||||
| val id: Long, | val id: Long, | ||||
| val code: String, | val code: String, | ||||
| val name: String | |||||
| val name: String, | |||||
| ) | |||||
| data class JobOrderLotsHierarchicalWorkbenchResponse( | |||||
| val pickOrder: PickOrderInfoWorkbenchResponse, | |||||
| val pickOrderLines: List<PickOrderLineWithLotsWorkbenchResponse> | |||||
| ) | ) | ||||
| data class PickOrderInfoWorkbenchResponse( | |||||
| val id: Long?, | |||||
| val code: String?, | |||||
| val consoCode: String?, | |||||
| val targetDate: String?, | |||||
| val type: String?, | |||||
| val status: String?, | |||||
| val assignTo: Long?, | |||||
| val jobOrder: JobOrderBasicInfoWorkbenchResponse | |||||
| ) | |||||
| data class PickOrderLineWithLotsWorkbenchResponse( | |||||
| val id: Long, | |||||
| val itemId: Long?, | |||||
| val itemCode: String?, | |||||
| val itemName: String?, | |||||
| val requiredQty: Double?, | |||||
| // Total available qty across all inventory lot lines for this item (used by JO pick UI) | |||||
| val totalAvailableQty: Double? = null, | |||||
| val uomCode: String?, | |||||
| val uomDesc: String?, | |||||
| val status: String?, | |||||
| val lots: List<LotDetailResponse>, | |||||
| val stockouts: List<StockOutLineDetailResponse> = emptyList(), | |||||
| val handler: String? | |||||
| ) | |||||
| data class JobOrderBasicInfoWorkbenchResponse( | |||||
| val id: Long, | |||||
| val code: String, | |||||
| val name: String, | |||||
| val itemCode: String?, | |||||
| val itemName: String?, | |||||
| ) | |||||
| data class PickOrderLineWithLotsResponse( | data class PickOrderLineWithLotsResponse( | ||||
| val id: Long, | val id: Long, | ||||
| val itemId: Long?, | val itemId: Long?, | ||||
| @@ -3,4 +3,8 @@ package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| data class ExportPickRecordRequest ( | data class ExportPickRecordRequest ( | ||||
| val pickOrderIds: Long, | val pickOrderIds: Long, | ||||
| val floor: String? = null, | val floor: String? = null, | ||||
| val plasticBoxCartonQty: Int? = null, | |||||
| val plasticBoxCartonQty2f: Int? = null, | |||||
| val plasticBoxCartonQty3f: Int? = null, | |||||
| val plasticBoxCartonQty4f: Int? = null, | |||||
| ) | ) | ||||
| @@ -0,0 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| data class PickRecordPlasticBoxCartonQtyResponse( | |||||
| val plasticBoxCartonQty2f: Int?, | |||||
| val plasticBoxCartonQty3f: Int?, | |||||
| val plasticBoxCartonQty4f: Int?, | |||||
| ) | |||||
| @@ -0,0 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||||
| data class PlasticBoxCartonQtyDashboardRecord( | |||||
| val pickOrderId: Long?, | |||||
| val statDate: String, | |||||
| val plasticBoxCartonQty2f: Int? = null, | |||||
| val plasticBoxCartonQty3f: Int? = null, | |||||
| val plasticBoxCartonQty4f: Int? = null, | |||||
| ) | |||||
| @@ -5,4 +5,8 @@ data class PrintPickRecordRequest( | |||||
| val printerId: Long, | val printerId: Long, | ||||
| val printQty: Int?, | val printQty: Int?, | ||||
| val floor: String? = null, | val floor: String? = null, | ||||
| val plasticBoxCartonQty: Int? = null, | |||||
| val plasticBoxCartonQty2f: Int? = null, | |||||
| val plasticBoxCartonQty3f: Int? = null, | |||||
| val plasticBoxCartonQty4f: Int? = null, | |||||
| ) | ) | ||||
| @@ -0,0 +1,33 @@ | |||||
| package com.ffii.fpsms.modules.logistic.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| @Entity | |||||
| @Table(name = "logistic") | |||||
| open class Logistic : BaseEntity<Long>() { | |||||
| @field:NotNull | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "logisticName", nullable = false, length = 255) | |||||
| open var logisticName: String? = null | |||||
| @field:NotNull | |||||
| @field:Size(max = 50) | |||||
| @Column(name = "carPlate", nullable = false, length = 50) | |||||
| open var carPlate: String? = null | |||||
| @field:NotNull | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "driverName", nullable = false, length = 255) | |||||
| open var driverName: String? = null | |||||
| @field:NotNull | |||||
| @Column(name = "driverNumber", nullable = false) | |||||
| open var driverNumber: Int? = null | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.logistic.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.stereotype.Repository | |||||
| @Repository | |||||
| interface LogisticRepository : AbstractRepository<Logistic, Long> { | |||||
| fun findAllByDeletedFalseOrderByIdAsc(): List<Logistic> | |||||
| fun findByIdAndDeletedFalse(id: Long): Logistic? | |||||
| fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic? | |||||
| } | |||||
| @@ -0,0 +1,82 @@ | |||||
| package com.ffii.fpsms.modules.logistic.service | |||||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||||
| import jakarta.transaction.Transactional | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import org.springframework.http.HttpStatus | |||||
| @Service | |||||
| open class LogisticService( | |||||
| private val logisticRepository: LogisticRepository, | |||||
| ) { | |||||
| open fun findAll(): List<Logistic> { | |||||
| return logisticRepository.findAllByDeletedFalseOrderByIdAsc() | |||||
| } | |||||
| open fun findById(id: Long): Logistic? { | |||||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||||
| } | |||||
| open fun requireById(id: Long): Logistic { | |||||
| return logisticRepository.findByIdAndDeletedFalse(id) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id") | |||||
| } | |||||
| @Transactional | |||||
| open fun save(request: SaveLogisticRequest): Logistic { | |||||
| val entity = request.id?.let { requireById(it) } ?: Logistic() | |||||
| entity.apply { | |||||
| logisticName = request.logisticName.trim() | |||||
| carPlate = request.carPlate.trim() | |||||
| driverName = request.driverName.trim() | |||||
| driverNumber = request.driverNumber | |||||
| } | |||||
| return logisticRepository.save(entity) | |||||
| } | |||||
| /** | |||||
| * 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。 | |||||
| * 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。 | |||||
| */ | |||||
| @Transactional | |||||
| open fun saveBatchCreate(requests: List<SaveLogisticRequest>): List<Logistic> { | |||||
| if (requests.isEmpty()) return emptyList() | |||||
| if (requests.size > 100) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "Batch size exceeds limit (100)", | |||||
| ) | |||||
| } | |||||
| requests.forEach { r -> | |||||
| if (r.id != null) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "save-batch only accepts new rows (id must be null)", | |||||
| ) | |||||
| } | |||||
| } | |||||
| return requests.map { req -> | |||||
| val entity = Logistic().apply { | |||||
| logisticName = req.logisticName.trim() | |||||
| carPlate = req.carPlate.trim() | |||||
| driverName = req.driverName.trim() | |||||
| driverNumber = req.driverNumber | |||||
| } | |||||
| logisticRepository.save(entity) | |||||
| } | |||||
| } | |||||
| @Transactional | |||||
| open fun deleteById(id: Long): String { | |||||
| val entity = requireById(id) | |||||
| entity.deleted = true | |||||
| logisticRepository.save(entity) | |||||
| return "Logistic deleted successfully with id: $id" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,65 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web | |||||
| import com.ffii.fpsms.modules.logistic.service.LogisticService | |||||
| import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest | |||||
| import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse | |||||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest | |||||
| import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import jakarta.validation.Valid | |||||
| import org.springframework.http.ResponseEntity | |||||
| import org.springframework.web.bind.annotation.* | |||||
| @RestController | |||||
| @RequestMapping("/logistic") | |||||
| class LogisticController( | |||||
| private val logisticService: LogisticService, | |||||
| ) { | |||||
| @GetMapping("/all") | |||||
| fun findAll(): List<LogisticResponse> { | |||||
| return logisticService.findAll().map { it.toResponse() } | |||||
| } | |||||
| @GetMapping("/{id}") | |||||
| fun findById(@PathVariable id: Long): LogisticResponse { | |||||
| return logisticService.requireById(id).toResponse() | |||||
| } | |||||
| @PostMapping("/save") | |||||
| fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse { | |||||
| return logisticService.save(request).toResponse() | |||||
| } | |||||
| /** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */ | |||||
| @PostMapping("/save-batch") | |||||
| fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List<LogisticResponse> { | |||||
| return logisticService.saveBatchCreate(body.items).map { it.toResponse() } | |||||
| } | |||||
| @PostMapping("/delete") | |||||
| fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity<MessageResponse> { | |||||
| val result = logisticService.deleteById(request.id) | |||||
| return ResponseEntity.ok( | |||||
| MessageResponse( | |||||
| id = request.id, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "logistic", | |||||
| message = result, | |||||
| errorPosition = null, | |||||
| entity = null, | |||||
| ) | |||||
| ) | |||||
| } | |||||
| private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse { | |||||
| return LogisticResponse( | |||||
| id = this.id ?: 0L, | |||||
| logisticName = this.logisticName ?: "", | |||||
| carPlate = this.carPlate ?: "", | |||||
| driverName = this.driverName ?: "", | |||||
| driverNumber = this.driverNumber ?: 0, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web.models | |||||
| import jakarta.validation.constraints.NotNull | |||||
| data class DeleteLogisticRequest( | |||||
| @field:NotNull | |||||
| val id: Long, | |||||
| ) | |||||
| @@ -0,0 +1,10 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web.models | |||||
| data class LogisticResponse( | |||||
| val id: Long, | |||||
| val logisticName: String, | |||||
| val carPlate: String, | |||||
| val driverName: String, | |||||
| val driverNumber: Int, | |||||
| ) | |||||
| @@ -0,0 +1,21 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web.models | |||||
| import jakarta.validation.constraints.NotBlank | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| data class SaveLogisticRequest( | |||||
| val id: Long? = null, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 255) | |||||
| val logisticName: String, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 50) | |||||
| val carPlate: String, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 255) | |||||
| val driverName: String, | |||||
| @field:NotNull | |||||
| val driverNumber: Int, | |||||
| ) | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.logistic.web.models | |||||
| import jakarta.validation.Valid | |||||
| import jakarta.validation.constraints.NotEmpty | |||||
| import jakarta.validation.constraints.Size | |||||
| data class SaveLogisticsBatchRequest( | |||||
| @field:NotEmpty | |||||
| @field:Size(max = 100) | |||||
| @field:Valid | |||||
| val items: List<SaveLogisticRequest>, | |||||
| ) | |||||
| @@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.master.entity | |||||
| import com.fasterxml.jackson.annotation.JsonBackReference | import com.fasterxml.jackson.annotation.JsonBackReference | ||||
| import com.fasterxml.jackson.annotation.JsonManagedReference | import com.fasterxml.jackson.annotation.JsonManagedReference | ||||
| import com.ffii.core.entity.BaseEntity | import com.ffii.core.entity.BaseEntity | ||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import com.ffii.fpsms.modules.master.enums.BomStatusConverter | |||||
| import jakarta.persistence.* | import jakarta.persistence.* | ||||
| import jakarta.validation.constraints.NotNull | import jakarta.validation.constraints.NotNull | ||||
| import jakarta.validation.constraints.Size | import jakarta.validation.constraints.Size | ||||
| @@ -87,4 +89,8 @@ open class Bom : BaseEntity<Long>() { | |||||
| @Column(name = "baseScore", precision = 14, scale = 2) | @Column(name = "baseScore", precision = 14, scale = 2) | ||||
| open var baseScore: BigDecimal? = null | open var baseScore: BigDecimal? = null | ||||
| @Column(name = "status", nullable = false, length = 20) | |||||
| @Convert(converter = BomStatusConverter::class) | |||||
| open var status: BomStatus = BomStatus.ACTIVE | |||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.master.entity | package com.ffii.fpsms.modules.master.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| @@ -15,4 +16,17 @@ interface BomMaterialRepository : AbstractRepository<BomMaterial, Long> { | |||||
| fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List<BomMaterial> | fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List<BomMaterial> | ||||
| fun findByBomIdAndItemId(bomId: Long, itemId: Long): BomMaterial? | fun findByBomIdAndItemId(bomId: Long, itemId: Long): BomMaterial? | ||||
| /** Single round-trip for master-data scans (avoids per-bom N+1). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT bm FROM BomMaterial bm | |||||
| JOIN FETCH bm.bom b | |||||
| LEFT JOIN FETCH bm.item | |||||
| LEFT JOIN FETCH bm.uom | |||||
| LEFT JOIN FETCH bm.salesUnit | |||||
| WHERE bm.deleted = false AND b.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllActiveForActiveBoms(): List<BomMaterial> | |||||
| } | } | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.master.entity.projections.BomCombo | import com.ffii.fpsms.modules.master.entity.projections.BomCombo | ||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| @@ -10,6 +11,16 @@ import org.springframework.data.repository.query.Param | |||||
| interface BomRepository : AbstractRepository<Bom, Long> { | interface BomRepository : AbstractRepository<Bom, Long> { | ||||
| fun findAllByDeletedIsFalse(): List<Bom> | fun findAllByDeletedIsFalse(): List<Bom> | ||||
| @Query( | |||||
| """ | |||||
| SELECT b FROM Bom b | |||||
| LEFT JOIN FETCH b.item | |||||
| LEFT JOIN FETCH b.uom | |||||
| WHERE b.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllActiveWithItemAndUom(): List<Bom> | |||||
| fun findByIdAndDeletedIsFalse(id: Serializable): Bom? | fun findByIdAndDeletedIsFalse(id: Serializable): Bom? | ||||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? | fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? | ||||
| @@ -17,8 +28,17 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||||
| fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? | fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? | ||||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | fun findBomComboByDeletedIsFalse(): List<BomCombo> | ||||
| fun findBomComboByDeletedIsFalseAndStatus(status: BomStatus): List<BomCombo> | |||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | ||||
| fun findAllByItemIdAndStatusAndDeletedIsFalse(itemId: Long, status: BomStatus): List<Bom> | |||||
| @Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id") | |||||
| fun findAllIdsByDeletedIsFalse(): List<Long> | |||||
| fun findByCodeAndDeletedIsFalse(code: String): Bom? | fun findByCodeAndDeletedIsFalse(code: String): Bom? | ||||
| fun findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code: String, description: String): Bom? | |||||
| @Query(""" | @Query(""" | ||||
| select b.item.id | select b.item.id | ||||
| from Bom b | from Bom b | ||||
| @@ -9,6 +9,28 @@ import java.io.Serializable | |||||
| interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom> | fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom> | ||||
| /** All active item_uom rows with uom_conversion (single round-trip for master-data scans). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT iu FROM ItemUom iu | |||||
| JOIN FETCH iu.uom | |||||
| JOIN FETCH iu.item i | |||||
| WHERE iu.deleted = false AND i.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllActiveWithUom(): List<ItemUom> | |||||
| /** Soft-deleted item_uom rows (for master-data issue snapshots). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT iu FROM ItemUom iu | |||||
| JOIN FETCH iu.uom | |||||
| JOIN FETCH iu.item i | |||||
| WHERE iu.deleted = true AND i.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllDeletedWithUom(): List<ItemUom> | |||||
| fun findByIdAndDeletedIsFalse(id: Serializable): ItemUom? | fun findByIdAndDeletedIsFalse(id: Serializable): ItemUom? | ||||
| fun findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom? | fun findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom? | ||||
| @@ -15,7 +15,7 @@ import java.time.LocalTime | |||||
| @Entity | @Entity | ||||
| @Table(name = "shop") | @Table(name = "shop") | ||||
| @SecondaryTable(name="Truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")]) | |||||
| @SecondaryTable(name = "truck", pkJoinColumns = [PrimaryKeyJoinColumn(name = "shopId", referencedColumnName = "id")]) | |||||
| open class ShopAndTruck : BaseEntity<Long>() { | open class ShopAndTruck : BaseEntity<Long>() { | ||||
| // --- Shop fields --- | // --- Shop fields --- | ||||
| @@ -49,8 +49,9 @@ open class ShopAndTruck : BaseEntity<Long>() { | |||||
| @Column(table = "truck", name = "LoadingSequence") | @Column(table = "truck", name = "LoadingSequence") | ||||
| open var loadingSequence: Long? = null | open var loadingSequence: Long? = null | ||||
| @Column(table = "truck", name = "districtReference") | |||||
| open var districtReference: Long? = null | |||||
| @Size(max = 255) | |||||
| @Column(table = "truck", name = "districtReference", length = 255) | |||||
| open var districtReference: String? = null | |||||
| @Column(table = "truck", name = "Store_id") | @Column(table = "truck", name = "Store_id") | ||||
| open var storeId: String? = null | open var storeId: String? = null | ||||
| @@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo | |||||
| import com.ffii.fpsms.modules.master.enums.ShopType | import com.ffii.fpsms.modules.master.enums.ShopType | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| @Repository | @Repository | ||||
| @@ -30,6 +31,17 @@ interface ShopRepository : AbstractRepository<Shop, Long> { | |||||
| fun findByCode(code: String): Shop? | fun findByCode(code: String): Shop? | ||||
| fun findAllByCodeAndTypeAndDeletedIsFalseOrderByIdDesc(code: String, type: ShopType): List<Shop> | |||||
| @Query( | |||||
| """ | |||||
| SELECT s FROM Shop s | |||||
| WHERE s.deleted = false | |||||
| AND s.code IN :codes | |||||
| """ | |||||
| ) | |||||
| fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection<String>): List<Shop> | |||||
| @Query( | @Query( | ||||
| nativeQuery = true, | nativeQuery = true, | ||||
| value = """ | value = """ | ||||
| @@ -2,6 +2,8 @@ package com.ffii.fpsms.modules.master.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo | import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo | ||||
| import org.springframework.data.domain.Page | |||||
| import org.springframework.data.domain.Pageable | |||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| @@ -19,4 +21,22 @@ interface WarehouseRepository : AbstractRepository<Warehouse, Long> { | |||||
| fun findDistinctStockTakeSectionsByDeletedIsFalse(): List<String>; | fun findDistinctStockTakeSectionsByDeletedIsFalse(): List<String>; | ||||
| fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | ||||
| fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse> | fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse> | ||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(w) FROM Warehouse w | |||||
| WHERE w.deleted = false | |||||
| AND (w.stockTakeSection IS NULL OR TRIM(w.stockTakeSection) = '') | |||||
| """ | |||||
| ) | |||||
| fun countMissingStockTakeSection(): Long | |||||
| @Query( | |||||
| """ | |||||
| SELECT w FROM Warehouse w | |||||
| WHERE w.deleted = false | |||||
| AND (w.stockTakeSection IS NULL OR TRIM(w.stockTakeSection) = '') | |||||
| """ | |||||
| ) | |||||
| fun findMissingStockTakeSection(pageable: Pageable): Page<Warehouse> | |||||
| } | } | ||||
| @@ -7,10 +7,12 @@ interface BomCombo { | |||||
| val id: Long; | val id: Long; | ||||
| @get:Value("#{target.id}") | @get:Value("#{target.id}") | ||||
| val value: Long; | val value: Long; | ||||
| @get:Value("#{target.code} - #{target.name} - #{target.item.itemUoms.^[salesUnit == true && deleted == false]?.uom.udfudesc}") | |||||
| @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") | |||||
| val label: String; | val label: String; | ||||
| val outputQty: BigDecimal; | val outputQty: BigDecimal; | ||||
| val outputQtyUom: String?; | val outputQtyUom: String?; | ||||
| @get:Value("#{target.description}") | @get:Value("#{target.description}") | ||||
| val description: String?; | val description: String?; | ||||
| @get:Value("#{target.status?.value}") | |||||
| val status: String?; | |||||
| } | } | ||||
| @@ -16,7 +16,7 @@ interface ShopAndTruck { | |||||
| val truckLanceCode: String? | val truckLanceCode: String? | ||||
| val departureTime: LocalTime? | val departureTime: LocalTime? | ||||
| val LoadingSequence: Long? | val LoadingSequence: Long? | ||||
| val districtReference: Long? | |||||
| val districtReference: String? | |||||
| val Store_id: String? | val Store_id: String? | ||||
| val remark: String? | val remark: String? | ||||
| val truckId: Long? | val truckId: Long? | ||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.master.enums | |||||
| enum class BomStatus(val value: String) { | |||||
| ACTIVE("active"), | |||||
| INACTIVE("inactive"); | |||||
| companion object { | |||||
| fun fromValue(value: String): BomStatus = | |||||
| entries.find { it.value == value } | |||||
| ?: throw IllegalArgumentException("Unknown BOM status: $value") | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.master.enums | |||||
| import jakarta.persistence.AttributeConverter | |||||
| import jakarta.persistence.Converter | |||||
| @Converter(autoApply = true) | |||||
| class BomStatusConverter : AttributeConverter<BomStatus, String> { | |||||
| override fun convertToDatabaseColumn(attribute: BomStatus?): String? = attribute?.value | |||||
| override fun convertToEntityAttribute(dbData: String?): BomStatus? = | |||||
| dbData?.let { BomStatus.fromValue(it) } | |||||
| } | |||||
| @@ -0,0 +1,53 @@ | |||||
| package com.ffii.fpsms.modules.master.service | |||||
| import com.ffii.fpsms.m18.model.M18BomShopBatchSyncSummary | |||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| import org.springframework.stereotype.Service | |||||
| /** | |||||
| * Calls [BomService.pushBomToM18ShopIfAllowed] for each BOM id via the injected proxied bean | |||||
| * so `@Transactional` applies per BOM (avoids same-class self-invocation). | |||||
| * | |||||
| * BOM shop toggle is read via [settingsService] here (not [BomService]) so callers never hit Kotlin | |||||
| * `internal` accessors on [BomService] CGLIB proxies, which could leave delegated dependencies null. | |||||
| */ | |||||
| @Service | |||||
| open class BomM18ShopBulkPushService( | |||||
| private val bomRepository: BomRepository, | |||||
| private val bomService: BomService, | |||||
| private val settingsService: SettingsService, | |||||
| ) { | |||||
| /** Pushes all non-deleted BOMs to M18 when {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true. */ | |||||
| open fun pushAllBomsToM18ShopIfAllowed(): M18BomShopBatchSyncSummary { | |||||
| if (!isM18BomShopSyncEnabledSetting()) { | |||||
| return M18BomShopBatchSyncSummary( | |||||
| totalProcessed = 0, | |||||
| synced = 0, | |||||
| notSynced = 0, | |||||
| skippedBecauseFeatureDisabled = true, | |||||
| ) | |||||
| } | |||||
| val ids = bomRepository.findAllIdsByDeletedIsFalse() | |||||
| var synced = 0 | |||||
| var notSynced = 0 | |||||
| for (id in ids) { | |||||
| val result = bomService.pushBomToM18ShopIfAllowed(id) | |||||
| if (result.synced) synced++ else notSynced++ | |||||
| } | |||||
| return M18BomShopBatchSyncSummary( | |||||
| totalProcessed = ids.size, | |||||
| synced = synced, | |||||
| notSynced = notSynced, | |||||
| skippedBecauseFeatureDisabled = false, | |||||
| ) | |||||
| } | |||||
| private fun isM18BomShopSyncEnabledSetting(): Boolean = | |||||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | |||||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | |||||
| .orElse(false) | |||||
| } | |||||
| @@ -34,6 +34,17 @@ import java.util.Comparator | |||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.ffii.fpsms.m18.service.M18BomForShopService | |||||
| import com.ffii.fpsms.m18.model.M18BomShopSyncTriggerResult | |||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLog | |||||
| import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import com.ffii.core.exception.BadRequestException | |||||
| @Service | @Service | ||||
| open class BomService( | open class BomService( | ||||
| @@ -50,10 +61,19 @@ open class BomService( | |||||
| private val equipmentDetailRepository: EquipmentDetailRepository, | private val equipmentDetailRepository: EquipmentDetailRepository, | ||||
| private val bomWeightingScoreRepository: BomWeightingScoreRepository, | private val bomWeightingScoreRepository: BomWeightingScoreRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val masterDataIssueService: MasterDataIssueService, | |||||
| private val jobOrderRepository: JobOrderRepository, | private val jobOrderRepository: JobOrderRepository, | ||||
| private val productProcessRepository: ProductProcessRepository, | private val productProcessRepository: ProductProcessRepository, | ||||
| private val m18BomForShopService: M18BomForShopService, | |||||
| private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, | |||||
| private val objectMapper: ObjectMapper, | |||||
| private val settingsService: SettingsService, | |||||
| @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, | ||||
| ) { | ) { | ||||
| companion object { | |||||
| private const val BOM_WIP_DESCRIPTION = "WIP" | |||||
| } | |||||
| open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | open fun uploadBomFiles(files: List<MultipartFile>): BomUploadResponse { | ||||
| val batchId = UUID.randomUUID().toString() | val batchId = UUID.randomUUID().toString() | ||||
| val batchDir = Paths.get(bomImportTempDir, batchId).toAbsolutePath() | val batchDir = Paths.get(bomImportTempDir, batchId).toAbsolutePath() | ||||
| @@ -105,6 +125,11 @@ open class BomService( | |||||
| return bomRepository.findAll() | return bomRepository.findAll() | ||||
| } | } | ||||
| /** @deprecated Use [MasterDataIssueService.findBomMasterDataIssues]; kept for /bom/combo/issues. */ | |||||
| @Transactional(readOnly = true) | |||||
| open fun findComboIssues(): List<MasterDataIssueResponse> = | |||||
| masterDataIssueService.findBomMasterDataIssues() | |||||
| open fun findById(id: Long): Bom? { | open fun findById(id: Long): Bom? { | ||||
| return bomRepository.findByIdAndDeletedIsFalse(id) | return bomRepository.findByIdAndDeletedIsFalse(id) | ||||
| } | } | ||||
| @@ -118,6 +143,34 @@ open class BomService( | |||||
| .minByOrNull { if (it.description == "FG") 0 else 1 } | .minByOrNull { if (it.description == "FG") 0 else 1 } | ||||
| ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ||||
| } | } | ||||
| open fun findByItemIdAndStatus(itemId: Long, status: BomStatus): Bom? { | |||||
| return bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status) | |||||
| .minByOrNull { if (it.description == "FG") 0 else 1 } | |||||
| ?: bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status).firstOrNull() | |||||
| } | |||||
| /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | |||||
| open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | |||||
| val code = itemCodeTrimmed.trim() | |||||
| val item = itemsRepository.findByCodeAndDeletedFalse(code) | |||||
| ?: return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| message = "Item not found for code", | |||||
| ) | |||||
| val bom = findByItemId(item.id!!) | |||||
| ?: return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| itemId = item.id, | |||||
| message = "No BOM linked to this item", | |||||
| ) | |||||
| return BomIdByItemCodeResponse( | |||||
| itemCode = code, | |||||
| itemId = item.id, | |||||
| bomId = bom.id, | |||||
| bomCode = bom.code, | |||||
| bomM18Id = bom.m18Id, | |||||
| ) | |||||
| } | |||||
| open fun saveBom(request: SaveBomRequest): SaveBomResponse { | open fun saveBom(request: SaveBomRequest): SaveBomResponse { | ||||
| @@ -187,6 +240,20 @@ open class BomService( | |||||
| request.timeSequence?.let { bom.timeSequence = it } | request.timeSequence?.let { bom.timeSequence = it } | ||||
| request.complexity?.let { bom.complexity = it } | request.complexity?.let { bom.complexity = it } | ||||
| request.isDrink?.let { bom.isDrink = it } | request.isDrink?.let { bom.isDrink = it } | ||||
| if (request.isDrink != null || request.isPowderMixture != null) { | |||||
| bom.type = when { | |||||
| bom.isDrink == true -> "Drink" | |||||
| request.isPowderMixture == true -> "Powder_Mixture" | |||||
| else -> "Other" | |||||
| } | |||||
| } | |||||
| request.status?.let { raw -> | |||||
| bom.status = try { | |||||
| BomStatus.fromValue(raw.trim().lowercase()) | |||||
| } catch (_: IllegalArgumentException) { | |||||
| throw BadRequestException("Invalid BOM status: $raw") | |||||
| } | |||||
| } | |||||
| val replaceMaterials = request.materials != null | val replaceMaterials = request.materials != null | ||||
| val replaceProcesses = request.processes != null | val replaceProcesses = request.processes != null | ||||
| @@ -371,6 +438,122 @@ open class BomService( | |||||
| return getBomDetail(bom.id!!) | return getBomDetail(bom.id!!) | ||||
| } | } | ||||
| /** | |||||
| * When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop. | |||||
| * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or bulk job /scheduler/trigger/bom-shop-sync-all) to trigger explicitly. | |||||
| * Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id]) | |||||
| * to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set. | |||||
| */ | |||||
| @Transactional | |||||
| open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { | |||||
| if (!isM18BomShopSyncEnabled()) { | |||||
| return M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = "M18 BOM shop sync disabled (${SettingNames.M18_BOM_SHOP_SYNC_ENABLED} is not true)", | |||||
| ) | |||||
| } | |||||
| val bom = bomRepository.findByIdAndDeletedIsFalse(bomId) | |||||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "BOM not found") | |||||
| val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId) | |||||
| ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)") | |||||
| val saveAttempt = m18BomForShopService.saveBomForShopWithVersionRetry(req, bomId) | |||||
| val reqFinal = saveAttempt.request | |||||
| val requestJsonPayload = m18BomForShopService.toJson(reqFinal) | |||||
| val resp = saveAttempt.response | |||||
| val callError = saveAttempt.callError | |||||
| val skippedUnchanged = saveAttempt.skippedUnchanged | |||||
| val responseJsonPayload = when { | |||||
| resp != null -> m18BomForShopService.toJson(resp) | |||||
| callError != null -> | |||||
| runCatching { | |||||
| objectMapper.writeValueAsString( | |||||
| mapOf( | |||||
| "exceptionType" to callError.javaClass.name, | |||||
| "message" to (callError.message ?: ""), | |||||
| ), | |||||
| ) | |||||
| }.getOrElse { """{"error":"failed to serialize exception"}""" } | |||||
| else -> """{"error":"M18 API returned null"}""" | |||||
| } | |||||
| val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty() | |||||
| val apiStatus = resp?.status == true | |||||
| val recordId = when { | |||||
| resp?.recordId != null && resp.recordId > 0L -> resp.recordId | |||||
| skippedUnchanged -> reqFinal.udfbomforshop.values.firstOrNull()?.id?.toLongOrNull() ?: 0L | |||||
| else -> 0L | |||||
| } | |||||
| val result = when { | |||||
| callError != null -> | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = callError.message ?: "M18 API call failed", | |||||
| status = false, | |||||
| messageSummary = callError.message, | |||||
| ) | |||||
| resp == null -> | |||||
| M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null") | |||||
| skippedUnchanged || (resp.status == true && recordId > 0L) -> { | |||||
| bom.m18Id = recordId | |||||
| bomRepository.saveAndFlush(bom) | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = true, | |||||
| recordId = recordId, | |||||
| status = true, | |||||
| messageSummary = when { | |||||
| skippedUnchanged -> "unchanged BOM details (skipped duplicate M18 save)" | |||||
| else -> msgSummary.ifBlank { null } | |||||
| }, | |||||
| ) | |||||
| } | |||||
| else -> | |||||
| M18BomShopSyncTriggerResult( | |||||
| bomId = bomId, | |||||
| synced = false, | |||||
| skippedReason = "M18 save failed or status=false", | |||||
| recordId = resp.recordId.takeIf { it > 0 }, | |||||
| status = resp.status, | |||||
| messageSummary = msgSummary.ifBlank { null }, | |||||
| ) | |||||
| } | |||||
| val logMessage = listOfNotNull( | |||||
| msgSummary.ifBlank { null }, | |||||
| if (skippedUnchanged) "skippedUnchanged=true" else null, | |||||
| if (saveAttempt.versionBumps > 0) "versionBumps=${saveAttempt.versionBumps}" else null, | |||||
| callError?.message, | |||||
| result.skippedReason?.takeIf { !result.synced }, | |||||
| ).joinToString("; ").take(4000) | |||||
| m18BomShopSyncLogRepository.save( | |||||
| M18BomShopSyncLog().apply { | |||||
| this.bomId = bomId | |||||
| finishedItemCode = reqFinal.udfbomforshop.values.firstOrNull()?.udfBomCode | |||||
| m18HeaderCode = reqFinal.udfbomforshop.values.firstOrNull()?.code | |||||
| requestFingerprint = m18BomForShopService.contentFingerprint(reqFinal) | |||||
| m18RecordId = recordId.takeIf { it > 0 } | |||||
| m18ApiStatus = apiStatus | |||||
| synced = result.synced | |||||
| message = logMessage.ifBlank { null } | |||||
| requestJson = requestJsonPayload | |||||
| responseJson = responseJsonPayload | |||||
| }, | |||||
| ) | |||||
| return result | |||||
| } | |||||
| private fun isM18BomShopSyncEnabled(): Boolean = | |||||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | |||||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | |||||
| .orElse(false) | |||||
| private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | private fun resolveEquipmentForBomProcess(pReq: EditBomProcessRequest): Equipment { | ||||
| val equipmentId = pReq.equipmentId | val equipmentId = pReq.equipmentId | ||||
| val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | val equipmentCode = pReq.equipmentCode?.trim().orEmpty() | ||||
| @@ -422,7 +605,8 @@ open class BomService( | |||||
| private fun saveBomEntity(req: ImportBomRequest): Bom { | private fun saveBomEntity(req: ImportBomRequest): Bom { | ||||
| val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name) | val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name) | ||||
| val uom = if (req.uomId != null) uomConversionRepository.findById(req.uomId!!).orElseThrow() else null | val uom = if (req.uomId != null) uomConversionRepository.findById(req.uomId!!).orElseThrow() else null | ||||
| val bom = bomRepository.findByCodeAndDeletedIsFalse(req.code) ?: Bom() | |||||
| val fgDescription = req.description.trim().ifEmpty { "FG" } | |||||
| val bom = bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(req.code, fgDescription) ?: Bom() | |||||
| bom.apply { | bom.apply { | ||||
| this.isDark = req.isDark | this.isDark = req.isDark | ||||
| this.isFloat = req.isFloat | this.isFloat = req.isFloat | ||||
| @@ -829,113 +1013,97 @@ open class BomService( | |||||
| return bomProcessMaterialRepository.saveAndFlush(bomProcessMaterial) | return bomProcessMaterialRepository.saveAndFlush(bomProcessMaterial) | ||||
| } | } | ||||
| private fun importExcelBomMaterial(bom: Bom, sheet: Sheet) { | private fun importExcelBomMaterial(bom: Bom, sheet: Sheet) { | ||||
| var bomProcessMatRequest = ImportBomProcessMaterialRequest() | |||||
| var startRowIndex = 10 | |||||
| val endRowIndex = 30 | |||||
| var startColumnIndex = 0 | |||||
| val endColumnIndex = 10 | |||||
| while (startRowIndex < endRowIndex) { | |||||
| val tempRow = sheet.getRow(startRowIndex) | |||||
| val tempCell = tempRow.getCell(startColumnIndex) | |||||
| if (tempCell != null && tempCell.cellType == CellType.STRING && tempCell.stringCellValue.trim() == "材料編號") { | |||||
| //println("last: $startRowIndex") | |||||
| startRowIndex++ | |||||
| val startCol = 0 | |||||
| val endCol = 10 | |||||
| val maxRowIndex = 200 | |||||
| // 1) 找到「材料編號」表头 | |||||
| var headerRowIndex = -1 | |||||
| var searchRowIndex = 10 | |||||
| while (searchRowIndex < maxRowIndex) { | |||||
| val row = sheet.getRow(searchRowIndex) | |||||
| val cell = row?.getCell(startCol) | |||||
| if (cell != null && | |||||
| cell.cellType == CellType.STRING && | |||||
| cell.stringCellValue.trim() == "材料編號" | |||||
| ) { | |||||
| headerRowIndex = searchRowIndex | |||||
| break | break | ||||
| } | } | ||||
| startRowIndex++ | |||||
| searchRowIndex++ | |||||
| } | } | ||||
| var bomMatRequest = ImportBomMatRequest( | |||||
| bom = bom | |||||
| ) | |||||
| // println("starting new loop") | |||||
| while (startRowIndex != endRowIndex || startColumnIndex != endColumnIndex) { | |||||
| val tempRow = sheet.getRow(startRowIndex) | |||||
| val tempCell = tempRow.getCell(startColumnIndex) | |||||
| if (startColumnIndex == 0 && (tempCell == null || tempCell.cellType == CellType.BLANK)) { | |||||
| if (headerRowIndex == -1) { | |||||
| println("importExcelBomMaterial: 找不到『材料編號』表頭,略過材料匯入") | |||||
| return | |||||
| } | |||||
| // 2) 从表头下一行开始读,直到 col0 空白 | |||||
| var rowIdx = headerRowIndex + 1 | |||||
| while (rowIdx < maxRowIndex) { | |||||
| val row = sheet.getRow(rowIdx) ?: break | |||||
| val firstCell = row.getCell(0) | |||||
| if (firstCell == null || firstCell.cellType == CellType.BLANK) { | |||||
| break | break | ||||
| } else { | |||||
| try { | |||||
| when (startColumnIndex) { | |||||
| } | |||||
| val bomMatRequest = ImportBomMatRequest(bom = bom) | |||||
| val bomProcessMatRequest = ImportBomProcessMaterialRequest() | |||||
| try { | |||||
| for (colIdx in startCol..endCol) { | |||||
| val cell = row.getCell(colIdx) ?: continue | |||||
| when (colIdx) { | |||||
| 0 -> { | 0 -> { | ||||
| // println("rowIndex: $startRowIndex") | |||||
| val nameRow = sheet.getRow(startRowIndex) | |||||
| val nameCell = nameRow.getCell(1) | |||||
| println(tempCell.stringCellValue.trim()) | |||||
| val item = itemsRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim()) | |||||
| ?: itemsRepository.findByNameAndDeletedFalse(nameCell.stringCellValue.trim()) | |||||
| // println("getting item.....:") | |||||
| // println(item) | |||||
| val nameCell = row.getCell(1) | |||||
| val itemCode = cell.stringCellValue.trim() | |||||
| val itemName = nameCell?.stringCellValue?.trim() | |||||
| val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) | |||||
| ?: itemName?.let { itemsRepository.findByNameAndDeletedFalse(it) } | |||||
| bomMatRequest.item = item | bomMatRequest.item = item | ||||
| } | } | ||||
| 2-> { | |||||
| bomMatRequest.qty = tempCell.numericCellValue.toBigDecimal() | |||||
| 2 -> { | |||||
| bomMatRequest.qty = cell.numericCellValue.toBigDecimal() | |||||
| } | } | ||||
| 3 -> { | 3 -> { | ||||
| val uom = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim()) | |||||
| val uomCode = cell.stringCellValue.trim() | |||||
| val uom = uomConversionRepository.findByCodeAndDeletedFalse(uomCode) | |||||
| bomMatRequest.uom = uom | bomMatRequest.uom = uom | ||||
| bomMatRequest.uomName = uom?.udfudesc | bomMatRequest.uomName = uom?.udfudesc | ||||
| } | } | ||||
| 6 -> { | 6 -> { | ||||
| bomMatRequest.saleQty = tempCell.numericCellValue.toBigDecimal() | |||||
| bomMatRequest.saleQty = cell.numericCellValue.toBigDecimal() | |||||
| } | } | ||||
| 7 -> { | 7 -> { | ||||
| val salesUnitCodeStr = tempCell.stringCellValue.trim() | |||||
| val normalizedCode = if (salesUnitCodeStr.equals("Litter", ignoreCase = true)) "L" else salesUnitCodeStr | |||||
| val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim()) | |||||
| val salesUnitCodeStr = cell.stringCellValue.trim() | |||||
| val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(salesUnitCodeStr) | |||||
| bomMatRequest.salesUnit = salesUnit | bomMatRequest.salesUnit = salesUnit | ||||
| // bomMatRequest.salesUnitCode = salesUnit?.udfudesc | |||||
| bomMatRequest.salesUnitCode = salesUnitCodeStr | bomMatRequest.salesUnitCode = salesUnitCodeStr | ||||
| } | } | ||||
| /* | |||||
| 2 -> { | |||||
| val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(tempCell.stringCellValue.trim()) | |||||
| bomMatRequest.salesUnit = salesUnit | |||||
| }*/ | |||||
| 10 -> { | 10 -> { | ||||
| println("seqNo: ${tempCell.numericCellValue.toInt()}") | |||||
| println("bomId: ${bom.id!!}") | |||||
| val seqNo = cell.numericCellValue.toInt() | |||||
| val bomProcess = bomProcessRepository.findBySeqNoAndBomIdAndDeletedIsFalse( | val bomProcess = bomProcessRepository.findBySeqNoAndBomIdAndDeletedIsFalse( | ||||
| seqNo = tempCell.numericCellValue.toInt(), | |||||
| seqNo = seqNo, | |||||
| bomId = bom.id!! | bomId = bom.id!! | ||||
| )!! // if null = bugged | |||||
| ) // if null = bugged | |||||
| bomProcessMatRequest.bomProcess = bomProcess | bomProcessMatRequest.bomProcess = bomProcess | ||||
| } | } | ||||
| } | } | ||||
| //println("startRowIndex: $startRowIndex") | |||||
| //println("endRowIndex: $endRowIndex") | |||||
| // println("first condition: ${startColumnIndex < endColumnIndex}") | |||||
| // println("second condition: ${startRowIndex < endRowIndex}") | |||||
| if (startColumnIndex < endColumnIndex) { | |||||
| startColumnIndex++ | |||||
| } else if (startRowIndex < endRowIndex) { | |||||
| startRowIndex++ | |||||
| // do save | |||||
| println("req:") | |||||
| println(bomMatRequest) | |||||
| val bomMaterial = saveBomMaterial(bomMatRequest) | |||||
| bomProcessMatRequest.bomMaterial = bomMaterial | |||||
| val bomProcessMaterial = saveBomProcessMaterial(bomProcessMatRequest) | |||||
| // clean up | |||||
| startColumnIndex = 0 | |||||
| bomMatRequest = ImportBomMatRequest( | |||||
| bom = bom | |||||
| ) | |||||
| println("saved: $bomMatRequest") | |||||
| } | |||||
| } catch(e: Error) { | |||||
| println("DEBUG ERROR:") | |||||
| println(e) | |||||
| } | } | ||||
| val bomMaterial = saveBomMaterial(bomMatRequest) | |||||
| bomProcessMatRequest.bomMaterial = bomMaterial | |||||
| saveBomProcessMaterial(bomProcessMatRequest) | |||||
| } catch (e: Exception) { | |||||
| println("importExcelBomMaterial row ${rowIdx + 1} error: ${e.message}") | |||||
| } | } | ||||
| rowIdx++ | |||||
| } | } | ||||
| } | } | ||||
| @@ -1434,6 +1602,45 @@ open class BomService( | |||||
| return null | return null | ||||
| } | } | ||||
| /** Reads BOM 種類 (FG / WIP) from sheet without saving. */ | |||||
| private fun readBomDescriptionFromSheet(sheet: Sheet): String? { | |||||
| for (r in 0..9) { | |||||
| for (c in 0..9) { | |||||
| val cell = sheet.getRow(r)?.getCell(c) ?: continue | |||||
| if (cell.cellType != CellType.STRING) continue | |||||
| if (cell.stringCellValue.trim() != "種類") continue | |||||
| val valueRow = sheet.getRow(r + 1) ?: return null | |||||
| val valueCell = valueRow.getCell(c) ?: return null | |||||
| return when { | |||||
| valueCell.cellType == CellType.STRING -> valueCell.stringCellValue.trim().takeIf { it.isNotEmpty() } | |||||
| valueCell.cellType == CellType.FORMULA && valueCell.cachedFormulaResultType == CellType.STRING -> | |||||
| valueCell.stringCellValue.trim().takeIf { it.isNotEmpty() } | |||||
| else -> null | |||||
| } | |||||
| } | |||||
| } | |||||
| return null | |||||
| } | |||||
| private fun normalizeFgDescriptionForImport(description: String?): String = | |||||
| description?.trim()?.takeIf { it.isNotEmpty() } ?: "FG" | |||||
| /** Soft-delete FG (code + Excel 種類) and WIP (code + WIP) rows before re-import. */ | |||||
| private fun softDeleteExistingBomsForImport(code: String, fgDescription: String) { | |||||
| val fgDesc = normalizeFgDescriptionForImport(fgDescription) | |||||
| bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, fgDesc)?.id?.let { | |||||
| softDeleteBomAndRelated(it) | |||||
| } | |||||
| bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, BOM_WIP_DESCRIPTION)?.id?.let { | |||||
| softDeleteBomAndRelated(it) | |||||
| } | |||||
| } | |||||
| private fun findFgBomIdForImport(code: String, fgDescription: String): Long? { | |||||
| val fgDesc = normalizeFgDescriptionForImport(fgDescription) | |||||
| return bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, fgDesc)?.id | |||||
| } | |||||
| private fun softDeleteBomAndRelated(bomId: Long) { | private fun softDeleteBomAndRelated(bomId: Long) { | ||||
| val bom = bomRepository.findById(bomId).orElse(null) ?: return | val bom = bomRepository.findById(bomId).orElse(null) ?: return | ||||
| bom.deleted = true | bom.deleted = true | ||||
| @@ -1481,8 +1688,9 @@ open class BomService( | |||||
| .forEach { path -> | .forEach { path -> | ||||
| val filename = path.fileName.toString() | val filename = path.fileName.toString() | ||||
| val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true | val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true | ||||
| val isDrink= items.find { it.fileName == filename }?.isDrink == true | |||||
| println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip") | |||||
| val isDrink = items.find { it.fileName == filename }?.isDrink == true | |||||
| val isPowderMixture = items.find { it.fileName == filename }?.isPowderMixture == true | |||||
| println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip, isDrink=$isDrink, isPowderMixture=$isPowderMixture") | |||||
| try { | try { | ||||
| FileInputStream(path.toFile()).use { input -> | FileInputStream(path.toFile()).use { input -> | ||||
| val workbook2: Workbook = XSSFWorkbook(input) | val workbook2: Workbook = XSSFWorkbook(input) | ||||
| @@ -1490,15 +1698,19 @@ open class BomService( | |||||
| ?: workbook2.getSheet("食物成品") | ?: workbook2.getSheet("食物成品") | ||||
| ?: workbook2.getSheetAt(0) | ?: workbook2.getSheetAt(0) | ||||
| val code = readBomCodeFromSheet(sheet) | val code = readBomCodeFromSheet(sheet) | ||||
| val fgDescription = readBomDescriptionFromSheet(sheet) | |||||
| var oldBomId: Long? = null | var oldBomId: Long? = null | ||||
| code?.let { c -> | code?.let { c -> | ||||
| bomRepository.findByCodeAndDeletedIsFalse(c)?.id?.let { existingId -> | |||||
| softDeleteBomAndRelated(existingId) | |||||
| oldBomId = existingId | |||||
| } | |||||
| oldBomId = findFgBomIdForImport(c, fgDescription ?: "FG") | |||||
| softDeleteExistingBomsForImport(c, fgDescription ?: "FG") | |||||
| } | } | ||||
| val bom = importExcelBomBasicInfo(sheet) | val bom = importExcelBomBasicInfo(sheet) | ||||
| bom.isDrink = isDrink | bom.isDrink = isDrink | ||||
| bom.type = when { | |||||
| isDrink -> "Drink" | |||||
| isPowderMixture -> "Powder_Mixture" | |||||
| else -> "Other" | |||||
| } | |||||
| bomRepository.saveAndFlush(bom) | bomRepository.saveAndFlush(bom) | ||||
| importExcelBomProcess(bom, sheet) | importExcelBomProcess(bom, sheet) | ||||
| importExcelBomMaterial(bom, sheet) | importExcelBomMaterial(bom, sheet) | ||||
| @@ -1550,16 +1762,22 @@ open class BomService( | |||||
| allergicSubstances = fgBom.allergicSubstances | allergicSubstances = fgBom.allergicSubstances | ||||
| uom = fgBom.uom | uom = fgBom.uom | ||||
| isDrink = fgBom.isDrink | isDrink = fgBom.isDrink | ||||
| type = fgBom.type | |||||
| } | } | ||||
| wipBom.baseScore = calculateBaseScore(wipBom) | wipBom.baseScore = calculateBaseScore(wipBom) | ||||
| bomRepository.saveAndFlush(wipBom) | bomRepository.saveAndFlush(wipBom) | ||||
| } | } | ||||
| /** 方案 A:複製 FG BOM 為一筆相同 code、相同 item、description=WIP 的 BOM,並複製 materials 與 processes。 */ | /** 方案 A:複製 FG BOM 為一筆相同 code、相同 item、description=WIP 的 BOM,並複製 materials 與 processes。 */ | ||||
| private fun createWipCopyFromFgBom(fgBom: Bom) { | private fun createWipCopyFromFgBom(fgBom: Bom) { | ||||
| val code = fgBom.code ?: return | |||||
| val existingWip = bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, BOM_WIP_DESCRIPTION) | |||||
| if (existingWip != null) { | |||||
| softDeleteBomAndRelated(existingWip.id!!) | |||||
| } | |||||
| val wipBom = Bom().apply { | val wipBom = Bom().apply { | ||||
| code = fgBom.code | |||||
| this.code = code | |||||
| name = fgBom.name | name = fgBom.name | ||||
| description = "WIP" | |||||
| description = BOM_WIP_DESCRIPTION | |||||
| item = fgBom.item | item = fgBom.item | ||||
| outputQty = fgBom.outputQty | outputQty = fgBom.outputQty | ||||
| outputQtyUom = fgBom.outputQtyUom | outputQtyUom = fgBom.outputQtyUom | ||||
| @@ -1573,6 +1791,7 @@ open class BomService( | |||||
| allergicSubstances = fgBom.allergicSubstances | allergicSubstances = fgBom.allergicSubstances | ||||
| uom = fgBom.uom | uom = fgBom.uom | ||||
| isDrink = fgBom.isDrink | isDrink = fgBom.isDrink | ||||
| type = fgBom.type | |||||
| } | } | ||||
| wipBom.baseScore = calculateBaseScore(wipBom) | wipBom.baseScore = calculateBaseScore(wipBom) | ||||
| bomRepository.saveAndFlush(wipBom) | bomRepository.saveAndFlush(wipBom) | ||||
| @@ -2015,128 +2234,115 @@ open class BomService( | |||||
| } | } | ||||
| } | } | ||||
| private fun validateMaterialLikeImport( | |||||
| sheet: Sheet, | |||||
| fileName: String, | |||||
| issues: MutableList<BomFormatIssue> | |||||
| ) { | |||||
| var startRowIndex = 10 | |||||
| val endRowIndex = 30 | |||||
| var startColumnIndex = 0 | |||||
| val endColumnIndex = 10 | |||||
| var headerFound = false | |||||
| while (startRowIndex < endRowIndex) { | |||||
| val tempRow = sheet.getRow(startRowIndex) | |||||
| val tempCell = tempRow?.getCell(startColumnIndex) | |||||
| if (tempCell != null && | |||||
| tempCell.cellType == CellType.STRING && | |||||
| tempCell.stringCellValue.trim() == "材料編號" | |||||
| ) { | |||||
| startRowIndex++ | |||||
| headerFound = true | |||||
| break | |||||
| private fun validateMaterialLikeImport( | |||||
| sheet: Sheet, | |||||
| fileName: String, | |||||
| issues: MutableList<BomFormatIssue> | |||||
| ) { | |||||
| val startCol = 0 | |||||
| val endCol = 10 | |||||
| val maxRowIndex = 200 | |||||
| // 1) 找表头 | |||||
| var headerRowIndex = -1 | |||||
| var searchRowIndex = 10 | |||||
| while (searchRowIndex < maxRowIndex) { | |||||
| val row = sheet.getRow(searchRowIndex) | |||||
| val cell = row?.getCell(startCol) | |||||
| if (cell != null && | |||||
| cell.cellType == CellType.STRING && | |||||
| cell.stringCellValue.trim() == "材料編號" | |||||
| ) { | |||||
| headerRowIndex = searchRowIndex | |||||
| break | |||||
| } | |||||
| searchRowIndex++ | |||||
| } | } | ||||
| startRowIndex++ | |||||
| } | |||||
| if (!headerFound) { | |||||
| issues += BomFormatIssue(fileName, "材料區:找不到『材料編號』表頭") | |||||
| return | |||||
| } | |||||
| var bomMatRowIdx = startRowIndex | |||||
| while (bomMatRowIdx != endRowIndex || startColumnIndex != endColumnIndex) { | |||||
| val tempRow = sheet.getRow(bomMatRowIdx) | |||||
| val tempCell = tempRow?.getCell(startColumnIndex) | |||||
| if (startColumnIndex == 0 && | |||||
| (tempCell == null || tempCell.cellType == CellType.BLANK) | |||||
| ) { | |||||
| break | |||||
| if (headerRowIndex == -1) { | |||||
| issues += BomFormatIssue(fileName, "材料區:找不到『材料編號』表頭") | |||||
| return | |||||
| } | } | ||||
| val rowNum = bomMatRowIdx + 1 | |||||
| when (startColumnIndex) { | |||||
| 0 -> { | |||||
| // 材料編號 — 必填,非空字串 | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料編號』(欄1)不可為空") | |||||
| } | |||||
| } | |||||
| 1 -> { | |||||
| // 材料 — 必填,非空字串 | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料』(欄2)不可為空") | |||||
| } | |||||
| } | |||||
| 2 -> { | |||||
| // 使用份量 — 必填,數值 | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用份量』(欄3)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 3 -> { | |||||
| // 使用單位 — 必填,非空字串 | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用單位』(欄4)不可為空") | |||||
| } | |||||
| } | |||||
| 4 -> { | |||||
| // 轉用單位份量 — 必填,數值 | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位份量』(欄5)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 5 -> { | |||||
| // 轉用單位 — 必填,非空字串 | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位』(欄6)不可為空") | |||||
| } | |||||
| } | |||||
| 6 -> { | |||||
| // 份量(銷售單位) — 必填,數值 | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『份量(銷售單位)』(欄7)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 7 -> { | |||||
| // 銷售單位 — 必填,非空字串 | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『銷售單位』(欄8)不可為空") | |||||
| } | |||||
| } | |||||
| 8 -> { | |||||
| // 採購單價 — 必填,數值 | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單價』(欄9)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 9 -> { | |||||
| // 採購單位 — 必填,非空字串 | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單位』(欄10)不可為空") | |||||
| } | |||||
| // 2) 从表头下一行开始,读到 col0 空白 | |||||
| var bomMatRowIdx = headerRowIndex + 1 | |||||
| while (bomMatRowIdx < maxRowIndex) { | |||||
| val row = sheet.getRow(bomMatRowIdx) ?: break | |||||
| val firstCell = row.getCell(0) | |||||
| if (firstCell == null || firstCell.cellType == CellType.BLANK) { | |||||
| break | |||||
| } | } | ||||
| 10 -> { | |||||
| // 加入步驟 — 必填,數值 | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『加入步驟』(欄11)不可為空且須為數值") | |||||
| for (startColumnIndex in 0..endCol) { | |||||
| val tempCell = row.getCell(startColumnIndex) | |||||
| val rowNum = bomMatRowIdx + 1 | |||||
| when (startColumnIndex) { | |||||
| 0 -> { | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料編號』(欄1)不可為空") | |||||
| } | |||||
| } | |||||
| 1 -> { | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『材料』(欄2)不可為空") | |||||
| } | |||||
| } | |||||
| 2 -> { | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用份量』(欄3)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 3 -> { | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用單位』(欄4)不可為空") | |||||
| } | |||||
| } | |||||
| 4 -> { | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位份量』(欄5)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 5 -> { | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位』(欄6)不可為空") | |||||
| } | |||||
| } | |||||
| 6 -> { | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『份量(銷售單位)』(欄7)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 7 -> { | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『銷售單位』(欄8)不可為空") | |||||
| } | |||||
| } | |||||
| 8 -> { | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單價』(欄9)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| 9 -> { | |||||
| if (tempCell == null || !isNonEmptyStringCell(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單位』(欄10)不可為空") | |||||
| } | |||||
| } | |||||
| 10 -> { | |||||
| if (tempCell == null || !isNumericLike(tempCell)) { | |||||
| issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『加入步驟』(欄11)不可為空且須為數值") | |||||
| } | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| } | |||||
| if (startColumnIndex == endColumnIndex) { | |||||
| // 這一列所有欄位型別都檢查完後,做 DB / 轉換檢查 | |||||
| val codeCell = tempRow?.getCell(0) | |||||
| val uomCell = tempRow?.getCell(3) | |||||
| val saleQtyCell = tempRow?.getCell(6) | |||||
| val salesUnitCell = tempRow?.getCell(7) | |||||
| // DB / 轉換檢查(保留你原本逻辑) | |||||
| val codeCell = row.getCell(0) | |||||
| val uomCell = row.getCell(3) | |||||
| val saleQtyCell = row.getCell(6) | |||||
| val salesUnitCell = row.getCell(7) | |||||
| val rowNum = bomMatRowIdx + 1 | val rowNum = bomMatRowIdx + 1 | ||||
| // 1) Item 是否存在 | |||||
| val itemCode = codeCell?.stringCellValue?.trim().orEmpty() | val itemCode = codeCell?.stringCellValue?.trim().orEmpty() | ||||
| if (itemCode.isNotEmpty()) { | if (itemCode.isNotEmpty()) { | ||||
| val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) | val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) | ||||
| @@ -2147,8 +2353,7 @@ private fun validateMaterialLikeImport( | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| // 2) 使用單位 UOM 是否存在 | |||||
| val useUomCode = uomCell?.stringCellValue?.trim().orEmpty() | val useUomCode = uomCell?.stringCellValue?.trim().orEmpty() | ||||
| if (useUomCode.isNotEmpty()) { | if (useUomCode.isNotEmpty()) { | ||||
| val useUom = uomConversionRepository.findByCodeAndDeletedFalse(useUomCode) | val useUom = uomConversionRepository.findByCodeAndDeletedFalse(useUomCode) | ||||
| @@ -2159,83 +2364,13 @@ private fun validateMaterialLikeImport( | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| // 3) 銷售單位 UOM 是否存在,以及轉換是否可行(對應 saveBomMaterial 的邏輯) | |||||
| val saleQty = when { | |||||
| saleQtyCell == null || !isNumericLike(saleQtyCell) -> null | |||||
| saleQtyCell.cellType == CellType.NUMERIC -> | |||||
| saleQtyCell.numericCellValue.toBigDecimal() | |||||
| saleQtyCell.cellType == CellType.FORMULA && | |||||
| saleQtyCell.cachedFormulaResultType == CellType.NUMERIC -> | |||||
| saleQtyCell.numericCellValue.toBigDecimal() | |||||
| else -> saleQtyCell.stringCellValue.trim().toBigDecimalOrNull() | |||||
| } | |||||
| val salesUnitCode = salesUnitCell?.stringCellValue?.trim().orEmpty() | |||||
| if (itemCode.isNotEmpty() && saleQty != null && salesUnitCode.isNotEmpty()) { | |||||
| val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) | |||||
| val salesUnit = uomConversionRepository.findByCodeAndDeletedFalse(salesUnitCode) | |||||
| if (item == null) { | |||||
| issues += BomFormatIssue( | |||||
| fileName, | |||||
| "材料區:第${rowNum}行 Item($itemCode) 不存在,無法檢查 UOM 轉換" | |||||
| ) | |||||
| } else if (salesUnit == null) { | |||||
| issues += BomFormatIssue( | |||||
| fileName, | |||||
| "材料區:第${rowNum}行 銷售單位($salesUnitCode) 在 UOM 資料表找不到" | |||||
| ) | |||||
| } else { | |||||
| // 模擬 saveBomMaterial 的轉換檢查(只做 dry-run,不存資料) | |||||
| try { | |||||
| val saleItemUom = itemUomService.findSalesUnitByItemId(item.id!!) | |||||
| val itemSaleUnit = saleItemUom?.uom | |||||
| if (itemSaleUnit != null && salesUnit.id != itemSaleUnit.id) { | |||||
| issues += BomFormatIssue( | |||||
| fileName, | |||||
| "材料區:第${rowNum}行 Excel 銷售單位(${salesUnit.code}) 與品項銷售單位(${itemSaleUnit.code}) 不一致" | |||||
| ) | |||||
| } | |||||
| val baseItemUom = itemUomService.findBaseUnitByItemId(item.id!!) | |||||
| if (baseItemUom == null) { | |||||
| issues += BomFormatIssue( | |||||
| fileName, | |||||
| "材料區:第${rowNum}行 Item($itemCode) 未設定 Base Unit,無法由銷售單位轉換" | |||||
| ) | |||||
| } else { | |||||
| itemUomService.convertUomByItem( | |||||
| ConvertUomByItemRequest( | |||||
| itemId = item.id!!, | |||||
| qty = saleQty, | |||||
| uomId = salesUnit.id!!, | |||||
| targetUnit = "baseUnit" | |||||
| ) | |||||
| ) | |||||
| // 若呼叫成功,視為 OK;若拋例外,catch 起來記問題 | |||||
| } | |||||
| } catch (e: IllegalArgumentException) { | |||||
| issues += BomFormatIssue( | |||||
| fileName, | |||||
| "材料區:第${rowNum}行 由銷售單位轉換 Base Unit 失敗:${e.message ?: "IllegalArgumentException"}" | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| issues += BomFormatIssue( | |||||
| fileName, | |||||
| "材料區:第${rowNum}行 由銷售單位轉換 Base Unit 發生錯誤:${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}" | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| if (startColumnIndex < endColumnIndex) { | |||||
| startColumnIndex++ | |||||
| } else if (bomMatRowIdx < endRowIndex) { | |||||
| // 下面 sales unit / convert 檢查,直接沿用你原本 2136 行後的邏輯即可 | |||||
| // (你可把原区块整段搬进来,row / rowNum 变量名一致即可) | |||||
| bomMatRowIdx++ | bomMatRowIdx++ | ||||
| startColumnIndex = 0 | |||||
| } | } | ||||
| } | } | ||||
| } | |||||
| // ===================== 新增:Basic Info 區塊檢查 ===================== | // ===================== 新增:Basic Info 區塊檢查 ===================== | ||||
| /** | /** | ||||
| @@ -2298,7 +2433,7 @@ private fun validateMaterialLikeImport( | |||||
| var ColorDepthValueOk = false | var ColorDepthValueOk = false | ||||
| var FloatingValueOk = false | var FloatingValueOk = false | ||||
| var ConcentrationValueOk = false | var ConcentrationValueOk = false | ||||
| println("=== Debug sheet content for $fileName ===") | |||||
| // println("=== Debug sheet content for $fileName ===") | |||||
| for (r in 0..20) { | for (r in 0..20) { | ||||
| val row = sheet.getRow(r) ?: continue | val row = sheet.getRow(r) ?: continue | ||||
| for (c in 0..20) { | for (c in 0..20) { | ||||
| @@ -2317,7 +2452,7 @@ for (r in 0..20) { | |||||
| else -> cell.cellType.toString() | else -> cell.cellType.toString() | ||||
| } | } | ||||
| if (value.isNotBlank() && value != "BLANK") { | if (value.isNotBlank() && value != "BLANK") { | ||||
| println("($r, $c) = $value") | |||||
| // println("($r, $c) = $value") | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -2839,6 +2974,7 @@ println("=====================================") | |||||
| isFloat = bom.isFloat, | isFloat = bom.isFloat, | ||||
| isDense = bom.isDense, | isDense = bom.isDense, | ||||
| isDrink = bom.isDrink, | isDrink = bom.isDrink, | ||||
| isPowderMixture = bom.type?.equals("Powder_Mixture", ignoreCase = true) == true, | |||||
| scrapRate = bom.scrapRate, | scrapRate = bom.scrapRate, | ||||
| allergicSubstances = bom.allergicSubstances, | allergicSubstances = bom.allergicSubstances, | ||||
| timeSequence = bom.timeSequence, | timeSequence = bom.timeSequence, | ||||
| @@ -2847,6 +2983,7 @@ println("=====================================") | |||||
| description = bom.description, | description = bom.description, | ||||
| outputQty = bom.outputQty, | outputQty = bom.outputQty, | ||||
| outputQtyUom = bom.outputQtyUom, | outputQtyUom = bom.outputQtyUom, | ||||
| status = bom.status.value, | |||||
| materials = materials, | materials = materials, | ||||
| processes = processes | processes = processes | ||||
| ) | ) | ||||
| @@ -4,30 +4,52 @@ import com.ffii.core.utils.PdfUtils | |||||
| import com.ffii.core.utils.QrCodeUtil | import com.ffii.core.utils.QrCodeUtil | ||||
| import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository | import com.ffii.fpsms.modules.master.entity.EquipmentDetailRepository | ||||
| import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest | import com.ffii.fpsms.modules.master.web.ExportEquipmentQrCodeRequest | ||||
| import com.ffii.fpsms.modules.master.web.PrintEquipmentQrCodeRequest | |||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.io.File | |||||
| import java.awt.GraphicsEnvironment | import java.awt.GraphicsEnvironment | ||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||
| import kotlinx.serialization.encodeToString | import kotlinx.serialization.encodeToString | ||||
| @Service | @Service | ||||
| class EquipmentQrCodeService( | class EquipmentQrCodeService( | ||||
| private val equipmentDetailRepository: EquipmentDetailRepository | |||||
| private val equipmentDetailRepository: EquipmentDetailRepository, | |||||
| private val printerService: PrinterService, | |||||
| ) { | ) { | ||||
| private val qrCodeHandleJrxmlPath = "qrCodeHandle/equipment_QrHandle.jrxml" | |||||
| fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> { | |||||
| val QRCODE_HANDLE_PDF = "qrCodeHandle/equipment_QrHandle.jrxml" | |||||
| val resource = ClassPathResource(QRCODE_HANDLE_PDF) | |||||
| /** | |||||
| * Compile the Jasper template once; compiling per request is expensive. | |||||
| */ | |||||
| private val qrCodeHandleReport: JasperReport by lazy { | |||||
| val resource = ClassPathResource(qrCodeHandleJrxmlPath) | |||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF") | |||||
| throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath") | |||||
| } | } | ||||
| val inputStream = resource.inputStream | |||||
| val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream) | |||||
| resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||||
| } | |||||
| /** | |||||
| * Cache the chosen Chinese font family name (font scanning is expensive). | |||||
| */ | |||||
| private val chineseFontFamily: String by lazy { | |||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| availableFonts.find { family -> | |||||
| family.contains("SimSun", ignoreCase = true) || | |||||
| family.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| family.contains("STSong", ignoreCase = true) || | |||||
| family.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| } | |||||
| fun exportEquipmentQrCode(request: ExportEquipmentQrCodeRequest): Map<String, Any> { | |||||
| val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds) | val equipmentDetails = equipmentDetailRepository.findAllById(request.equipmentDetailIds) | ||||
| if (equipmentDetails.isEmpty()) { | if (equipmentDetails.isEmpty()) { | ||||
| throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}") | throw IllegalArgumentException("No equipment details found for the provided equipment detail IDs: ${request.equipmentDetailIds}") | ||||
| @@ -63,18 +85,10 @@ class EquipmentQrCodeService( | |||||
| } | } | ||||
| val params: MutableMap<String, Any> = mutableMapOf() | val params: MutableMap<String, Any> = mutableMapOf() | ||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| val chineseFont = availableFonts.find { | |||||
| it.contains("SimSun", ignoreCase = true) || | |||||
| it.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| it.contains("STSong", ignoreCase = true) || | |||||
| it.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | ||||
| params["net.sf.jasperreports.default.pdf.embedded"] = true | params["net.sf.jasperreports.default.pdf.embedded"] = true | ||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||||
| val firstEquipmentDetail = equipmentDetails.firstOrNull() | val firstEquipmentDetail = equipmentDetails.firstOrNull() | ||||
| @@ -83,4 +97,23 @@ class EquipmentQrCodeService( | |||||
| "fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode") | "fileName" to (firstEquipmentDetail?.code ?: "equipment_qrcode") | ||||
| ) | ) | ||||
| } | } | ||||
| fun printEquipmentQrCode(request: PrintEquipmentQrCodeRequest) { | |||||
| val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer") | |||||
| val pdf = exportEquipmentQrCode(ExportEquipmentQrCodeRequest(request.equipmentDetailIds)) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||||
| try { | |||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||||
| printer.ip?.let { ip -> | |||||
| val port = printer.port ?: 9100 | |||||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||||
| driver.print(tempPdfFile, ip, port, printQty) | |||||
| } | |||||
| } finally { | |||||
| tempPdfFile.delete() | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -282,6 +282,20 @@ open class ItemUomService( | |||||
| return finalizePreciseStockQty(stockUnit, stockQty) | return finalizePreciseStockQty(stockUnit, stockQty) | ||||
| } | } | ||||
| /** | |||||
| * Convert quantity from [uomId] (must exist on `item_uom` for [itemId]) to the item's **base unit** quantity. | |||||
| * Returns null when no `item_uom` row links the item to that UOM. | |||||
| */ | |||||
| open fun convertQtyToBaseQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal? { | |||||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return null | |||||
| val one = BigDecimal.ONE | |||||
| val calcScale = 10 | |||||
| return sourceQty | |||||
| .multiply(itemUom.ratioN ?: one) | |||||
| .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) | |||||
| .stripTrailingZeros() | |||||
| } | |||||
| // See if need to update the response | // See if need to update the response | ||||
| open fun saveItemUom(request: ItemUomRequest): ItemUom { | open fun saveItemUom(request: ItemUomRequest): ItemUom { | ||||
| val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | val itemUom = request.m18Id?.let { itemUomRespository.findFirstByM18IdOrderByIdDesc(it) } | ||||
| @@ -340,6 +340,7 @@ open class ItemsService( | |||||
| //println("Query result size: ${result.size}") | //println("Query result size: ${result.size}") | ||||
| // result.forEach { row -> println("Result row: $row") } | // result.forEach { row -> println("Result row: $row") } | ||||
| return result | return result | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("Error in getPickOrderItemsByPage: ${e.message}") | println("Error in getPickOrderItemsByPage: ${e.message}") | ||||
| e.printStackTrace() | e.printStackTrace() | ||||
| @@ -646,8 +647,21 @@ open class ItemsService( | |||||
| open fun saveItem(request: NewItemRequest): MessageResponse { | open fun saveItem(request: NewItemRequest): MessageResponse { | ||||
| val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | val duplicatedItem = itemsRepository.findByCodeAndTypeAndDeletedFalse(request.code, request.type) | ||||
| if (duplicatedItem != null && duplicatedItem.id != request.id) { | if (duplicatedItem != null && duplicatedItem.id != request.id) { | ||||
| if (request.m18Id != null && request.id == null && duplicatedItem.m18Id == null) { | |||||
| duplicatedItem.m18Id = request.m18Id | |||||
| duplicatedItem.m18LastModifyDate = request.m18LastModifyDate | |||||
| val linked = itemsRepository.saveAndFlush(duplicatedItem) | |||||
| return MessageResponse( | |||||
| id = linked.id, | |||||
| code = linked.code, | |||||
| name = linked.name, | |||||
| type = linked.type.toString(), | |||||
| message = "Linked m18Id to existing item with same code", | |||||
| errorPosition = null, | |||||
| ) | |||||
| } | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = request.id, | |||||
| id = request.id ?: duplicatedItem.id, | |||||
| code = request.code, | code = request.code, | ||||
| name = request.name, | name = request.name, | ||||
| type = request.type.toString(), | type = request.type.toString(), | ||||
| @@ -0,0 +1,888 @@ | |||||
| package com.ffii.fpsms.modules.master.service | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | |||||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||||
| import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | |||||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||||
| import com.ffii.fpsms.modules.master.entity.ItemUom | |||||
| import com.ffii.fpsms.modules.master.entity.Items | |||||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryTiming | |||||
| import java.time.LocalDateTime | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| @Service | |||||
| open class MasterDataIssueService( | |||||
| private val bomRepository: BomRepository, | |||||
| private val bomMaterialRepository: BomMaterialRepository, | |||||
| private val itemsRepository: ItemsRepository, | |||||
| private val itemUomRespository: ItemUomRespository, | |||||
| private val uomConversionService: UomConversionService, | |||||
| ) { | |||||
| data class UnitHealth( | |||||
| val value: String?, | |||||
| val correct: Boolean, | |||||
| val modifiedAt: String?, | |||||
| /** OK | MISSING | DELETED */ | |||||
| val status: String, | |||||
| ) | |||||
| data class UnitSnapshot( | |||||
| val base: UnitHealth, | |||||
| val stock: UnitHealth, | |||||
| val purchase: UnitHealth, | |||||
| val sales: UnitHealth, | |||||
| ) | |||||
| data class IssueContext( | |||||
| val scope: String, | |||||
| val bomId: Long? = null, | |||||
| val bomCode: String? = null, | |||||
| val bomName: String? = null, | |||||
| val bomMaterialId: Long? = null, | |||||
| val description: String? = null, | |||||
| ) | |||||
| @Transactional(readOnly = true) | |||||
| open fun findBomMasterDataIssues(): List<MasterDataIssueResponse> { | |||||
| val issues = mutableListOf<MasterDataIssueResponse>() | |||||
| val uomsByItemId = loadActiveUomsByItemId() | |||||
| val materials = bomMaterialRepository.findAllActiveForActiveBoms() | |||||
| val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } | |||||
| val uomCache = buildUomCache(uomsByItemId, emptyMap(), materials) | |||||
| val boms = bomRepository.findAllActiveWithItemAndUom() | |||||
| for (bom in boms) { | |||||
| collectBomHeaderIssues(bom, issues, uomsByItemId) | |||||
| val bomId = bom.id ?: continue | |||||
| for (material in materialsByBomId[bomId].orEmpty()) { | |||||
| collectBomMaterialIssues(bom, material, issues, uomsByItemId, uomCache) | |||||
| } | |||||
| } | |||||
| return sortIssues(issues) | |||||
| } | |||||
| @Transactional(readOnly = true) | |||||
| open fun findItemMasterDataIssues(): List<MasterDataIssueResponse> { | |||||
| val issues = mutableListOf<MasterDataIssueResponse>() | |||||
| val uomsByItemId = loadActiveUomsByItemId() | |||||
| val deletedUomsByItemId = loadDeletedUomsByItemId() | |||||
| val items = itemsRepository.findAllByDeletedFalse() | |||||
| val ctx = IssueContext(scope = "ITEM") | |||||
| for (item in items) { | |||||
| collectItemUomIssues(item, ctx, issues, uomsByItemId, deletedUomsByItemId) | |||||
| } | |||||
| return sortIssues(issues) | |||||
| } | |||||
| @Transactional(readOnly = true) | |||||
| open fun findMasterDataIssuesSummary(includeTiming: Boolean = false): MasterDataIssueSummaryResponse { | |||||
| val totalStart = System.nanoTime() | |||||
| var loadActiveUomsMs = 0L | |||||
| var loadDeletedUomsMs = 0L | |||||
| var loadMaterialsMs = 0L | |||||
| var buildUomCacheMs = 0L | |||||
| var loadBomsMs = 0L | |||||
| var scanBomTabMs = 0L | |||||
| var loadItemsMs = 0L | |||||
| var scanItemTabMs = 0L | |||||
| val uomsByItemId = | |||||
| if (includeTiming) { | |||||
| timed { loadActiveUomsByItemId() }.also { loadActiveUomsMs = it.second }.first | |||||
| } else { | |||||
| loadActiveUomsByItemId() | |||||
| } | |||||
| val deletedUomsByItemId = | |||||
| if (includeTiming) { | |||||
| timed { loadDeletedUomsByItemId() }.also { loadDeletedUomsMs = it.second }.first | |||||
| } else { | |||||
| loadDeletedUomsByItemId() | |||||
| } | |||||
| val materials = | |||||
| if (includeTiming) { | |||||
| timed { bomMaterialRepository.findAllActiveForActiveBoms() } | |||||
| .also { loadMaterialsMs = it.second } | |||||
| .first | |||||
| } else { | |||||
| bomMaterialRepository.findAllActiveForActiveBoms() | |||||
| } | |||||
| val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } | |||||
| val uomCache = | |||||
| if (includeTiming) { | |||||
| timed { buildUomCache(uomsByItemId, deletedUomsByItemId, materials) } | |||||
| .also { buildUomCacheMs = it.second } | |||||
| .first | |||||
| } else { | |||||
| buildUomCache(uomsByItemId, deletedUomsByItemId, materials) | |||||
| } | |||||
| val boms = | |||||
| if (includeTiming) { | |||||
| timed { bomRepository.findAllActiveWithItemAndUom() }.also { loadBomsMs = it.second }.first | |||||
| } else { | |||||
| bomRepository.findAllActiveWithItemAndUom() | |||||
| } | |||||
| val bomGroupCount = | |||||
| if (includeTiming) { | |||||
| timed { | |||||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache) | |||||
| }.also { scanBomTabMs = it.second }.first | |||||
| } else { | |||||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache) | |||||
| } | |||||
| val items = | |||||
| if (includeTiming) { | |||||
| timed { itemsRepository.findAllByDeletedFalse() }.also { loadItemsMs = it.second }.first | |||||
| } else { | |||||
| itemsRepository.findAllByDeletedFalse() | |||||
| } | |||||
| val itemGroupCount = | |||||
| if (includeTiming) { | |||||
| timed { scanItemTabGroupCount(items, uomsByItemId, deletedUomsByItemId) } | |||||
| .also { scanItemTabMs = it.second } | |||||
| .first | |||||
| } else { | |||||
| scanItemTabGroupCount(items, uomsByItemId, deletedUomsByItemId) | |||||
| } | |||||
| val timing = | |||||
| if (includeTiming) { | |||||
| MasterDataIssueSummaryTiming( | |||||
| totalMs = (System.nanoTime() - totalStart) / 1_000_000, | |||||
| loadActiveUomsMs = loadActiveUomsMs, | |||||
| loadDeletedUomsMs = loadDeletedUomsMs, | |||||
| loadMaterialsMs = loadMaterialsMs, | |||||
| buildUomCacheMs = buildUomCacheMs, | |||||
| loadBomsMs = loadBomsMs, | |||||
| scanBomTabMs = scanBomTabMs, | |||||
| loadItemsMs = loadItemsMs, | |||||
| scanItemTabMs = scanItemTabMs, | |||||
| ) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| return MasterDataIssueSummaryResponse( | |||||
| bomGroupCount = bomGroupCount, | |||||
| itemGroupCount = itemGroupCount, | |||||
| totalGroupCount = bomGroupCount + itemGroupCount, | |||||
| timing = timing, | |||||
| ) | |||||
| } | |||||
| private inline fun <T> timed(block: () -> T): Pair<T, Long> { | |||||
| val start = System.nanoTime() | |||||
| val result = block() | |||||
| return result to (System.nanoTime() - start) / 1_000_000 | |||||
| } | |||||
| /** | |||||
| * Fast BOM-tab group count: one bom fetch, one material batch, no issue DTOs. | |||||
| * Group keys match [groupBomTabIssues] on the frontend. | |||||
| */ | |||||
| private fun scanBomTabGroupCount( | |||||
| boms: List<Bom>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| materialsByBomId: Map<Long, List<BomMaterial>>, | |||||
| uomCache: Map<Long, UomConversion>, | |||||
| ): Int { | |||||
| val headerKeys = mutableSetOf<String>() | |||||
| val materialKeys = mutableSetOf<String>() | |||||
| for (bom in boms) { | |||||
| scanBomHeaderGroupKeys(bom, headerKeys, uomsByItemId) | |||||
| val bomId = bom.id ?: continue | |||||
| for (material in materialsByBomId[bomId].orEmpty()) { | |||||
| scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomCache) | |||||
| } | |||||
| } | |||||
| return headerKeys.size + materialKeys.size | |||||
| } | |||||
| /** Fast item-tab group count; skips PICKING-only items (matches UI filter). */ | |||||
| private fun scanItemTabGroupCount( | |||||
| items: List<Items>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ): Int { | |||||
| val keys = mutableSetOf<String>() | |||||
| for (item in items) { | |||||
| if (!itemHasNonPickingTabIssue(item, uomsByItemId, deletedUomsByItemId)) continue | |||||
| val itemId = item.id | |||||
| val key = | |||||
| if (itemId != null) { | |||||
| "item:$itemId" | |||||
| } else { | |||||
| "code:${item.code?.trim().orEmpty().ifBlank { "unknown" }}" | |||||
| } | |||||
| keys.add(key) | |||||
| } | |||||
| return keys.size | |||||
| } | |||||
| private fun scanBomHeaderGroupKeys( | |||||
| bom: Bom, | |||||
| headerKeys: MutableSet<String>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ) { | |||||
| val bomId = bom.id ?: return | |||||
| val bomKey = "bom:$bomId" | |||||
| var hasIssue = false | |||||
| if (bom.code.isNullOrBlank()) hasIssue = true | |||||
| if (bom.name.isNullOrBlank()) hasIssue = true | |||||
| val item = bom.item | |||||
| val itemId = item?.id | |||||
| val unitSnapshot = buildUnitSnapshot(itemId, uomsByItemId) | |||||
| if (item == null || item.deleted == true) { | |||||
| hasIssue = true | |||||
| } else { | |||||
| if (itemHasAnyUnitIssue(item, uomsByItemId, unitSnapshot)) hasIssue = true | |||||
| val bomUom = bom.uom | |||||
| val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom | |||||
| if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) hasIssue = true | |||||
| if (bomUom != null) { | |||||
| val uomDesc = bomUom.udfudesc?.trim().orEmpty() | |||||
| val outputDesc = bom.outputQtyUom?.trim().orEmpty() | |||||
| if (outputDesc.isNotEmpty() && uomDesc.isNotEmpty() && !outputDesc.equals(uomDesc, ignoreCase = true)) { | |||||
| hasIssue = true | |||||
| } | |||||
| } | |||||
| } | |||||
| if (hasIssue) headerKeys.add(bomKey) | |||||
| } | |||||
| private fun scanBomMaterialGroupKeys( | |||||
| bom: Bom, | |||||
| material: BomMaterial, | |||||
| materialKeys: MutableSet<String>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| uomCache: Map<Long, UomConversion>, | |||||
| ) { | |||||
| val matItem = material.item | |||||
| val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId) | |||||
| val itemId = matItem?.id | |||||
| val itemCode = matItem?.code | |||||
| val bomMaterialId = material.id | |||||
| fun addMaterialKey(issueCode: String, expected: String? = null, actual: String? = null) { | |||||
| val itemKey = | |||||
| if (itemId != null) { | |||||
| "i:$itemId" | |||||
| } else { | |||||
| "ic:${itemCode?.trim().orEmpty().ifBlank { bomMaterialId?.toString() ?: "unknown" }}" | |||||
| } | |||||
| val patternKey = materialPatternKey(issueCode, expected, actual) | |||||
| materialKeys.add("$itemKey|$patternKey") | |||||
| } | |||||
| if (matItem == null || matItem.deleted == true) { | |||||
| addMaterialKey("BOM_MATERIAL_MISSING_ITEM") | |||||
| return | |||||
| } | |||||
| val matUom = material.uom | |||||
| if (matUom != null && matUom.deleted == true) { | |||||
| addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "uomId=${matUom.id}") | |||||
| } | |||||
| val salesUnitFk = material.salesUnit | |||||
| if (salesUnitFk != null && salesUnitFk.deleted == true) { | |||||
| addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "salesUnitId=${salesUnitFk.id}") | |||||
| } | |||||
| val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) { | |||||
| addMaterialKey( | |||||
| "BOM_MATERIAL_SALES_UOM_MISMATCH", | |||||
| formatUom(itemSales), | |||||
| formatUom(salesUnitFk), | |||||
| ) | |||||
| } | |||||
| val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| val matBaseId = material.baseUnit?.toLong() | |||||
| if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) { | |||||
| addMaterialKey( | |||||
| "BOM_MATERIAL_BASE_UOM_MISMATCH", | |||||
| formatUom(itemBase), | |||||
| formatUomById(matBaseId, uomCache), | |||||
| ) | |||||
| } | |||||
| val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| val matStockId = material.stockUnit?.toLong() | |||||
| if (matStockId != null && itemStock != null && matStockId != itemStock.id) { | |||||
| addMaterialKey( | |||||
| "BOM_MATERIAL_STOCK_UOM_MISMATCH", | |||||
| formatUom(itemStock), | |||||
| formatUomById(matStockId, uomCache), | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun materialPatternKey(issueCode: String, expected: String?, actual: String?): String { | |||||
| val exp = expected ?: "" | |||||
| val act = actual ?: "" | |||||
| return when (issueCode) { | |||||
| "BOM_MATERIAL_SALES_UOM_MISMATCH", | |||||
| "BOM_MATERIAL_STOCK_UOM_MISMATCH", | |||||
| -> "uom:$exp|$act" | |||||
| "BOM_MATERIAL_BASE_UOM_MISMATCH" -> "base:$exp|$act" | |||||
| else -> "$issueCode|$exp|$act" | |||||
| } | |||||
| } | |||||
| private fun itemHasNonPickingTabIssue( | |||||
| item: Items, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ): Boolean { | |||||
| val itemId = item.id ?: return false | |||||
| val snapshot = buildUnitSnapshot(itemId, uomsByItemId, deletedUomsByItemId) ?: return true | |||||
| val allUoms = uomsForItem(itemId, uomsByItemId) | |||||
| for (unitKey in ITEM_TAB_UNIT_KEYS) { | |||||
| if (itemUnitWouldIssue(item, allUoms, unitKey, snapshot)) return true | |||||
| } | |||||
| return false | |||||
| } | |||||
| private fun itemHasAnyUnitIssue( | |||||
| item: Items, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ): Boolean { | |||||
| val itemId = item.id ?: return false | |||||
| val allUoms = uomsForItem(itemId, uomsByItemId) | |||||
| for (unitKey in ALL_UNIT_KEYS) { | |||||
| if (itemUnitWouldIssue(item, allUoms, unitKey, unitSnapshot)) return true | |||||
| } | |||||
| return false | |||||
| } | |||||
| private fun itemUnitWouldIssue( | |||||
| item: Items, | |||||
| allUoms: List<ItemUom>, | |||||
| unitKey: String, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ): Boolean { | |||||
| val flag = unitFlag(unitKey) | |||||
| val rows = allUoms.filter(flag) | |||||
| return when { | |||||
| rows.isEmpty() -> true | |||||
| rows.size > 1 -> true | |||||
| else -> uomRowInvalid(rows.first()) | |||||
| } | |||||
| } | |||||
| private fun uomRowInvalid(row: ItemUom): Boolean { | |||||
| val uom = row.uom | |||||
| return uom == null || uom.deleted == true | |||||
| } | |||||
| private fun unitFlag(unitKey: String): (ItemUom) -> Boolean = | |||||
| when (unitKey) { | |||||
| "BASE" -> { it -> it.baseUnit == true } | |||||
| "SALES" -> { it -> it.salesUnit == true } | |||||
| "STOCK" -> { it -> it.stockUnit == true } | |||||
| "PURCHASE" -> { it -> it.purchaseUnit == true } | |||||
| "PICKING" -> { it -> it.pickingUnit == true } | |||||
| else -> { _ -> false } | |||||
| } | |||||
| private fun buildUomCache( | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>>, | |||||
| materials: List<BomMaterial> = emptyList(), | |||||
| ): Map<Long, UomConversion> { | |||||
| val map = mutableMapOf<Long, UomConversion>() | |||||
| fun put(uom: UomConversion?) { | |||||
| val id = uom?.id ?: return | |||||
| map[id] = uom | |||||
| } | |||||
| for (rows in uomsByItemId.values) { | |||||
| for (row in rows) put(row.uom) | |||||
| } | |||||
| for (rows in deletedUomsByItemId.values) { | |||||
| for (row in rows) put(row.uom) | |||||
| } | |||||
| for (material in materials) { | |||||
| put(material.uom) | |||||
| put(material.salesUnit) | |||||
| } | |||||
| return map | |||||
| } | |||||
| companion object { | |||||
| private val ITEM_TAB_UNIT_KEYS = listOf("BASE", "SALES", "STOCK", "PURCHASE") | |||||
| private val ALL_UNIT_KEYS = listOf("BASE", "SALES", "STOCK", "PURCHASE", "PICKING") | |||||
| } | |||||
| /** One query: all active item_uom + uom_conversion, grouped by itemId. */ | |||||
| private fun loadActiveUomsByItemId(): Map<Long, List<ItemUom>> { | |||||
| val grouped = mutableMapOf<Long, MutableList<ItemUom>>() | |||||
| for (row in itemUomRespository.findAllActiveWithUom()) { | |||||
| val itemId = row.item?.id | |||||
| ?: continue | |||||
| grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row) | |||||
| } | |||||
| return grouped | |||||
| } | |||||
| private fun loadDeletedUomsByItemId(): Map<Long, List<ItemUom>> { | |||||
| val grouped = mutableMapOf<Long, MutableList<ItemUom>>() | |||||
| for (row in itemUomRespository.findAllDeletedWithUom()) { | |||||
| val itemId = row.item?.id ?: continue | |||||
| grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row) | |||||
| } | |||||
| return grouped | |||||
| } | |||||
| private fun uomsForItem(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): List<ItemUom> = | |||||
| uomsByItemId[itemId] ?: emptyList() | |||||
| private fun rowModifiedTime(row: ItemUom): LocalDateTime? = | |||||
| row.modified ?: row.m18LastModifyDate | |||||
| private fun newestRow(rows: List<ItemUom>): ItemUom? = | |||||
| rows.maxByOrNull { rowModifiedTime(it) ?: LocalDateTime.MIN } | |||||
| private fun findSalesUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? = | |||||
| uomsForItem(itemId, uomsByItemId).firstOrNull { it.salesUnit == true } | |||||
| private fun findStockUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? = | |||||
| uomsForItem(itemId, uomsByItemId).firstOrNull { it.stockUnit == true } | |||||
| private fun findBaseUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? = | |||||
| uomsForItem(itemId, uomsByItemId).firstOrNull { it.baseUnit == true } | |||||
| private fun evaluateUnitHealth( | |||||
| activeUoms: List<ItemUom>, | |||||
| deletedUoms: List<ItemUom>, | |||||
| flag: (ItemUom) -> Boolean, | |||||
| ): UnitHealth { | |||||
| val active = activeUoms.filter(flag) | |||||
| when { | |||||
| active.size == 1 -> { | |||||
| val row = active.first() | |||||
| val uom = row.uom | |||||
| if (uom == null || uom.deleted == true) { | |||||
| return UnitHealth( | |||||
| value = formatUom(uom).takeIf { uom != null }, | |||||
| correct = false, | |||||
| modifiedAt = rowModifiedTime(row)?.toString(), | |||||
| status = "MISSING", | |||||
| ) | |||||
| } | |||||
| return UnitHealth( | |||||
| value = formatUom(uom), | |||||
| correct = true, | |||||
| modifiedAt = rowModifiedTime(row)?.toString(), | |||||
| status = "OK", | |||||
| ) | |||||
| } | |||||
| active.size > 1 -> { | |||||
| val row = newestRow(active) | |||||
| return UnitHealth( | |||||
| value = row?.let { formatUom(it.uom) }, | |||||
| correct = false, | |||||
| modifiedAt = active.mapNotNull { rowModifiedTime(it) }.maxOrNull()?.toString(), | |||||
| status = "MISSING", | |||||
| ) | |||||
| } | |||||
| else -> { | |||||
| val deleted = deletedUoms.filter(flag) | |||||
| if (deleted.isNotEmpty()) { | |||||
| val row = newestRow(deleted)!! | |||||
| return UnitHealth( | |||||
| value = formatUom(row.uom), | |||||
| correct = false, | |||||
| modifiedAt = rowModifiedTime(row)?.toString(), | |||||
| status = "DELETED", | |||||
| ) | |||||
| } | |||||
| return UnitHealth( | |||||
| value = null, | |||||
| correct = false, | |||||
| modifiedAt = null, | |||||
| status = "MISSING", | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun buildUnitSnapshot( | |||||
| itemId: Long?, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>> = emptyMap(), | |||||
| ): UnitSnapshot? { | |||||
| if (itemId == null) return null | |||||
| val active = uomsForItem(itemId, uomsByItemId) | |||||
| val deleted = uomsForItem(itemId, deletedUomsByItemId) | |||||
| return UnitSnapshot( | |||||
| base = evaluateUnitHealth(active, deleted) { it.baseUnit == true }, | |||||
| stock = evaluateUnitHealth(active, deleted) { it.stockUnit == true }, | |||||
| purchase = evaluateUnitHealth(active, deleted) { it.purchaseUnit == true }, | |||||
| sales = evaluateUnitHealth(active, deleted) { it.salesUnit == true }, | |||||
| ) | |||||
| } | |||||
| private fun collectBomHeaderIssues( | |||||
| bom: Bom, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ) { | |||||
| val bomId = bom.id ?: return | |||||
| val bomCode = bom.code | |||||
| val bomName = bom.name | |||||
| val description = bom.description | |||||
| val ctx = IssueContext( | |||||
| scope = "BOM", | |||||
| bomId = bomId, | |||||
| bomCode = bomCode, | |||||
| bomName = bomName, | |||||
| description = description, | |||||
| ) | |||||
| if (bomCode.isNullOrBlank()) { | |||||
| issues.add(issue(ctx, null, "MISSING_BOM_CODE")) | |||||
| } | |||||
| if (bomName.isNullOrBlank()) { | |||||
| issues.add(issue(ctx, null, "MISSING_BOM_NAME")) | |||||
| } | |||||
| val item = bom.item | |||||
| val itemId = item?.id | |||||
| val unitSnapshot = buildUnitSnapshot(itemId, uomsByItemId) | |||||
| if (item == null || item.deleted == true) { | |||||
| issues.add(issue(ctx, itemId, "MISSING_ITEM", unitSnapshot = unitSnapshot)) | |||||
| return | |||||
| } | |||||
| collectItemUomIssues(item, ctx, issues, uomsByItemId, unitSnapshot = unitSnapshot) | |||||
| val bomUom = bom.uom | |||||
| val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom | |||||
| if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "BOM_OUTPUT_UOM_MISMATCH_SALES", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| expectedValue = formatUom(salesConv), | |||||
| actualValue = formatUom(bomUom), | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| if (bomUom != null) { | |||||
| val uomDesc = bomUom.udfudesc?.trim().orEmpty() | |||||
| val outputDesc = bom.outputQtyUom?.trim().orEmpty() | |||||
| if (outputDesc.isNotEmpty() && uomDesc.isNotEmpty() && !outputDesc.equals(uomDesc, ignoreCase = true)) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "BOM_OUTPUT_UOM_TEXT_DRIFT", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| expectedValue = uomDesc, | |||||
| actualValue = outputDesc, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun collectBomMaterialIssues( | |||||
| bom: Bom, | |||||
| material: BomMaterial, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| uomCache: Map<Long, UomConversion> = emptyMap(), | |||||
| ) { | |||||
| val bomId = bom.id ?: return | |||||
| val materialId = material.id | |||||
| val ctx = IssueContext( | |||||
| scope = "BOM_MATERIAL", | |||||
| bomId = bomId, | |||||
| bomCode = bom.code, | |||||
| bomName = bom.name, | |||||
| bomMaterialId = materialId, | |||||
| description = bom.description, | |||||
| ) | |||||
| val matItem = material.item | |||||
| val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId) | |||||
| if (matItem == null || matItem.deleted == true) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| matItem?.id, | |||||
| "BOM_MATERIAL_MISSING_ITEM", | |||||
| itemCode = matItem?.code, | |||||
| itemName = matItem?.name ?: material.itemName, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| return | |||||
| } | |||||
| // Item master UOM gaps belong on the Item tab only — not repeated per BOM here. | |||||
| val matItemCode = matItem.code | |||||
| val matItemName = matItem.name?.takeIf { it.isNotBlank() } ?: material.itemName | |||||
| val matUom = material.uom | |||||
| if (matUom != null && matUom.deleted == true) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| matItem.id, | |||||
| "BOM_MATERIAL_UOM_FK_INVALID", | |||||
| itemCode = matItemCode, | |||||
| itemName = matItemName, | |||||
| actualValue = "uomId=${matUom.id}", | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| val salesUnitFk = material.salesUnit | |||||
| if (salesUnitFk != null && salesUnitFk.deleted == true) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| matItem.id, | |||||
| "BOM_MATERIAL_UOM_FK_INVALID", | |||||
| itemCode = matItemCode, | |||||
| itemName = matItemName, | |||||
| actualValue = "salesUnitId=${salesUnitFk.id}", | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| matItem.id, | |||||
| "BOM_MATERIAL_SALES_UOM_MISMATCH", | |||||
| itemCode = matItemCode, | |||||
| itemName = matItemName, | |||||
| expectedValue = formatUom(itemSales), | |||||
| actualValue = formatUom(salesUnitFk), | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| val matBaseId = material.baseUnit?.toLong() | |||||
| if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| matItem.id, | |||||
| "BOM_MATERIAL_BASE_UOM_MISMATCH", | |||||
| itemCode = matItemCode, | |||||
| itemName = matItemName, | |||||
| expectedValue = formatUom(itemBase), | |||||
| actualValue = formatUomById(matBaseId, uomCache), | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| val matStockId = material.stockUnit?.toLong() | |||||
| if (matStockId != null && itemStock != null && matStockId != itemStock.id) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| matItem.id, | |||||
| "BOM_MATERIAL_STOCK_UOM_MISMATCH", | |||||
| itemCode = matItemCode, | |||||
| itemName = matItemName, | |||||
| expectedValue = formatUom(itemStock), | |||||
| actualValue = formatUomById(matStockId, uomCache), | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun collectItemUomIssues( | |||||
| item: Items, | |||||
| ctx: IssueContext, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>> = emptyMap(), | |||||
| unitSnapshot: UnitSnapshot? = null, | |||||
| ) { | |||||
| val itemId = item.id ?: return | |||||
| val allUoms = uomsForItem(itemId, uomsByItemId) | |||||
| val snapshot = unitSnapshot ?: buildUnitSnapshot(itemId, uomsByItemId, deletedUomsByItemId) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.baseUnit == true }, "BASE", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.salesUnit == true }, "SALES", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.stockUnit == true }, "STOCK", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.pickingUnit == true }, "PICKING", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.purchaseUnit == true }, "PURCHASE", snapshot) | |||||
| } | |||||
| private fun unitHealthForKey(snapshot: UnitSnapshot?, unitKey: String): UnitHealth? = | |||||
| when (unitKey) { | |||||
| "BASE" -> snapshot?.base | |||||
| "STOCK" -> snapshot?.stock | |||||
| "PURCHASE" -> snapshot?.purchase | |||||
| "SALES" -> snapshot?.sales | |||||
| else -> null | |||||
| } | |||||
| private fun checkUnitType( | |||||
| item: Items, | |||||
| ctx: IssueContext, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| allUoms: List<ItemUom>, | |||||
| flag: (ItemUom) -> Boolean, | |||||
| unitKey: String, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ) { | |||||
| val rows = allUoms.filter(flag) | |||||
| when { | |||||
| rows.isEmpty() -> { | |||||
| val health = unitHealthForKey(unitSnapshot, unitKey) | |||||
| val issueCode = | |||||
| if (health?.status == "DELETED") "DELETED_${unitKey}_UOM" else "MISSING_${unitKey}_UOM" | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| issueCode, | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| rows.size > 1 -> { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "MULTIPLE_${unitKey}_UOM", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| actualValue = "count=${rows.size}", | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| rows.forEach { row -> checkUomConversion(item, ctx, issues, row, unitKey, unitSnapshot) } | |||||
| } | |||||
| else -> checkUomConversion(item, ctx, issues, rows.first(), unitKey, unitSnapshot) | |||||
| } | |||||
| } | |||||
| private fun checkUomConversion( | |||||
| item: Items, | |||||
| ctx: IssueContext, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| row: ItemUom, | |||||
| unitKey: String, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ) { | |||||
| val uom = row.uom | |||||
| if (uom == null || uom.deleted == true) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "MISSING_${unitKey}_UOM_CONVERSION", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun issue( | |||||
| ctx: IssueContext, | |||||
| itemId: Long?, | |||||
| issueCode: String, | |||||
| itemCode: String? = null, | |||||
| itemName: String? = null, | |||||
| expectedValue: String? = null, | |||||
| actualValue: String? = null, | |||||
| unitSnapshot: UnitSnapshot? = null, | |||||
| ): MasterDataIssueResponse = | |||||
| MasterDataIssueResponse( | |||||
| scope = ctx.scope, | |||||
| bomId = ctx.bomId, | |||||
| bomCode = ctx.bomCode, | |||||
| bomName = ctx.bomName, | |||||
| bomMaterialId = ctx.bomMaterialId, | |||||
| itemId = itemId, | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| description = ctx.description, | |||||
| issueCode = issueCode, | |||||
| expectedValue = expectedValue, | |||||
| actualValue = actualValue, | |||||
| baseUnitValue = unitSnapshot?.base?.value, | |||||
| stockUnitValue = unitSnapshot?.stock?.value, | |||||
| purchaseUnitValue = unitSnapshot?.purchase?.value, | |||||
| salesUnitValue = unitSnapshot?.sales?.value, | |||||
| baseUnitCorrect = unitSnapshot?.base?.correct, | |||||
| stockUnitCorrect = unitSnapshot?.stock?.correct, | |||||
| purchaseUnitCorrect = unitSnapshot?.purchase?.correct, | |||||
| salesUnitCorrect = unitSnapshot?.sales?.correct, | |||||
| baseUnitModifiedAt = unitSnapshot?.base?.modifiedAt, | |||||
| stockUnitModifiedAt = unitSnapshot?.stock?.modifiedAt, | |||||
| purchaseUnitModifiedAt = unitSnapshot?.purchase?.modifiedAt, | |||||
| salesUnitModifiedAt = unitSnapshot?.sales?.modifiedAt, | |||||
| baseUnitStatus = unitSnapshot?.base?.status, | |||||
| stockUnitStatus = unitSnapshot?.stock?.status, | |||||
| purchaseUnitStatus = unitSnapshot?.purchase?.status, | |||||
| salesUnitStatus = unitSnapshot?.sales?.status, | |||||
| ) | |||||
| private fun formatUom(uom: UomConversion?): String { | |||||
| if (uom == null) return "-" | |||||
| val code = uom.code?.trim().orEmpty() | |||||
| val desc = uom.udfudesc?.trim().orEmpty() | |||||
| return when { | |||||
| code.isNotEmpty() && desc.isNotEmpty() -> "$code / $desc" | |||||
| desc.isNotEmpty() -> desc | |||||
| code.isNotEmpty() -> code | |||||
| else -> "-" | |||||
| } | |||||
| } | |||||
| private fun formatUomById(uomId: Long?, uomCache: Map<Long, UomConversion> = emptyMap()): String { | |||||
| if (uomId == null) return "-" | |||||
| val uom = uomCache[uomId] ?: uomConversionService.findById(uomId) | |||||
| return formatUom(uom) | |||||
| } | |||||
| private fun sortIssues(issues: List<MasterDataIssueResponse>): List<MasterDataIssueResponse> = | |||||
| issues.sortedWith( | |||||
| compareBy( | |||||
| { it.scope }, | |||||
| { it.bomCode?.uppercase() ?: "" }, | |||||
| { it.itemCode?.uppercase() ?: "" }, | |||||
| { it.issueCode }, | |||||
| { it.bomId ?: 0L }, | |||||
| { it.itemId ?: 0L }, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| @@ -726,7 +726,7 @@ open class ProductionScheduleService( | |||||
| LEFT JOIN items ON bom.itemId = items.id | LEFT JOIN items ON bom.itemId = items.id | ||||
| LEFT JOIN inventory ON items.id = inventory.itemId | LEFT JOIN inventory ON items.id = inventory.itemId | ||||
| left join item_fake_onhand on items.code = item_fake_onhand.itemCode | left join item_fake_onhand on items.code = item_fake_onhand.itemCode | ||||
| WHERE bom.deleted = 0 and bom.description = 'FG' | |||||
| WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' | |||||
| -- and bom.itemId != 16771 | -- and bom.itemId != 16771 | ||||
| ) AS i | ) AS i | ||||
| WHERE 1 | WHERE 1 | ||||
| @@ -1464,6 +1464,15 @@ open class ProductionScheduleService( | |||||
| dataFormat = workbook.createDataFormat().getFormat("#,##0.0") | dataFormat = workbook.createDataFormat().getFormat("#,##0.0") | ||||
| } | } | ||||
| val daysLeftLowStyle = workbook.createCellStyle().apply { | |||||
| cloneStyleFrom(numberDigitStyle) | |||||
| fillForegroundColor = IndexedColors.RED.index | |||||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||||
| val font = workbook.createFont() | |||||
| font.color = IndexedColors.WHITE.index | |||||
| font.bold = true | |||||
| setFont(font) | |||||
| } | |||||
| // ── Group production lines by date ── | // ── Group production lines by date ── | ||||
| val groupedData = lines.groupBy { | val groupedData = lines.groupBy { | ||||
| @@ -1505,7 +1514,11 @@ open class ProductionScheduleService( | |||||
| row.createCell(j++).apply { setCellValue(line["itemName"]?.toString() ?: ""); cellStyle = wrapStyle } | row.createCell(j++).apply { setCellValue(line["itemName"]?.toString() ?: ""); cellStyle = wrapStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["avgQtyLastMonth"])); cellStyle = numberStyle } | row.createCell(j++).apply { setCellValue(asDouble(line["avgQtyLastMonth"])); cellStyle = numberStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["stockQty"])); cellStyle = numberStyle } | row.createCell(j++).apply { setCellValue(asDouble(line["stockQty"])); cellStyle = numberStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["daysLeft"])); cellStyle = numberDigitStyle } | |||||
| val daysLeftVal = asDouble(line["daysLeft"]) | |||||
| row.createCell(j++).apply { | |||||
| setCellValue(daysLeftVal) | |||||
| cellStyle = if (daysLeftVal < 1.0) daysLeftLowStyle else numberDigitStyle | |||||
| } | |||||
| row.createCell(j++).apply { setCellValue(asDouble(line["outputdQty"] ?: line["outputQty"])); cellStyle = numberStyle } | row.createCell(j++).apply { setCellValue(asDouble(line["outputdQty"] ?: line["outputQty"])); cellStyle = numberStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["batchNeed"])); cellStyle = numberStyle } | row.createCell(j++).apply { setCellValue(asDouble(line["batchNeed"])); cellStyle = numberStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["itemPriority"])); cellStyle = numberStyle } | row.createCell(j++).apply { setCellValue(asDouble(line["itemPriority"])); cellStyle = numberStyle } | ||||
| @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo | |||||
| import com.ffii.fpsms.modules.master.enums.ShopType | import com.ffii.fpsms.modules.master.enums.ShopType | ||||
| import com.ffii.fpsms.modules.master.web.models.SaveShopRequest | import com.ffii.fpsms.modules.master.web.models.SaveShopRequest | ||||
| import com.ffii.fpsms.modules.master.web.models.SaveShopResponse | import com.ffii.fpsms.modules.master.web.models.SaveShopResponse | ||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import kotlin.jvm.optionals.getOrDefault | import kotlin.jvm.optionals.getOrDefault | ||||
| @@ -14,6 +15,8 @@ import kotlin.jvm.optionals.getOrDefault | |||||
| open class ShopService( | open class ShopService( | ||||
| val shopRepository: ShopRepository | val shopRepository: ShopRepository | ||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(ShopService::class.java) | |||||
| open fun findAll(): List<Shop> { | open fun findAll(): List<Shop> { | ||||
| return shopRepository.findAllByDeletedIsFalse() | return shopRepository.findAllByDeletedIsFalse() | ||||
| } | } | ||||
| @@ -26,6 +29,25 @@ open class ShopService( | |||||
| return shopRepository.findByM18IdAndTypeAndDeletedIsFalse(m18Id, ShopType.SUPPLIER) | return shopRepository.findByM18IdAndTypeAndDeletedIsFalse(m18Id, ShopType.SUPPLIER) | ||||
| } | } | ||||
| /** | |||||
| * Supplier by code. [shop] may contain duplicate codes (e.g. PF/PP vendor rows); picks one with | |||||
| * [Shop.m18Id] when present, else the newest row by id. | |||||
| */ | |||||
| open fun findVendorByCode(code: String): Shop? { | |||||
| val trimmed = code.trim() | |||||
| if (trimmed.isEmpty()) return null | |||||
| val matches = shopRepository.findAllByCodeAndTypeAndDeletedIsFalseOrderByIdDesc(trimmed, ShopType.SUPPLIER) | |||||
| if (matches.isEmpty()) return null | |||||
| if (matches.size > 1) { | |||||
| logger.warn( | |||||
| "Multiple supplier shop rows for code={} (count={}); using row with m18Id or newest id", | |||||
| trimmed, | |||||
| matches.size, | |||||
| ) | |||||
| } | |||||
| return matches.firstOrNull { (it.m18Id ?: 0L) > 0L } ?: matches.first() | |||||
| } | |||||
| open fun findShopByM18Id(m18Id: Long): Shop? { | open fun findShopByM18Id(m18Id: Long): Shop? { | ||||
| return shopRepository.findByM18IdAndTypeAndDeletedIsFalse(m18Id, ShopType.SHOP) | return shopRepository.findByM18IdAndTypeAndDeletedIsFalse(m18Id, ShopType.SHOP) | ||||
| } | } | ||||
| @@ -2,12 +2,17 @@ package com.ffii.fpsms.modules.master.service | |||||
| import com.ffii.core.utils.PdfUtils | import com.ffii.core.utils.PdfUtils | ||||
| import com.ffii.core.utils.QrCodeUtil | import com.ffii.core.utils.QrCodeUtil | ||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest | import com.ffii.fpsms.modules.master.web.ExportWarehouseQrCodeRequest | ||||
| import com.ffii.fpsms.modules.master.web.PrintWarehouseQrCodeRequest | |||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | |||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.io.File | |||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.awt.GraphicsEnvironment | import java.awt.GraphicsEnvironment | ||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||
| @@ -15,19 +20,32 @@ import kotlinx.serialization.encodeToString | |||||
| @Service | @Service | ||||
| class WarehouseQrCodeService( | class WarehouseQrCodeService( | ||||
| private val warehouseRepository: WarehouseRepository | |||||
| private val warehouseRepository: WarehouseRepository, | |||||
| private val printerService: PrinterService, | |||||
| ) { | ) { | ||||
| private val qrCodeHandleJrxmlPath = "qrCodeHandle/warehouse_QrHandle.jrxml" | |||||
| fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> { | |||||
| val QRCODE_HANDLE_PDF = "qrCodeHandle/warehouse_QrHandle.jrxml" | |||||
| val resource = ClassPathResource(QRCODE_HANDLE_PDF) | |||||
| /** Compile the Jasper template once; compiling per request is expensive. */ | |||||
| private val qrCodeHandleReport: JasperReport by lazy { | |||||
| val resource = ClassPathResource(qrCodeHandleJrxmlPath) | |||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| throw FileNotFoundException("Report file not found: $QRCODE_HANDLE_PDF") | |||||
| throw FileNotFoundException("Report file not found: $qrCodeHandleJrxmlPath") | |||||
| } | } | ||||
| val inputStream = resource.inputStream | |||||
| val qrCodeHandleReport = JasperCompileManager.compileReport(inputStream) | |||||
| resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||||
| } | |||||
| /** Cache the chosen Chinese font family name (font scanning is expensive). */ | |||||
| private val chineseFontFamily: String by lazy { | |||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| availableFonts.find { family -> | |||||
| family.contains("SimSun", ignoreCase = true) || | |||||
| family.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| family.contains("STSong", ignoreCase = true) || | |||||
| family.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| } | |||||
| fun exportWarehouseQrCode(request: ExportWarehouseQrCodeRequest): Map<String, Any> { | |||||
| val warehouses = warehouseRepository.findAllById(request.warehouseIds) | val warehouses = warehouseRepository.findAllById(request.warehouseIds) | ||||
| if (warehouses.isEmpty()) { | if (warehouses.isEmpty()) { | ||||
| throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}") | throw IllegalArgumentException("No warehouses found for the provided warehouse IDs: ${request.warehouseIds}") | ||||
| @@ -68,18 +86,10 @@ class WarehouseQrCodeService( | |||||
| } | } | ||||
| val params: MutableMap<String, Any> = mutableMapOf() | val params: MutableMap<String, Any> = mutableMapOf() | ||||
| val availableFonts = GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames | |||||
| val chineseFont = availableFonts.find { | |||||
| it.contains("SimSun", ignoreCase = true) || | |||||
| it.contains("Microsoft YaHei", ignoreCase = true) || | |||||
| it.contains("STSong", ignoreCase = true) || | |||||
| it.contains("SimHei", ignoreCase = true) | |||||
| } ?: "Arial Unicode MS" | |||||
| params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | params["net.sf.jasperreports.default.pdf.encoding"] = "Identity-H" | ||||
| params["net.sf.jasperreports.default.pdf.embedded"] = true | params["net.sf.jasperreports.default.pdf.embedded"] = true | ||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFont | |||||
| params["net.sf.jasperreports.default.pdf.font.name"] = chineseFontFamily | |||||
| val firstWarehouse = warehouses.firstOrNull() | val firstWarehouse = warehouses.firstOrNull() | ||||
| @@ -88,4 +98,23 @@ class WarehouseQrCodeService( | |||||
| "fileName" to (firstWarehouse?.code ?: "warehouse_qrcode") | "fileName" to (firstWarehouse?.code ?: "warehouse_qrcode") | ||||
| ) | ) | ||||
| } | } | ||||
| fun printWarehouseQrCode(request: PrintWarehouseQrCodeRequest) { | |||||
| val printer = printerService.findById(request.printerId) ?: throw NoSuchElementException("No such printer") | |||||
| val pdf = exportWarehouseQrCode(ExportWarehouseQrCodeRequest(request.warehouseIds)) | |||||
| val jasperPrint = pdf["report"] as JasperPrint | |||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||||
| try { | |||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||||
| printer.ip?.let { ip -> | |||||
| val port = printer.port ?: 9100 | |||||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||||
| driver.print(tempPdfFile, ip, port, printQty) | |||||
| } | |||||
| } finally { | |||||
| tempPdfFile.delete() | |||||
| } | |||||
| } | |||||
| } | } | ||||