"""
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, Request
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"""
| Code | Description | Unit | Price | Qty | Total |
|---|