|
@@ -1,10 +1,19 @@
|
|
|
"""
|
|
"""
|
|
|
Homelegance ERP Bridge — FastAPI service
|
|
Homelegance ERP Bridge — FastAPI service
|
|
|
-Calls erp_api.* stored functions in the ERP PostgreSQL database.
|
|
|
|
|
|
|
+Calls chatbot_api.* stored functions in the ERP PostgreSQL database.
|
|
|
Listens on 127.0.0.1:8080 (internal only, never exposed to the internet).
|
|
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.
|
|
|
|
|
|
|
+Permission model (passed by Node.js via request headers):
|
|
|
|
|
+ X-User-Role : "admin" | "agent" | "user"
|
|
|
|
|
+ X-Customer-CID : ERP ContactID (required when role == "user")
|
|
|
|
|
+
|
|
|
|
|
+ admin / agent → calls *_staff function variants — sees all customers
|
|
|
|
|
+ user (dealer) → calls *_dealer function variants — CID enforced at DB level
|
|
|
|
|
+
|
|
|
|
|
+Column selection is handled entirely by the chatbot_api.* functions in PostgreSQL.
|
|
|
|
|
+The dealer variants have p_customer_cid as a mandatory parameter with no default,
|
|
|
|
|
+making it physically impossible to return another customer's data even if this
|
|
|
|
|
+application code is compromised.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
@@ -13,6 +22,7 @@ import json
|
|
|
import logging
|
|
import logging
|
|
|
import os
|
|
import os
|
|
|
from contextlib import asynccontextmanager
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
+from dataclasses import dataclass
|
|
|
from typing import Any
|
|
from typing import Any
|
|
|
|
|
|
|
|
import asyncpg
|
|
import asyncpg
|
|
@@ -32,122 +42,6 @@ PORT: int = int(os.getenv("PORT", "8080"))
|
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
|
log = logging.getLogger(__name__)
|
|
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 (lifespan)
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -163,7 +57,7 @@ async def lifespan(app: FastAPI):
|
|
|
min_size=2,
|
|
min_size=2,
|
|
|
max_size=10,
|
|
max_size=10,
|
|
|
command_timeout=30,
|
|
command_timeout=30,
|
|
|
- statement_cache_size=0, # required when using pgBouncer in transaction mode
|
|
|
|
|
|
|
+ statement_cache_size=0, # required for pgBouncer transaction mode
|
|
|
)
|
|
)
|
|
|
log.info("ERP database pool ready.")
|
|
log.info("ERP database pool ready.")
|
|
|
yield
|
|
yield
|
|
@@ -174,14 +68,14 @@ async def lifespan(app: FastAPI):
|
|
|
|
|
|
|
|
app = FastAPI(
|
|
app = FastAPI(
|
|
|
title="Homelegance ERP Bridge",
|
|
title="Homelegance ERP Bridge",
|
|
|
- version="1.0.0",
|
|
|
|
|
|
|
+ version="3.0.0",
|
|
|
docs_url=None, # disable Swagger UI in production
|
|
docs_url=None, # disable Swagger UI in production
|
|
|
redoc_url=None,
|
|
redoc_url=None,
|
|
|
lifespan=lifespan,
|
|
lifespan=lifespan,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
-# Auth dependency
|
|
|
|
|
|
|
+# Auth & permission dependencies
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> None:
|
|
def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> None:
|
|
@@ -189,70 +83,93 @@ def verify_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> None:
|
|
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
-# Helpers
|
|
|
|
|
-# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
+@dataclass
|
|
|
|
|
+class UserCtx:
|
|
|
|
|
+ role: str # "admin" | "agent" | "user"
|
|
|
|
|
+ customer_cid: str | None # ERP ContactID; required when role == "user"
|
|
|
|
|
|
|
|
-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}
|
|
|
|
|
|
|
+ @property
|
|
|
|
|
+ def is_staff(self) -> bool:
|
|
|
|
|
+ return self.role in ("admin", "agent")
|
|
|
|
|
|
|
|
|
|
+ @property
|
|
|
|
|
+ def is_dealer(self) -> bool:
|
|
|
|
|
+ return self.role == "user"
|
|
|
|
|
|
|
|
-def filter_list(rows: list[dict], allowed: set[str]) -> list[dict]:
|
|
|
|
|
- return [filter_keys(r, allowed) for r in rows]
|
|
|
|
|
|
|
|
|
|
|
|
+def get_user_ctx(
|
|
|
|
|
+ x_user_role: str = Header("admin", alias="X-User-Role"),
|
|
|
|
|
+ x_customer_cid: str | None = Header(None, alias="X-Customer-CID"),
|
|
|
|
|
+) -> UserCtx:
|
|
|
|
|
+ role = x_user_role.lower().strip()
|
|
|
|
|
+ if role not in ("admin", "agent", "user"):
|
|
|
|
|
+ role = "admin" # safe default for unknown values
|
|
|
|
|
+ if role == "user" and not x_customer_cid:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=403,
|
|
|
|
|
+ detail="X-Customer-CID header required for dealer (user) role",
|
|
|
|
|
+ )
|
|
|
|
|
+ return UserCtx(role=role, customer_cid=x_customer_cid)
|
|
|
|
|
|
|
|
-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."""
|
|
|
|
|
|
|
+
|
|
|
|
|
+# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+# DB call helpers
|
|
|
|
|
+# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+async def call_list(sql: str, *args: Any) -> list[dict]:
|
|
|
|
|
+ """Call a chatbot_api function that returns a JSON array."""
|
|
|
assert DB_POOL is not None
|
|
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:
|
|
|
|
|
|
|
+ row = await DB_POOL.fetchrow(sql, *args)
|
|
|
|
|
+ if row is None or row[0] is None:
|
|
|
return []
|
|
return []
|
|
|
- data = json.loads(result) if isinstance(result, str) else result
|
|
|
|
|
|
|
+ data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
|
|
|
return data if isinstance(data, list) else []
|
|
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}
|
|
|
|
|
|
|
+async def call_single(sql: str, *args: Any) -> dict | None:
|
|
|
|
|
+ """Call a chatbot_api function that returns a single JSON object (or NULL)."""
|
|
|
|
|
+ assert DB_POOL is not None
|
|
|
|
|
+ row = await DB_POOL.fetchrow(sql, *args)
|
|
|
|
|
+ if row is None or row[0] is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ data = json.loads(row[0]) if isinstance(row[0], str) else row[0]
|
|
|
|
|
+ return data if isinstance(data, dict) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
-# Request / response models
|
|
|
|
|
|
|
+# Request models
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
class CatalogRequest(BaseModel):
|
|
class CatalogRequest(BaseModel):
|
|
|
- model: str | None = Field(None, description="Partial model number (ILIKE search)")
|
|
|
|
|
- manufacturer: str | None = Field(None, description="Manufacturer filter")
|
|
|
|
|
|
|
+ model: str | None = Field(None, description="Partial model number (ILIKE)")
|
|
|
description: str | None = Field(None, description="Description keyword")
|
|
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)")
|
|
|
|
|
|
|
+ # manufacturer and category are accepted for API compatibility but are
|
|
|
|
|
+ # combined into the keyword search — chatbot_api.catalog_search searches
|
|
|
|
|
+ # model + description fields only
|
|
|
|
|
+ manufacturer: str | None = Field(None, description="Manufacturer (combined into keyword)")
|
|
|
|
|
+ category: str | None = Field(None, description="Category (combined into keyword)")
|
|
|
|
|
+ status: str | None = Field(None, description="Item status (not filtered at DB level)")
|
|
|
limit: int = Field(20, ge=1, le=200)
|
|
limit: int = Field(20, ge=1, le=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
class ContactsRequest(BaseModel):
|
|
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")
|
|
|
|
|
|
|
+ contact_id: str | None = Field(None, description="ContactID (exact)")
|
|
|
|
|
+ company: str | None = Field(None, description="Company name (partial)")
|
|
|
|
|
+ name: str | None = Field(None, description="Contact person name (not used, for API compat)")
|
|
|
limit: int = Field(20, ge=1, le=200)
|
|
limit: int = Field(20, ge=1, le=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrdersListRequest(BaseModel):
|
|
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")
|
|
|
|
|
|
|
+ so_id: str | None = Field(None, description="Sales Order ID")
|
|
|
|
|
+ customer_name: str | None = Field(None, description="Customer name (partial, staff only)")
|
|
|
|
|
+ customer_cid: str | None = Field(None, description="Customer CID — overridden for dealer role")
|
|
|
|
|
+ po_id: str | None = Field(None, description="PO number")
|
|
|
|
|
+ status: str | None = Field(None, description="Order status")
|
|
|
limit: int = Field(20, ge=1, le=200)
|
|
limit: int = Field(20, ge=1, le=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
class StockRequest(BaseModel):
|
|
class StockRequest(BaseModel):
|
|
|
- model: str | None = Field(None, description="Model number (partial match)")
|
|
|
|
|
|
|
+ model: str | None = Field(None, description="Model number (partial)")
|
|
|
warehouse_cid: str | None = Field(None, description="Warehouse code")
|
|
warehouse_cid: str | None = Field(None, description="Warehouse code")
|
|
|
limit: int = Field(50, ge=1, le=500)
|
|
limit: int = Field(50, ge=1, le=500)
|
|
|
|
|
|
|
@@ -263,121 +180,123 @@ class StockRequest(BaseModel):
|
|
|
|
|
|
|
|
@app.get("/health")
|
|
@app.get("/health")
|
|
|
async def health() -> dict:
|
|
async def health() -> dict:
|
|
|
- """Simple liveness probe — no auth required."""
|
|
|
|
|
- return {"status": "ok", "service": "erp-bridge"}
|
|
|
|
|
|
|
+ """Liveness probe — no auth required."""
|
|
|
|
|
+ return {"status": "ok", "service": "erp-bridge", "version": "3.0.0"}
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/catalog", dependencies=[Depends(verify_api_key)])
|
|
@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)
|
|
|
|
|
|
|
+async def catalog_search(
|
|
|
|
|
+ req: CatalogRequest,
|
|
|
|
|
+ ctx: UserCtx = Depends(get_user_ctx),
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Search the product catalog. Catalog is not customer-scoped."""
|
|
|
|
|
+ # Combine description, manufacturer, category into a single keyword search
|
|
|
|
|
+ keyword_parts = [p for p in [req.description, req.manufacturer, req.category] if p]
|
|
|
|
|
+ keyword = " ".join(keyword_parts) or None
|
|
|
|
|
+
|
|
|
|
|
+ return await call_list(
|
|
|
|
|
+ "SELECT chatbot_api.catalog_search($1, $2, $3)",
|
|
|
|
|
+ keyword, req.model, req.limit,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/contacts", dependencies=[Depends(verify_api_key)])
|
|
@app.post("/contacts", dependencies=[Depends(verify_api_key)])
|
|
|
-async def contacts_search(req: ContactsRequest) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
+async def contacts_search(
|
|
|
|
|
+ req: ContactsRequest,
|
|
|
|
|
+ ctx: UserCtx = Depends(get_user_ctx),
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
"""
|
|
"""
|
|
|
- Search customers / contacts.
|
|
|
|
|
- Calls erp_api.contact_lists(conditions jsonb, limit int).
|
|
|
|
|
|
|
+ Search customers/contacts.
|
|
|
|
|
+ Dealer role is scoped to their own record at the DB level.
|
|
|
"""
|
|
"""
|
|
|
- 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)
|
|
|
|
|
|
|
+ if ctx.is_dealer:
|
|
|
|
|
+ # contact_get_dealer returns a single object; wrap in list for consistent response
|
|
|
|
|
+ record = await call_single(
|
|
|
|
|
+ "SELECT chatbot_api.contact_get_dealer($1)",
|
|
|
|
|
+ ctx.customer_cid,
|
|
|
|
|
+ )
|
|
|
|
|
+ return [record] if record else []
|
|
|
|
|
+ else:
|
|
|
|
|
+ return await call_list(
|
|
|
|
|
+ "SELECT chatbot_api.contact_get_staff($1, $2, $3)",
|
|
|
|
|
+ req.company, req.contact_id, req.limit,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/orders", dependencies=[Depends(verify_api_key)])
|
|
@app.post("/orders", dependencies=[Depends(verify_api_key)])
|
|
|
-async def orders_list(req: OrdersListRequest) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
+async def orders_list(
|
|
|
|
|
+ req: OrdersListRequest,
|
|
|
|
|
+ ctx: UserCtx = Depends(get_user_ctx),
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
"""
|
|
"""
|
|
|
- List sales orders (one row per line item).
|
|
|
|
|
- Calls erp_api.sales_orders_lists(conditions jsonb, limit int).
|
|
|
|
|
|
|
+ List sales orders.
|
|
|
|
|
+ Dealer variant enforces CustomerCID at the DB level — cannot be bypassed.
|
|
|
"""
|
|
"""
|
|
|
- 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)
|
|
|
|
|
|
|
+ if ctx.is_dealer:
|
|
|
|
|
+ return await call_list(
|
|
|
|
|
+ "SELECT chatbot_api.orders_list_dealer($1, $2, $3, $4, $5)",
|
|
|
|
|
+ ctx.customer_cid, req.so_id, req.po_id, req.status, req.limit,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ return await call_list(
|
|
|
|
|
+ "SELECT chatbot_api.orders_list_staff($1, $2, $3, $4, $5, $6)",
|
|
|
|
|
+ req.so_id, req.po_id, req.customer_cid, req.customer_name, req.status, req.limit,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/orders/{so_id}", dependencies=[Depends(verify_api_key)])
|
|
@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]:
|
|
|
|
|
|
|
+async def order_get(
|
|
|
|
|
+ so_id: str = Path(..., description="Sales Order ID"),
|
|
|
|
|
+ ctx: UserCtx = Depends(get_user_ctx),
|
|
|
|
|
+) -> dict[str, Any]:
|
|
|
"""
|
|
"""
|
|
|
- Retrieve a single sales order with nested Items and Notes.
|
|
|
|
|
- Calls erp_api.sales_order_get(so_id text).
|
|
|
|
|
|
|
+ Get a single sales order with line items and notes.
|
|
|
|
|
+ Dealer variant validates CID match at the DB level and returns NULL on mismatch
|
|
|
|
|
+ (treated as 404 — prevents order-ID enumeration).
|
|
|
"""
|
|
"""
|
|
|
- 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 ctx.is_dealer:
|
|
|
|
|
+ result = await call_single(
|
|
|
|
|
+ "SELECT chatbot_api.order_get_dealer($1, $2)",
|
|
|
|
|
+ so_id, ctx.customer_cid,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ result = await call_single(
|
|
|
|
|
+ "SELECT chatbot_api.order_get_staff($1)",
|
|
|
|
|
+ so_id,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- if row is None or row[0] is None:
|
|
|
|
|
|
|
+ if result is None:
|
|
|
raise HTTPException(status_code=404, detail=f"Sales order not found: {so_id}")
|
|
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
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/stock", dependencies=[Depends(verify_api_key)])
|
|
@app.post("/stock", dependencies=[Depends(verify_api_key)])
|
|
|
-async def stock_search(req: StockRequest) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
+async def stock_search(
|
|
|
|
|
+ req: StockRequest,
|
|
|
|
|
+ ctx: UserCtx = Depends(get_user_ctx),
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
"""
|
|
"""
|
|
|
- Search available stock (pallet level).
|
|
|
|
|
- Calls erp_api.stock_lists(conditions jsonb, limit int).
|
|
|
|
|
|
|
+ Search available stock.
|
|
|
|
|
+ Dealer variant returns availability info only (no pallet/hold/logistics detail).
|
|
|
"""
|
|
"""
|
|
|
- conditions: dict[str, Any] = {}
|
|
|
|
|
- if req.model:
|
|
|
|
|
- conditions["Model"] = req.model
|
|
|
|
|
- if req.warehouse_cid:
|
|
|
|
|
- conditions["WarehouseCID"] = req.warehouse_cid
|
|
|
|
|
|
|
+ if not req.model:
|
|
|
|
|
+ return []
|
|
|
|
|
|
|
|
- rows = await call_list_fn("stock_lists", conditions, req.limit)
|
|
|
|
|
- return filter_list(rows, COLUMNS_STOCK)
|
|
|
|
|
|
|
+ if ctx.is_dealer:
|
|
|
|
|
+ return await call_list(
|
|
|
|
|
+ "SELECT chatbot_api.stock_search_dealer($1, $2, $3)",
|
|
|
|
|
+ req.model, req.warehouse_cid, req.limit,
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ return await call_list(
|
|
|
|
|
+ "SELECT chatbot_api.stock_search_staff($1, $2, $3)",
|
|
|
|
|
+ req.model, req.warehouse_cid, req.limit,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
-# Dev runner (not used in production — uvicorn is started by systemd)
|
|
|
|
|
|
|
+# Dev runner (production uses uvicorn via systemd)
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
|
if __name__ == "__main__":
|
|
if __name__ == "__main__":
|
|
|
import uvicorn
|
|
import uvicorn
|