Forráskód Böngészése

Add ERP integration: FastAPI bridge + intent-aware context injection

- erp-bridge/main.py: FastAPI service (port 8080, internal only) with 5
  endpoints calling erp_api.* stored functions (catalog, contacts, orders
  list, order detail, stock). Selected return columns defined as Python
  sets for easy tuning without touching query logic.
- erp-bridge/requirements.txt, .env.example, erp-bridge.service: deps,
  config template, and systemd unit for python3.12/uvicorn deployment.
- server/erpClient.ts: typed fetch wrapper (8 s timeout, X-API-Key auth)
  covering all 5 bridge endpoints with full request/response interfaces.
- server/erpTools.ts: six business helpers that return LLM-ready strings
  (lookupOrder, lookupOrdersByCustomer, lookupOrdersByPO, lookupCatalog,
  lookupStock, lookupContact).
- server/routers.ts: chat.sendMessage now runs rule-based intent detection
  before the Claude call and injects live ERP data into the system prompt
  via an [ERP CONTEXT] block. Falls back silently if bridge is unreachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tony T 2 hete
szülő
commit
d41f3556a1
6 módosított fájl, 934 hozzáadás és 1 törlés
  1. 20 0
      erp-bridge/erp-bridge.service
  2. 384 0
      erp-bridge/main.py
  3. 5 0
      erp-bridge/requirements.txt
  4. 241 0
      server/erpClient.ts
  5. 203 0
      server/erpTools.ts
  6. 81 1
      server/routers.ts

+ 20 - 0
erp-bridge/erp-bridge.service

@@ -0,0 +1,20 @@
+[Unit]
+Description=Homelegance ERP FastAPI Bridge
+After=network.target
+
+[Service]
+Type=simple
+User=apache
+Group=apache
+WorkingDirectory=/redant/web/erp-bridge
+EnvironmentFile=/redant/web/erp-bridge/.env
+ExecStart=/usr/bin/python3.12 -m uvicorn main:app --host 127.0.0.1 --port 8080 --workers 2
+Restart=always
+RestartSec=5
+
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target

+ 384 - 0
erp-bridge/main.py

