kitchen/backend/api/suppliers.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

343 lines
11 KiB
Python

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from database import get_db, AsyncSessionLocal
from models.user import User
from models.supplier import Supplier
from models.invoice import Invoice
from auth import get_current_user, require_cap
from ocr.parser import identify_supplier
router = APIRouter()
async def rematch_unmatched_invoices(kitchen_id: int):
"""
Re-run supplier matching for all invoices without a supplier.
Called after supplier create/update to match previously unmatched invoices.
"""
async with AsyncSessionLocal() as db:
# Get all invoices without a supplier that have vendor_name from OCR
result = await db.execute(
select(Invoice).where(
Invoice.kitchen_id == kitchen_id,
Invoice.supplier_id == None,
Invoice.vendor_name != None
)
)
invoices = result.scalars().all()
for invoice in invoices:
if invoice.vendor_name:
supplier_id, match_type = await identify_supplier(
invoice.vendor_name, kitchen_id, db
)
if supplier_id:
invoice.supplier_id = supplier_id
invoice.supplier_match_type = match_type
await db.commit()
class SupplierCreate(BaseModel):
name: str
aliases: list[str] = []
template_config: dict = {}
identifier_config: dict = {}
skip_dext: bool = False
order_email: Optional[str] = None
account_number: Optional[str] = None
class SupplierUpdate(BaseModel):
name: Optional[str] = None
aliases: Optional[list[str]] = None
template_config: Optional[dict] = None
identifier_config: Optional[dict] = None
skip_dext: Optional[bool] = None
order_email: Optional[str] = None
account_number: Optional[str] = None
class SupplierResponse(BaseModel):
id: int
name: str
aliases: list[str]
template_config: dict
identifier_config: dict
skip_dext: bool
order_email: Optional[str] = None
account_number: Optional[str] = None
created_at: str
class Config:
from_attributes = True
@router.post("/", response_model=SupplierResponse)
async def create_supplier(
request: SupplierCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create a new supplier with extraction templates"""
supplier = Supplier(
kitchen_id=current_user.kitchen_id,
name=request.name,
aliases=request.aliases,
template_config=request.template_config,
identifier_config=request.identifier_config,
skip_dext=request.skip_dext,
order_email=request.order_email,
account_number=request.account_number,
)
db.add(supplier)
await db.commit()
await db.refresh(supplier)
# Rematch unmatched invoices in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.get("/", response_model=list[SupplierResponse])
async def list_suppliers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""List all suppliers for the current kitchen"""
result = await db.execute(
select(Supplier)
.where(Supplier.kitchen_id == current_user.kitchen_id)
.order_by(Supplier.name)
)
suppliers = result.scalars().all()
return [
SupplierResponse(
id=s.id,
name=s.name,
aliases=s.aliases or [],
template_config=s.template_config,
identifier_config=s.identifier_config,
skip_dext=s.skip_dext,
order_email=s.order_email,
account_number=s.account_number,
created_at=s.created_at.isoformat()
)
for s in suppliers
]
@router.get("/{supplier_id}", response_model=SupplierResponse)
async def get_supplier(
supplier_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get a supplier by ID"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.patch("/{supplier_id}", response_model=SupplierResponse)
async def update_supplier(
supplier_id: int,
update: SupplierUpdate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update a supplier's template configuration"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(supplier, field, value)
await db.commit()
await db.refresh(supplier)
# Rematch unmatched invoices in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.delete("/{supplier_id}")
async def delete_supplier(
supplier_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete a supplier"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
await db.delete(supplier)
await db.commit()
return {"message": "Supplier deleted"}
class AddAliasRequest(BaseModel):
alias: str
invoice_id: Optional[int] = None # If provided, update this invoice's match type to 'exact'
@router.post("/{supplier_id}/aliases", response_model=SupplierResponse)
async def add_supplier_alias(
supplier_id: int,
request: AddAliasRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Add an alias to a supplier for better matching"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
alias = request.alias.strip()
if not alias:
raise HTTPException(status_code=400, detail="Alias cannot be empty")
# Add alias if not already present
# Create a new list to ensure SQLAlchemy detects the change (JSON columns don't detect in-place mutations)
current_aliases = list(supplier.aliases or [])
if alias not in current_aliases:
current_aliases.append(alias)
supplier.aliases = current_aliases
# If invoice_id provided, update that invoice's match type to 'exact'
if request.invoice_id:
inv_result = await db.execute(
select(Invoice).where(
Invoice.id == request.invoice_id,
Invoice.kitchen_id == current_user.kitchen_id
)
)
invoice = inv_result.scalar_one_or_none()
if invoice and invoice.supplier_match_type == 'fuzzy':
invoice.supplier_match_type = 'exact'
await db.commit()
await db.refresh(supplier)
# Rematch unmatched invoices in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.post("/rematch-fuzzy")
async def rematch_fuzzy_invoices(
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Clear all fuzzy-matched invoices and re-run supplier matching.
Use this after updating matching logic to fix incorrect fuzzy matches.
"""
# Count fuzzy matches before clearing
count_result = await db.execute(
select(Invoice).where(
Invoice.kitchen_id == current_user.kitchen_id,
Invoice.supplier_match_type == "fuzzy"
)
)
fuzzy_invoices = count_result.scalars().all()
count = len(fuzzy_invoices)
# Clear supplier assignment for all fuzzy matches
for invoice in fuzzy_invoices:
invoice.supplier_id = None
invoice.supplier_match_type = None
await db.commit()
# Re-run matching in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return {"message": f"Cleared {count} fuzzy matches. Re-matching in background."}