""" Purchase Order API endpoints — full CRUD, attachment, product search, budget view, preview (HTML), and email sending. """ import os import uuid import logging from datetime import date from decimal import Decimal from typing import Optional from html import escape as html_escape from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, or_, func, delete from sqlalchemy.orm import selectinload from pydantic import BaseModel, field_serializer from database import get_db from models.user import User, Kitchen from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem from models.supplier import Supplier from models.settings import KitchenSettings from models.line_item import LineItem from models.invoice import Invoice from auth import get_current_user, require_cap, get_current_user_from_token from services.email_service import EmailService logger = logging.getLogger(__name__) router = APIRouter() UPLOAD_DIR = "data/po_attachments" # ── Pydantic schemas ────────────────────────────────────────────────────────── class LineItemIn(BaseModel): product_id: Optional[int] = None product_code: Optional[str] = None description: str unit: Optional[str] = None unit_price: Decimal quantity: Decimal total: Decimal line_number: int = 0 source: str = "manual" class PurchaseOrderCreate(BaseModel): supplier_id: int order_date: date order_type: str # 'itemised' or 'single_value' total_amount: Optional[Decimal] = None order_reference: Optional[str] = None notes: Optional[str] = None status: str = "DRAFT" line_items: list[LineItemIn] = [] class PurchaseOrderUpdate(BaseModel): supplier_id: Optional[int] = None order_date: Optional[date] = None order_type: Optional[str] = None total_amount: Optional[Decimal] = None order_reference: Optional[str] = None notes: Optional[str] = None status: Optional[str] = None line_items: Optional[list[LineItemIn]] = None class StatusUpdate(BaseModel): status: str class LineItemOut(BaseModel): id: int product_id: Optional[int] product_code: Optional[str] description: str unit: Optional[str] unit_price: Decimal quantity: Decimal total: Decimal line_number: int source: str @field_serializer('unit_price', 'quantity', 'total') def ser(self, v: Decimal) -> float: return float(v) class PurchaseOrderOut(BaseModel): id: int kitchen_id: int supplier_id: int supplier_name: Optional[str] = None order_date: date order_type: str status: str total_amount: Optional[Decimal] order_reference: Optional[str] notes: Optional[str] attachment_path: Optional[str] attachment_original_name: Optional[str] linked_invoice_id: Optional[int] created_by: int created_by_name: Optional[str] = None created_at: str updated_at: str line_items: list[LineItemOut] = [] @field_serializer('total_amount') def ser_amount(self, v: Optional[Decimal]) -> Optional[float]: return float(v) if v is not None else None class BudgetPO(BaseModel): id: int order_type: str status: str total_amount: Optional[float] order_reference: Optional[str] # ── Helpers ─────────────────────────────────────────────────────────────────── def po_to_out(po: PurchaseOrder) -> PurchaseOrderOut: return PurchaseOrderOut( id=po.id, kitchen_id=po.kitchen_id, supplier_id=po.supplier_id, supplier_name=po.supplier.name if po.supplier else None, order_date=po.order_date, order_type=po.order_type, status=po.status, total_amount=po.total_amount, order_reference=po.order_reference, notes=po.notes, attachment_path=po.attachment_path, attachment_original_name=po.attachment_original_name, linked_invoice_id=po.linked_invoice_id, created_by=po.created_by, created_by_name=po.created_by_user.name if po.created_by_user else None, created_at=po.created_at.isoformat() if po.created_at else "", updated_at=po.updated_at.isoformat() if po.updated_at else "", line_items=[ LineItemOut( id=li.id, product_id=li.product_id, product_code=li.product_code, description=li.description, unit=li.unit, unit_price=li.unit_price, quantity=li.quantity, total=li.total, line_number=li.line_number, source=li.source, ) for li in (po.line_items or []) ], ) def _calc_itemised_total(items: list[LineItemIn]) -> Decimal: return sum((i.total for i in items), Decimal("0")) async def _load_po(db: AsyncSession, po_id: int, kitchen_id: int) -> PurchaseOrder: result = await db.execute( select(PurchaseOrder) .where( PurchaseOrder.id == po_id, PurchaseOrder.kitchen_id == kitchen_id, ) .options( selectinload(PurchaseOrder.line_items), selectinload(PurchaseOrder.supplier), selectinload(PurchaseOrder.created_by_user), ) ) po = result.scalar_one_or_none() if not po: raise HTTPException(status_code=404, detail="Purchase order not found") return po # ── CRUD Endpoints ──────────────────────────────────────────────────────────── @router.post("/", response_model=PurchaseOrderOut) async def create_purchase_order( data: PurchaseOrderCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # Validate supplier belongs to this kitchen sup = await db.execute( select(Supplier).where( Supplier.id == data.supplier_id, Supplier.kitchen_id == current_user.kitchen_id, ) ) if not sup.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Invalid supplier") total = data.total_amount if data.order_type == "single_value" else _calc_itemised_total(data.line_items) po = PurchaseOrder( kitchen_id=current_user.kitchen_id, supplier_id=data.supplier_id, order_date=data.order_date, order_type=data.order_type, status=data.status if data.status in ("DRAFT", "PENDING") else "DRAFT", total_amount=total, order_reference=data.order_reference, notes=data.notes, created_by=current_user.id, updated_by=current_user.id, ) db.add(po) await db.flush() for idx, li in enumerate(data.line_items): db.add(PurchaseOrderLineItem( purchase_order_id=po.id, kitchen_id=current_user.kitchen_id, product_id=li.product_id, product_code=li.product_code, description=li.description, unit=li.unit, unit_price=li.unit_price, quantity=li.quantity, total=li.total, line_number=li.line_number or idx, source=li.source, )) await db.commit() return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) @router.get("/", response_model=list[PurchaseOrderOut]) async def list_purchase_orders( status: Optional[str] = None, supplier_id: Optional[int] = None, date_from: Optional[date] = None, date_to: Optional[date] = None, limit: int = 100, offset: int = 0, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): q = ( select(PurchaseOrder) .where(PurchaseOrder.kitchen_id == current_user.kitchen_id) .options( selectinload(PurchaseOrder.line_items), selectinload(PurchaseOrder.supplier), selectinload(PurchaseOrder.created_by_user), ) .order_by(PurchaseOrder.order_date.desc(), PurchaseOrder.id.desc()) ) if status: statuses = [s.strip().upper() for s in status.split(",")] q = q.where(PurchaseOrder.status.in_(statuses)) if supplier_id: q = q.where(PurchaseOrder.supplier_id == supplier_id) if date_from: q = q.where(PurchaseOrder.order_date >= date_from) if date_to: q = q.where(PurchaseOrder.order_date <= date_to) q = q.offset(offset).limit(limit) result = await db.execute(q) return [po_to_out(po) for po in result.scalars().all()] @router.get("/products/search") async def search_products_for_po( query: str, supplier_id: Optional[int] = None, limit: int = 20, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Search products from invoice line items, optionally filtered by supplier.""" q = ( select( LineItem.description, LineItem.product_code, LineItem.unit, LineItem.unit_price, Supplier.name.label("supplier_name"), Invoice.supplier_id.label("sup_id"), ) .join(Invoice, LineItem.invoice_id == Invoice.id) .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) .where( and_( Invoice.kitchen_id == current_user.kitchen_id, or_( LineItem.description.ilike(f"%{query}%"), LineItem.product_code.ilike(f"%{query}%"), ), ) ) .order_by(Invoice.invoice_date.desc()) ) if supplier_id: q = q.where(Invoice.supplier_id == supplier_id) q = q.limit(limit * 3) result = await db.execute(q) rows = result.all() seen: set[str] = set() products = [] for row in rows: key = (row.description or "").lower() if key and key not in seen: seen.add(key) products.append({ "id": 0, "name": row.description, "product_code": row.product_code, "supplier_name": row.supplier_name, "unit": row.unit, "last_price": float(row.unit_price) if row.unit_price else None, }) if len(products) >= limit: break return products @router.get("/by-date") async def get_pos_by_date( week_start: date, week_end: date, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """POs grouped by supplier_id → date for budget table integration.""" result = await db.execute( select(PurchaseOrder) .where( PurchaseOrder.kitchen_id == current_user.kitchen_id, PurchaseOrder.status.in_(["DRAFT", "PENDING"]), PurchaseOrder.order_date >= week_start, PurchaseOrder.order_date <= week_end, ) .options(selectinload(PurchaseOrder.line_items)) ) pos = result.scalars().all() grouped: dict[int, dict[str, list]] = {} for po in pos: sid = po.supplier_id ds = po.order_date.isoformat() if sid not in grouped: grouped[sid] = {} if ds not in grouped[sid]: grouped[sid][ds] = [] grouped[sid][ds].append(BudgetPO( id=po.id, order_type=po.order_type, status=po.status, total_amount=float(po.total_amount) if po.total_amount else None, order_reference=po.order_reference, ).model_dump()) return grouped @router.get("/{po_id}", response_model=PurchaseOrderOut) async def get_purchase_order( po_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return po_to_out(await _load_po(db, po_id, current_user.kitchen_id)) @router.put("/{po_id}", response_model=PurchaseOrderOut) async def update_purchase_order( po_id: int, data: PurchaseOrderUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): po = await _load_po(db, po_id, current_user.kitchen_id) if po.status in ("LINKED", "CLOSED", "CANCELLED"): raise HTTPException(status_code=400, detail=f"Cannot edit PO with status {po.status}") if data.supplier_id is not None: po.supplier_id = data.supplier_id if data.order_date is not None: po.order_date = data.order_date if data.order_type is not None: po.order_type = data.order_type if data.order_reference is not None: po.order_reference = data.order_reference if data.notes is not None: po.notes = data.notes if data.status is not None and data.status in ("DRAFT", "PENDING"): po.status = data.status # Replace line items if provided if data.line_items is not None: await db.execute( delete(PurchaseOrderLineItem).where( PurchaseOrderLineItem.purchase_order_id == po.id ) ) for idx, li in enumerate(data.line_items): db.add(PurchaseOrderLineItem( purchase_order_id=po.id, kitchen_id=current_user.kitchen_id, product_id=li.product_id, product_code=li.product_code, description=li.description, unit=li.unit, unit_price=li.unit_price, quantity=li.quantity, total=li.total, line_number=li.line_number or idx, source=li.source, )) # Recalculate total if po.order_type == "single_value": if data.total_amount is not None: po.total_amount = data.total_amount else: items = data.line_items if data.line_items is not None else [] po.total_amount = _calc_itemised_total(items) if items else po.total_amount po.updated_by = current_user.id await db.commit() return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) @router.delete("/{po_id}") async def delete_purchase_order( po_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): po = await _load_po(db, po_id, current_user.kitchen_id) allowed_statuses = ("DRAFT", "CANCELLED", "PENDING") if current_user.is_admin else ("DRAFT", "CANCELLED") if po.status not in allowed_statuses: raise HTTPException(status_code=400, detail="Only DRAFT or CANCELLED POs can be deleted") await db.delete(po) await db.commit() return {"ok": True} @router.put("/{po_id}/status", response_model=PurchaseOrderOut) async def update_po_status( po_id: int, data: StatusUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): po = await _load_po(db, po_id, current_user.kitchen_id) allowed = { "DRAFT": ["PENDING", "CANCELLED"], "PENDING": ["DRAFT", "CLOSED", "CANCELLED"], "LINKED": ["CLOSED"], "CLOSED": [], "CANCELLED": ["DRAFT"], } if data.status not in allowed.get(po.status, []): raise HTTPException( status_code=400, detail=f"Cannot change status from {po.status} to {data.status}", ) po.status = data.status po.updated_by = current_user.id await db.commit() return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) # ── Attachment ──────────────────────────────────────────────────────────────── @router.post("/{po_id}/attachment", response_model=PurchaseOrderOut) async def upload_attachment( po_id: int, file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): po = await _load_po(db, po_id, current_user.kitchen_id) os.makedirs(UPLOAD_DIR, exist_ok=True) ext = os.path.splitext(file.filename or "file")[1] filename = f"{uuid.uuid4().hex}{ext}" filepath = os.path.join(UPLOAD_DIR, filename) contents = await file.read() with open(filepath, "wb") as f: f.write(contents) po.attachment_path = filepath po.attachment_original_name = file.filename po.updated_by = current_user.id await db.commit() return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) @router.delete("/{po_id}/attachment", response_model=PurchaseOrderOut) async def remove_attachment( po_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): po = await _load_po(db, po_id, current_user.kitchen_id) if po.attachment_path and os.path.exists(po.attachment_path): os.remove(po.attachment_path) po.attachment_path = None po.attachment_original_name = None po.updated_by = current_user.id await db.commit() return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) # ── Preview & Email ────────────────────────────────────────────────────────── def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str = "£") -> str: """Generate a clean HTML page for PO preview / email body.""" supplier = po.supplier esc = html_escape # Kitchen letterhead kitchen_name = esc(kitchen.kitchen_display_name or "") addr_parts = [ kitchen.kitchen_address_line1, kitchen.kitchen_address_line2, kitchen.kitchen_city, kitchen.kitchen_postcode, ] addr_html = "
".join(esc(p) for p in addr_parts if p) kitchen_phone = esc(kitchen.kitchen_phone or "") kitchen_email = esc(kitchen.kitchen_email or "") # Supplier details supplier_name = esc(supplier.name) if supplier else "Unknown" account_number = esc(supplier.account_number or "") if supplier else "" # PO metadata po_number = f"PO-{po.id}" order_date = po.order_date.strftime("%d/%m/%Y") if po.order_date else "" notes = esc(po.notes or "").replace("\n", "
") if po.notes else "" # Line items table items_html = "" if po.order_type == "itemised" and po.line_items: rows = "" for li in sorted(po.line_items, key=lambda x: x.line_number): rows += f""" {esc(li.product_code or "")} {esc(li.description or "")} {esc(li.unit or "")} {currency}{li.unit_price:.2f} {li.quantity:g} {currency}{li.total:.2f} """ items_html = f""" {rows}
Code Description Unit Price Qty Total
""" elif po.order_type == "single_value": items_html = f"""
Order Value: {currency}{float(po.total_amount or 0):.2f} {f'
Order Ref: {esc(po.order_reference)}' if po.order_reference else ''}
""" total_amount = float(po.total_amount or 0) return f""" Purchase Order {po_number}

{kitchen_name}

{addr_html}
{f'
Tel: {kitchen_phone}
' if kitchen_phone else ''} {f'
{kitchen_email}
' if kitchen_email else ''}

PURCHASE ORDER

{po_number}
Supplier
{supplier_name}
{f'
Account: {account_number}
' if account_number else ''}
Date
{order_date}
Status: {po.status}
{items_html}
Total: {currency}{total_amount:.2f}
{f'
Notes:
{notes}
' if notes else ''}
""" @router.get("/{po_id}/preview") async def preview_purchase_order( po_id: int, token: Optional[str] = None, db: AsyncSession = Depends(get_db), ): """Return a print-friendly HTML preview of the purchase order (query-param auth).""" if not token: raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token") current_user = await get_current_user_from_token(token, db) if not current_user: raise HTTPException(status_code=401, detail="Not authenticated") po = await _load_po(db, po_id, current_user.kitchen_id) # Load kitchen settings for letterhead settings_result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) kitchen = settings_result.scalar_one_or_none() if not kitchen: kitchen = KitchenSettings(kitchen_id=current_user.kitchen_id) currency = kitchen.currency_symbol or "£" html = _build_po_html(po, kitchen, currency) return HTMLResponse(content=html) @router.post("/{po_id}/send-email") async def send_po_email( po_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Email the PO to the supplier's order_email address.""" po = await _load_po(db, po_id, current_user.kitchen_id) # Validate supplier has an order email if not po.supplier or not po.supplier.order_email: raise HTTPException(status_code=400, detail="Supplier does not have an order email address configured") # Load kitchen settings for SMTP + letterhead settings_result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) kitchen = settings_result.scalar_one_or_none() if not kitchen or not kitchen.smtp_host or not kitchen.smtp_from_email: raise HTTPException(status_code=400, detail="SMTP email is not configured in Settings") currency = kitchen.currency_symbol or "£" html = _build_po_html(po, kitchen, currency) po_number = f"PO-{po.id}" kitchen_name = kitchen.kitchen_display_name or "Kitchen" subject = f"Purchase Order {po_number} from {kitchen_name}" email_service = EmailService(kitchen) success = email_service.send_email( to_email=po.supplier.order_email, subject=subject, html_body=html, plain_body=f"Please find attached Purchase Order {po_number}. Total: {currency}{float(po.total_amount or 0):.2f}", ) if not success: raise HTTPException(status_code=500, detail="Failed to send email. Check SMTP settings.") # Update status to PENDING if currently DRAFT if po.status == "DRAFT": po.status = "PENDING" po.updated_by = current_user.id await db.commit() return {"ok": True, "message": f"PO emailed to {po.supplier.order_email}", "new_status": po.status} # ── Invoice Matching ───────────────────────────────────────────────────────── class LinkRequest(BaseModel): invoice_id: int @router.get("/matching/for-invoice") async def get_matching_pos( invoice_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Find pending POs that match a given invoice (by supplier).""" from services.po_matching import find_matching_pos # Load the invoice inv_result = await db.execute( select(Invoice).where( Invoice.id == invoice_id, Invoice.kitchen_id == current_user.kitchen_id, ) ) invoice = inv_result.scalar_one_or_none() if not invoice: raise HTTPException(status_code=404, detail="Invoice not found") if not invoice.supplier_id: return {"matches": [], "linked_po": None} # Check if invoice already has a linked PO linked_result = await db.execute( select(PurchaseOrder) .where( PurchaseOrder.kitchen_id == current_user.kitchen_id, PurchaseOrder.linked_invoice_id == invoice_id, PurchaseOrder.status == "LINKED", ) .options( selectinload(PurchaseOrder.supplier), selectinload(PurchaseOrder.line_items), ) ) linked_po = linked_result.scalar_one_or_none() if linked_po: return { "matches": [], "linked_po": { "po_id": linked_po.id, "order_date": linked_po.order_date.isoformat() if linked_po.order_date else None, "total_amount": float(linked_po.total_amount) if linked_po.total_amount else None, "order_reference": linked_po.order_reference, "status": linked_po.status, "order_type": linked_po.order_type, }, } # Find matching POs matches = await find_matching_pos( db, kitchen_id=current_user.kitchen_id, supplier_id=invoice.supplier_id, invoice_date=invoice.invoice_date, invoice_total=invoice.total, ) return {"matches": matches, "linked_po": None} @router.post("/{po_id}/link", response_model=PurchaseOrderOut) async def link_po_to_invoice( po_id: int, data: LinkRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Link a PO to an invoice. Sets PO status to LINKED.""" from services.po_matching import link_po_to_invoice as do_link po = await do_link(db, po_id, data.invoice_id, current_user.kitchen_id, current_user.id) if not po: raise HTTPException(status_code=404, detail="PO or invoice not found") return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) @router.post("/{po_id}/unlink", response_model=PurchaseOrderOut) async def unlink_po_from_invoice( po_id: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Unlink a PO from its invoice. Returns PO to PENDING status.""" from services.po_matching import unlink_po as do_unlink po = await do_unlink(db, po_id, current_user.kitchen_id, current_user.id) if not po: raise HTTPException(status_code=404, detail="PO not found") return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))