@@ -0,0 +1,384 @@
+"""
+Homelegance ERP Bridge — FastAPI service
+Calls erp_api.* stored functions in the ERP PostgreSQL database.
+Listens on 127.0.0.1:8080 (internal only, never exposed to the internet).
+
+Selected columns per endpoint are defined in COLUMNS_* dicts below.
+Adjust those dicts to add / remove fields without touching query logic.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+from contextlib import asynccontextmanager
+from typing import Any
+
+import asyncpg
+from dotenv import load_dotenv
+from fastapi import Depends, FastAPI, Header, HTTPException, Path
+from pydantic import BaseModel, Field
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Config
+# ──────────────────────────────────────────────────────────────────────────────
+load_dotenv()
+
+ERP_DATABASE_URL: str = os.environ["ERP_DATABASE_URL"]
+ERP_API_KEY: str = os.environ["ERP_API_KEY"]
+PORT: int = int(os.getenv("PORT", "8080"))
+
+logging.basicConfig(level=logging.INFO, format="%(levelname)s  %(message)s")
+log = logging.getLogger(__name__)
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Selected-column configuration
+# Each key is the JSON key returned by the ERP stored function.
+# Set the value to True to include it in the API response.
+# ──────────────────────────────────────────────────────────────────────────────
+
+COLUMNS_CATALOG: set[str] = {
+    "Model",
+    "Manufacturer",
+    "Description",
+    "Type",           # product category
+    "Department",
+    "Color",
+    "UOM",
+    "Status",
+    "StockQTY",
+    "EstimatedValue",
+}
+
+COLUMNS_CONTACTS: set[str] = {
+    "ContactID",
+    "Company",
+    "ContactName1",
+    "Phone1",
+    "Email1",
+    "Address1",
+    "City",
+    "State",
+    "Country",
+    "Sales1",         # primary sales rep
+    "AR_CreditHold",
+    "Active",
+    "Terms",
+    "PriceType",
+}
+
+COLUMNS_ORDERS_LIST: set[str] = {
+    "SOID",
+    "OrderType",
+    "Status",
+    "SubStatus",
+    "CustomerName",
+    "CustomerCID",
+    "POID",
+    "Model",
+    "Quantity",
+    "UnitPrice",
+    "TotalPrice",
+    "InvoiceNo",
+    "InvoiceDate",
+    "TrackingNumber",
+    "ETD",
+    "ShipVia",
+    "Carrier",
+    "SODate",
+    "ScheduleDate",
+}
+
+# Columns for the sales_order_get header (flat fields)
+COLUMNS_ORDER_HEADER: set[str] = {
+    "SOID",
+    "POID",
+    "OrderType",
+    "Status",
+    "StatusDesc",
+    "CustomerName",
+    "CustomerCID",
+    "ReceiverName",
+    "ReceiverAddress1",
+    "ReceiverCity",
+    "ReceiverState",
+    "ReceiverCountry",
+    "ReceiverZipcode",
+    "ShipVia",
+    "Shipper",
+    "ScheduleDate",
+    "ArrivalDate",
+    "Notes",
+    "InternalNote",
+    "ModifyTime",
+}
+
+# Columns for each item inside the Items[] array
+COLUMNS_ORDER_ITEM: set[str] = {
+    "LineNo",
+    "ItemID",
+    "Model",
+    "Manufacturer",
+    "Description",
+    "Quantity",
+    "UnitPrice",
+    "TotalPrice",
+    "Status",
+    "StatusDesc",
+    "ETD",
+    "ItemNote",
+    "WarehouseID",
+    "InvoiceQuantity",
+    "TransactedQuantity",
+}
+
+COLUMNS_STOCK: set[str] = {
+    "Model",
+    "Manufacturer",
+    "Description",
+    "Quantity",
+    "PalletID",
+    "WarehouseCID",
+    "SubInv",
+    "HoldByPalletID",
+    "HoldByRef",
+    "B_ETA",
+    "A_ETA",
+    "SalesOrderID",
+}
+
+# ──────────────────────────────────────────────────────────────────────────────
+# DB Pool (lifespan)
+# ──────────────────────────────────────────────────────────────────────────────
+DB_POOL: asyncpg.Pool | None = None
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    global DB_POOL
+    log.info("Connecting to ERP database …")
+    DB_POOL = await asyncpg.create_pool(
+        ERP_DATABASE_URL,
+        min_size=2,
+        max_size=10,
+        command_timeout=30,
+        statement_cache_size=0,   # required when using pgBouncer in transaction mode
+    )
+    log.info("ERP database pool ready.")
+    yield
+    if DB_POOL:
+        await DB_POOL.close()
+    log.info("ERP database pool closed.")
+
+
+app = FastAPI(
+    title="Homelegance ERP Bridge",
+    version="1.0.0",
+    docs_url=None,   # disable Swagger UI in production
+    redoc_url=None,
+    lifespan=lifespan,
+)
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Auth dependency
+# ──────────────────────────────────────────────────────────────────────────────
+
+def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> None:
+    if x_api_key != ERP_API_KEY:
+        raise HTTPException(status_code=401, detail="Invalid API key")
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────────────────────
+
+def filter_keys(obj: dict, allowed: set[str]) -> dict:
+    """Return a new dict containing only the allowed keys."""
+    return {k: v for k, v in obj.items() if k in allowed}
+
+
+def filter_list(rows: list[dict], allowed: set[str]) -> list[dict]:
+    return [filter_keys(r, allowed) for r in rows]
+
+
+async def call_list_fn(fn_name: str, conditions: dict, limit: int) -> list[dict]:
+    """Call an erp_api list stored function that accepts (jsonb, int) and returns json."""
+    assert DB_POOL is not None
+    sql = f"SELECT erp_api.{fn_name}($1::jsonb, $2)"
+    row = await DB_POOL.fetchrow(sql, json.dumps(conditions), limit)
+    if row is None:
+        return []
+    result = row[0]
+    if result is None:
+        return []
+    data = json.loads(result) if isinstance(result, str) else result
+    return data if isinstance(data, list) else []
+
+
+def pick(obj: dict | None, keys: set[str]) -> dict:
+    if not obj:
+        return {}
+    return {k: obj[k] for k in keys if k in obj}
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Request / response models
+# ──────────────────────────────────────────────────────────────────────────────
+
+class CatalogRequest(BaseModel):
+    model: str | None = Field(None, description="Partial model number (ILIKE search)")
+    manufacturer: str | None = Field(None, description="Manufacturer filter")
+    description: str | None = Field(None, description="Description keyword")
+    category: str | None = Field(None, description="Product category / Type")
+    status: str | None = Field(None, description="Item status code (e.g. 'A' for active)")
+    limit: int = Field(20, ge=1, le=200)
+
+
+class ContactsRequest(BaseModel):
+    contact_id: str | None = Field(None, description="Exact ContactID")
+    company: str | None = Field(None, description="Company name (partial match)")
+    name: str | None = Field(None, description="Contact person name")
+    limit: int = Field(20, ge=1, le=200)
+
+
+class OrdersListRequest(BaseModel):
+    so_id: str | None = Field(None, description="Sales Order ID (partial match)")
+    customer_name: str | None = Field(None, description="Customer name (partial match)")
+    customer_cid: str | None = Field(None, description="Customer CID (exact)")
+    po_id: str | None = Field(None, description="PO number (partial match)")
+    status: str | None = Field(None, description="Order status code")
+    limit: int = Field(20, ge=1, le=200)
+
+
+class StockRequest(BaseModel):
+    model: str | None = Field(None, description="Model number (partial match)")
+    warehouse_cid: str | None = Field(None, description="Warehouse code")
+    limit: int = Field(50, ge=1, le=500)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Endpoints
+# ──────────────────────────────────────────────────────────────────────────────
+
+@app.get("/health")
+async def health() -> dict:
+    """Simple liveness probe — no auth required."""
+    return {"status": "ok", "service": "erp-bridge"}
+
+
+@app.post("/catalog", dependencies=[Depends(verify_api_key)])
+async def catalog_search(req: CatalogRequest) -> list[dict[str, Any]]:
+    """
+    Search the product catalog.
+    Calls erp_api.catalog_lists(conditions jsonb, limit int).
+    """
+    conditions: dict[str, Any] = {}
+    if req.model:
+        conditions["Model"] = req.model
+    if req.manufacturer:
+        conditions["Manufacturer"] = req.manufacturer
+    if req.description:
+        conditions["Description"] = req.description
+    if req.category:
+        conditions["Type"] = req.category
+    if req.status:
+        conditions["Status"] = req.status
+
+    rows = await call_list_fn("catalog_lists", conditions, req.limit)
+    return filter_list(rows, COLUMNS_CATALOG)
+
+
+@app.post("/contacts", dependencies=[Depends(verify_api_key)])
+async def contacts_search(req: ContactsRequest) -> list[dict[str, Any]]:
+    """
+    Search customers / contacts.
+    Calls erp_api.contact_lists(conditions jsonb, limit int).
+    """
+    conditions: dict[str, Any] = {}
+    if req.contact_id:
+        conditions["ContactID"] = req.contact_id
+    if req.company:
+        conditions["Company"] = req.company
+    if req.name:
+        conditions["ContactName"] = req.name
+
+    rows = await call_list_fn("contact_lists", conditions, req.limit)
+    return filter_list(rows, COLUMNS_CONTACTS)
+
+
+@app.post("/orders", dependencies=[Depends(verify_api_key)])
+async def orders_list(req: OrdersListRequest) -> list[dict[str, Any]]:
+    """
+    List sales orders (one row per line item).
+    Calls erp_api.sales_orders_lists(conditions jsonb, limit int).
+    """
+    conditions: dict[str, Any] = {}
+    if req.so_id:
+        conditions["SOID"] = req.so_id
+    if req.customer_name:
+        conditions["CustomerName"] = req.customer_name
+    if req.customer_cid:
+        conditions["CustomerCID"] = req.customer_cid
+    if req.po_id:
+        conditions["POID"] = req.po_id
+    if req.status:
+        conditions["Status"] = req.status
+
+    rows = await call_list_fn("sales_orders_lists", conditions, req.limit)
+    return filter_list(rows, COLUMNS_ORDER_HEADER)
+
+
+@app.get("/orders/{so_id}", dependencies=[Depends(verify_api_key)])
+async def order_get(so_id: str = Path(..., description="Sales Order ID")) -> dict[str, Any]:
+    """
+    Retrieve a single sales order with nested Items and Notes.
+    Calls erp_api.sales_order_get(so_id text).
+    """
+    assert DB_POOL is not None
+    sql = "SELECT erp_api.sales_order_get($1)"
+    try:
+        row = await DB_POOL.fetchrow(sql, so_id)
+    except asyncpg.PostgresError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+
+    if row is None or row[0] is None:
+        raise HTTPException(status_code=404, detail=f"Sales order not found: {so_id}")
+
+    full: dict = json.loads(row[0]) if isinstance(row[0], str) else row[0]
+
+    # Filter header columns
+    result = pick(full, COLUMNS_ORDER_HEADER)
+
+    # Filter Items array
+    items_raw: list[dict] = full.get("Items") or []
+    result["Items"] = [pick(item, COLUMNS_ORDER_ITEM) for item in items_raw]
+
+    # Include Notes array as-is (already minimal)
+    result["Notes"] = full.get("Notes") or []
+
+    return result
+
+
+@app.post("/stock", dependencies=[Depends(verify_api_key)])
+async def stock_search(req: StockRequest) -> list[dict[str, Any]]:
+    """
+    Search available stock (pallet level).
+    Calls erp_api.stock_lists(conditions jsonb, limit int).
+    """
+    conditions: dict[str, Any] = {}
+    if req.model:
+        conditions["Model"] = req.model
+    if req.warehouse_cid:
+        conditions["WarehouseCID"] = req.warehouse_cid
+
+    rows = await call_list_fn("stock_lists", conditions, req.limit)
+    return filter_list(rows, COLUMNS_STOCK)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Dev runner (not used in production — uvicorn is started by systemd)
+# ──────────────────────────────────────────────────────────────────────────────
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run("main:app", host="127.0.0.1", port=PORT, reload=False)

+ 5 - 0
erp-bridge/requirements.txt

@@ -0,0 +1,5 @@
+fastapi==0.115.6
+uvicorn[standard]==0.32.1
+asyncpg==0.30.0
+python-dotenv==1.0.1
+pydantic==2.10.3

+ 241 - 0
server/erpClient.ts

@@ -0,0 +1,241 @@
+/**
+ * ERP Bridge HTTP client
+ * Calls the FastAPI erp-bridge service running at 127.0.0.1:8080 (internal only).
+ * All requests are authenticated with X-API-Key header.
+ */
+
+import { ENV } from "./_core/env";
+
+const TIMEOUT_MS = 8_000;
+
+/** Generic fetch wrapper with timeout and error handling */
+async function erpFetch<T>(
+  path: string,
+  options: RequestInit = {}
+): Promise<T> {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+
+  const url = `${ENV.erpApiUrl}${path}`;
+
+  try {
+    const res = await fetch(url, {
+      ...options,
+      signal: controller.signal,
+      headers: {
+        "Content-Type": "application/json",
+        "X-API-Key": ENV.erpApiKey,
+        ...(options.headers ?? {}),
+      },
+    });
+
+    if (!res.ok) {
+      const text = await res.text().catch(() => "");
+      throw new Error(`ERP bridge ${res.status} for ${path}: ${text}`);
+    }
+
+    return (await res.json()) as T;
+  } catch (err: unknown) {
+    if ((err as Error).name === "AbortError") {
+      throw new Error(`ERP bridge timeout after ${TIMEOUT_MS}ms for ${path}`);
+    }
+    throw err;
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+// ─── Request / response types ─────────────────────────────────────────────────
+
+export interface CatalogItem {
+  Model: string | null;
+  Manufacturer: string | null;
+  Description: string | null;
+  Type: string | null;
+  Department: string | null;
+  Color: string | null;
+  UOM: string | null;
+  Status: string | null;
+  StockQTY: number | null;
+  EstimatedValue: number | null;
+}
+
+export interface ContactRecord {
+  ContactID: string | null;
+  Company: string | null;
+  ContactName1: string | null;
+  Phone1: string | null;
+  Email1: string | null;
+  Address1: string | null;
+  City: string | null;
+  State: string | null;
+  Country: string | null;
+  Sales1: string | null;
+  AR_CreditHold: boolean | null;
+  Active: boolean | null;
+  Terms: string | null;
+  PriceType: string | null;
+}
+
+export interface OrderListItem {
+  SOID: string | null;
+  OrderType: string | null;
+  Status: string | null;
+  SubStatus: string | null;
+  CustomerName: string | null;
+  CustomerCID: string | null;
+  POID: string | null;
+  Model: string | null;
+  Quantity: number | null;
+  UnitPrice: number | null;
+  TotalPrice: number | null;
+  InvoiceNo: string | null;
+  InvoiceDate: string | null;
+  TrackingNumber: string | null;
+  ETD: string | null;
+  ShipVia: string | null;
+  Carrier: string | null;
+  SODate: string | null;
+  ScheduleDate: string | null;
+}
+
+export interface OrderItem {
+  LineNo: number | null;
+  ItemID: string | null;
+  Model: string | null;
+  Manufacturer: string | null;
+  Description: string | null;
+  Quantity: number | null;
+  UnitPrice: number | null;
+  TotalPrice: number | null;
+  Status: string | null;
+  StatusDesc: string | null;
+  ETD: string | null;
+  ItemNote: string | null;
+  WarehouseID: number | null;
+  InvoiceQuantity: number | null;
+  TransactedQuantity: number | null;
+}
+
+export interface OrderNote {
+  ID: number;
+  GeneralNote: string | null;
+  DeliveryCarrier: string | null;
+  CODCharges: number | null;
+  PickOrderNote: string | null;
+  InternalNote: string | null;
+  AccNotes: string | null;
+  Pickslip: string | null;
+  CreateUser: string | null;
+  CreateTime: string | null;
+}
+
+export interface OrderDetail {
+  SOID: string | null;
+  POID: string | null;
+  OrderType: string | null;
+  Status: string | null;
+  StatusDesc: string | null;
+  CustomerName: string | null;
+  CustomerCID: string | null;
+  ReceiverName: string | null;
+  ReceiverAddress1: string | null;
+  ReceiverCity: string | null;
+  ReceiverState: string | null;
+  ReceiverCountry: string | null;
+  ReceiverZipcode: string | null;
+  ShipVia: string | null;
+  Shipper: string | null;
+  ScheduleDate: string | null;
+  ArrivalDate: string | null;
+  Notes: string | null;
+  InternalNote: string | null;
+  ModifyTime: string | null;
+  Items: OrderItem[];
+  Notes_: OrderNote[]; // mapped from "Notes" array in the response
+}
+
+export interface StockRecord {
+  Model: string | null;
+  Manufacturer: string | null;
+  Description: string | null;
+  Quantity: number | null;
+  PalletID: string | null;
+  WarehouseCID: string | null;
+  SubInv: string | null;
+  HoldByPalletID: string | null;
+  HoldByRef: string | null;
+  B_ETA: string | null;
+  A_ETA: string | null;
+  SalesOrderID: string | null;
+}
+
+// ─── API calls ────────────────────────────────────────────────────────────────
+
+export interface CatalogParams {
+  model?: string;
+  manufacturer?: string;
+  description?: string;
+  category?: string;
+  status?: string;
+  limit?: number;
+}
+
+export async function fetchCatalog(params: CatalogParams): Promise<CatalogItem[]> {
+  return erpFetch<CatalogItem[]>("/catalog", {
+    method: "POST",
+    body: JSON.stringify({ limit: 20, ...params }),
+  });
+}
+
+export interface ContactParams {
+  contact_id?: string;
+  company?: string;
+  name?: string;
+  limit?: number;
+}
+
+export async function fetchContacts(params: ContactParams): Promise<ContactRecord[]> {
+  return erpFetch<ContactRecord[]>("/contacts", {
+    method: "POST",
+    body: JSON.stringify({ limit: 10, ...params }),
+  });
+}
+
+export interface OrderListParams {
+  so_id?: string;
+  customer_name?: string;
+  customer_cid?: string;
+  po_id?: string;
+  status?: string;
+  limit?: number;
+}
+
+export async function fetchOrdersList(params: OrderListParams): Promise<OrderListItem[]> {
+  return erpFetch<OrderListItem[]>("/orders", {
+    method: "POST",
+    body: JSON.stringify({ limit: 20, ...params }),
+  });
+}
+
+export async function fetchOrderDetail(soId: string): Promise<OrderDetail | null> {
+  try {
+    return await erpFetch<OrderDetail>(`/orders/${encodeURIComponent(soId)}`);
+  } catch (err: unknown) {
+    if ((err as Error).message?.includes("404")) return null;
+    throw err;
+  }
+}
+
+export interface StockParams {
+  model?: string;
+  warehouse_cid?: string;
+  limit?: number;
+}
+
+export async function fetchStock(params: StockParams): Promise<StockRecord[]> {
+  return erpFetch<StockRecord[]>("/stock", {
+    method: "POST",
+    body: JSON.stringify({ limit: 50, ...params }),
+  });
+}

+ 203 - 0
server/erpTools.ts

@@ -0,0 +1,203 @@
+/**
+ * ERP business-query helpers for the chatbot.
+ * Each function fetches ERP data and returns a concise, LLM-friendly string
+ * (or structured object) that can be injected into the system prompt context.
+ *
+ * Sensitive fields (cost prices, internal IDs, etc.) are intentionally omitted.
+ */
+
+import {
+  fetchCatalog,
+  fetchContacts,
+  fetchOrderDetail,
+  fetchOrdersList,
+  fetchStock,
+  type OrderDetail,
+  type OrderListItem,
+} from "./erpClient";
+
+// ─── Order lookup ─────────────────────────────────────────────────────────────
+
+/**
+ * Fetch a single sales order by SO-ID and return a chatbot-friendly summary.
+ */
+export async function lookupOrder(soId: string): Promise<string> {
+  const order = await fetchOrderDetail(soId.trim().toUpperCase());
+  if (!order) return `Sales order "${soId}" was not found in the system.`;
+  return formatOrderDetail(order);
+}
+
+/**
+ * Fetch the N most recent orders for a customer (by CID or name).
+ */
+export async function lookupOrdersByCustomer(
+  customerCid: string,
+  limit = 5
+): Promise<string> {
+  const rows = await fetchOrdersList({ customer_cid: customerCid, limit });
+  if (!rows.length) return `No orders found for customer "${customerCid}".`;
+  return formatOrderList(rows);
+}
+
+/**
+ * Search orders by PO number or partial SO-ID.
+ */
+export async function lookupOrdersByPO(poId: string): Promise<string> {
+  const rows = await fetchOrdersList({ po_id: poId, limit: 10 });
+  if (!rows.length) return `No orders found with PO number "${poId}".`;
+  return formatOrderList(rows);
+}
+
+// ─── Catalog / product lookup ─────────────────────────────────────────────────
+
+export async function lookupCatalog(params: {
+  model?: string;
+  description?: string;
+  category?: string;
+  manufacturer?: string;
+  limit?: number;
+}): Promise<string> {
+  const items = await fetchCatalog({ ...params, limit: params.limit ?? 10 });
+  if (!items.length) return "No matching products found in the catalog.";
+
+  const lines = items.map((i) => {
+    const parts = [
+      `• ${i.Model ?? "—"}`,
+      i.Manufacturer ? `by ${i.Manufacturer}` : null,
+      i.Description ? `— ${i.Description}` : null,
+      i.Type ? `[${i.Type}]` : null,
+      i.Status ? `Status: ${i.Status}` : null,
+      i.StockQTY != null ? `Stock: ${i.StockQTY} ${i.UOM ?? ""}`.trim() : null,
+    ].filter(Boolean);
+    return parts.join("  ");
+  });
+
+  return `Product catalog results (${items.length}):\n${lines.join("\n")}`;
+}
+
+// ─── Stock lookup ─────────────────────────────────────────────────────────────
+
+export async function lookupStock(params: {
+  model: string;
+  warehouse_cid?: string;
+  limit?: number;
+}): Promise<string> {
+  const records = await fetchStock({ ...params, limit: params.limit ?? 30 });
+  if (!records.length)
+    return `No stock records found for model "${params.model}".`;
+
+  // Aggregate quantity by model
+  const byModel: Record<string, { qty: number; wh: Set<string>; eta: string | null }> = {};
+  for (const r of records) {
+    const key = r.Model ?? "UNKNOWN";
+    if (!byModel[key]) byModel[key] = { qty: 0, wh: new Set(), eta: null };
+    byModel[key].qty += r.Quantity ?? 0;
+    if (r.WarehouseCID) byModel[key].wh.add(r.WarehouseCID);
+    if (!byModel[key].eta && (r.A_ETA ?? r.B_ETA))
+      byModel[key].eta = r.A_ETA ?? r.B_ETA;
+  }
+
+  const lines = Object.entries(byModel).map(([model, { qty, wh, eta }]) => {
+    const parts = [`• ${model}`, `Qty: ${qty}`];
+    if (wh.size) parts.push(`Warehouse: ${[...wh].join(", ")}`);
+    if (eta) parts.push(`ETA: ${eta}`);
+    return parts.join("  ");
+  });
+
+  return `Stock availability for "${params.model}" (${records.length} pallet records):\n${lines.join("\n")}`;
+}
+
+// ─── Contact / customer lookup ────────────────────────────────────────────────
+
+export async function lookupContact(params: {
+  contact_id?: string;
+  company?: string;
+  name?: string;
+}): Promise<string> {
+  const contacts = await fetchContacts({ ...params, limit: 5 });
+  if (!contacts.length) return "No matching customer records found.";
+
+  const lines = contacts.map((c) => {
+    const parts = [
+      `• ${c.Company ?? "—"}`,
+      c.ContactName1 ? `(${c.ContactName1})` : null,
+      c.Phone1 ? `Ph: ${c.Phone1}` : null,
+      c.Email1 ? `Email: ${c.Email1}` : null,
+      c.City ? `${c.City}, ${c.State ?? ""} ${c.Country ?? ""}`.trim() : null,
+      c.Terms ? `Terms: ${c.Terms}` : null,
+      c.AR_CreditHold ? "⚠ Credit Hold" : null,
+    ].filter(Boolean);
+    return parts.join("  ");
+  });
+
+  return `Customer records (${contacts.length}):\n${lines.join("\n")}`;
+}
+
+// ─── Formatters ───────────────────────────────────────────────────────────────
+
+function formatOrderDetail(o: OrderDetail): string {
+  const header = [
+    `Order: ${o.SOID ?? "—"}`,
+    `Status: ${o.StatusDesc ?? o.Status ?? "—"}`,
+    o.CustomerName ? `Customer: ${o.CustomerName}` : null,
+    o.POID ? `PO#: ${o.POID}` : null,
+    o.ScheduleDate ? `Ship Date: ${o.ScheduleDate}` : null,
+    o.ShipVia ? `Ship Via: ${o.ShipVia}` : null,
+    o.ReceiverName
+      ? `Receiver: ${o.ReceiverName}, ${o.ReceiverCity ?? ""} ${o.ReceiverState ?? ""}`
+      : null,
+    o.Notes ? `Notes: ${o.Notes}` : null,
+  ]
+    .filter(Boolean)
+    .join("\n");
+
+  const items =
+    o.Items?.length
+      ? "\nLine items:\n" +
+        o.Items.map((i) => {
+          const parts = [
+            `  [${i.LineNo}] ${i.Model ?? "—"}`,
+            i.Description ? `— ${i.Description}` : null,
+            `Qty: ${i.Quantity ?? 0}`,
+            i.UnitPrice != null ? `@ $${i.UnitPrice}` : null,
+            i.StatusDesc ? `(${i.StatusDesc})` : null,
+            i.ETD ? `ETD: ${i.ETD}` : null,
+            i.ItemNote ? `Note: ${i.ItemNote}` : null,
+          ].filter(Boolean);
+          return parts.join("  ");
+        }).join("\n")
+      : "";
+
+  return header + items;
+}
+
+function formatOrderList(rows: OrderListItem[]): string {
+  // Group by SOID to deduplicate (one row per line item)
+  const seen = new Set<string>();
+  const lines: string[] = [];
+
+  for (const r of rows) {
+    const soId = r.SOID ?? "—";
+    if (!seen.has(soId)) {
+      seen.add(soId);
+      const parts = [
+        `• ${soId}`,
+        r.CustomerName ? `— ${r.CustomerName}` : null,
+        r.Status ? `[${r.Status}]` : null,
+        r.ScheduleDate ? `Ship: ${r.ScheduleDate}` : null,
+        r.InvoiceNo ? `Inv: ${r.InvoiceNo}` : null,
+        r.TrackingNumber ? `Track: ${r.TrackingNumber}` : null,
+      ].filter(Boolean);
+      lines.push(parts.join("  "));
+    }
+
+    // Show line items beneath the order
+    if (r.Model) {
+      lines.push(
+        `    – ${r.Model}  Qty: ${r.Quantity ?? 0}  Price: $${r.UnitPrice ?? 0}`
+      );
+    }
+  }
+
+  return `Orders (${seen.size} found):\n${lines.join("\n")}`;
+}

+ 81 - 1
server/routers.ts

@@ -30,6 +30,14 @@ import { messages } from "../drizzle/schema";
 import { eq, desc } from "drizzle-orm";
 import { sdk } from "./_core/sdk";
 import { ENV } from "./_core/env";
+import {
+  lookupOrder,
+  lookupOrdersByCustomer,
+  lookupOrdersByPO,
+  lookupCatalog,
+  lookupStock,
+  lookupContact,
+} from "./erpTools";
 
 /* ─── Homelegance chatbot system prompt ─── */
 const SYSTEM_PROMPT = `You are **Ellie**, the Homelegance AI Assistant — a warm, knowledgeable furniture expert helping visitors on homelegance.com. Always introduce yourself as Ellie when greeting new visitors.
@@ -264,8 +272,80 @@ export const appRouter = router({
         }
 
         const history = await getMessagesByConversation(conversation.id);
+
+        // ── ERP intent detection & context injection ──────────────────────────
+        // Detect what the visitor is asking about and fetch live ERP data.
+        // The result is appended to the system prompt so Claude has real data.
+        let erpContext = "";
+        if (ENV.erpApiKey) {
+          try {
+            const msg = input.content;
+            const msgLower = msg.toLowerCase();
+
+            // 1. Single order lookup  —  "SO-12345", "order SO12345", "#SO-99"
+            const soMatch = msg.match(/\bSO[-\s]?\d{4,}\b/i);
+            if (soMatch) {
+              const soId = soMatch[0].replace(/\s/, "-").toUpperCase();
+              erpContext = await lookupOrder(soId);
+
+            // 2. "my orders" / "recent orders" — needs customer CID on conversation
+            } else if (/\b(my orders?|recent orders?|order history|order status)\b/.test(msgLower)) {
+              const cid = (conversation as any).customerId as string | undefined;
+              if (cid) {
+                erpContext = await lookupOrdersByCustomer(cid, 5);
+              }
+
+            // 3. PO number lookup  —  "PO-12345", "purchase order 5678"
+            } else {
+              const poMatch = msg.match(/\bPO[-\s]?\d{3,}\b/i);
+              if (poMatch) {
+                erpContext = await lookupOrdersByPO(poMatch[0]);
+              }
+            }
+
+            // 4. Stock / inventory
+            if (!erpContext && /\b(in stock|available|inventory|stock|availability)\b/.test(msgLower)) {
+              // Try to extract a model number: capital letters + digits, e.g. "B1234-1"
+              const modelMatch = msg.match(/\b([A-Z]{1,4}[-\s]?\d{3,}[-\w]*)\b/);
+              if (modelMatch) {
+                erpContext = await lookupStock({ model: modelMatch[1] });
+              }
+            }
+
+            // 5. Product / catalog search
+            if (!erpContext && /\b(product|catalog|collection|furniture|model|item|sofa|bed|table|chair|dresser|cabinet)\b/.test(msgLower)) {
+              // Pull keywords: skip common stop words
+              const stopWords = new Set(["the","a","an","is","are","do","you","have","i","can","tell","me","about","show","what","which"]);
+              const keywords = msg
+                .replace(/[^a-zA-Z0-9 ]/g, " ")
+                .split(/\s+/)
+                .filter(w => w.length > 2 && !stopWords.has(w.toLowerCase()))
+                .slice(0, 4)
+                .join(" ");
+              if (keywords) {
+                erpContext = await lookupCatalog({ description: keywords, limit: 8 });
+              }
+            }
+
+            // 6. Customer / dealer lookup
+            if (!erpContext && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
+              const nameMatch = msg.match(/(?:customer|dealer|account|contact|company)[:\s]+([A-Za-z &'.-]{3,40})/i);
+              if (nameMatch) {
+                erpContext = await lookupContact({ company: nameMatch[1].trim() });
+              }
+            }
+          } catch (erpErr) {
+            // ERP errors must never break the chat — just log and continue without context
+            console.error("[ERP] intent lookup error:", erpErr);
+          }
+        }
+
+        const systemContent = erpContext
+          ? `${SYSTEM_PROMPT}\n\n---\n[ERP CONTEXT — live data retrieved for this query]\n${erpContext}\n---`
+          : SYSTEM_PROMPT;
+
         const llmMessages = [
-          { role: "system" as const, content: SYSTEM_PROMPT },
+          { role: "system" as const, content: systemContent },
           ...history.map(m => ({
             role: (m.sender === "visitor" ? "user" : "assistant") as "user" | "assistant",
             content: m.content,