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."}