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>
835 lines
30 KiB
Python
835 lines
30 KiB
Python
"""
|
|
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 = "<br>".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", "<br>") 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"""<tr>
|
|
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.product_code or "")}</td>
|
|
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.description or "")}</td>
|
|
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.unit or "")}</td>
|
|
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{currency}{li.unit_price:.2f}</td>
|
|
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{li.quantity:g}</td>
|
|
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{currency}{li.total:.2f}</td>
|
|
</tr>"""
|
|
items_html = f"""
|
|
<table style="width:100%;border-collapse:collapse;margin-top:20px;font-size:14px;">
|
|
<thead>
|
|
<tr style="background:#f5f5f5;">
|
|
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Code</th>
|
|
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Description</th>
|
|
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Unit</th>
|
|
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Price</th>
|
|
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Qty</th>
|
|
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{rows}</tbody>
|
|
</table>"""
|
|
elif po.order_type == "single_value":
|
|
items_html = f"""
|
|
<div style="margin-top:20px;padding:12px;background:#f9f9f9;border-radius:6px;">
|
|
<strong>Order Value:</strong> {currency}{float(po.total_amount or 0):.2f}
|
|
{f'<br><strong>Order Ref:</strong> {esc(po.order_reference)}' if po.order_reference else ''}
|
|
</div>"""
|
|
|
|
total_amount = float(po.total_amount or 0)
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Purchase Order {po_number}</title>
|
|
<style>
|
|
body {{ font-family: Arial, Helvetica, sans-serif; color: #333; margin: 0; padding: 0; }}
|
|
.page {{ max-width: 800px; margin: 20px auto; padding: 40px; }}
|
|
@media print {{
|
|
body {{ margin: 0; }}
|
|
.page {{ max-width: 100%; margin: 0; padding: 20px; }}
|
|
.no-print {{ display: none !important; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<!-- Letterhead -->
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:30px;padding-bottom:20px;border-bottom:3px solid #1a1a2e;">
|
|
<div>
|
|
<h1 style="margin:0;color:#1a1a2e;font-size:22px;">{kitchen_name}</h1>
|
|
<div style="margin-top:6px;font-size:13px;color:#666;line-height:1.5;">{addr_html}</div>
|
|
{f'<div style="margin-top:4px;font-size:13px;color:#666;">Tel: {kitchen_phone}</div>' if kitchen_phone else ''}
|
|
{f'<div style="font-size:13px;color:#666;">{kitchen_email}</div>' if kitchen_email else ''}
|
|
</div>
|
|
<div style="text-align:right;">
|
|
<h2 style="margin:0;color:#1a1a2e;font-size:24px;">PURCHASE ORDER</h2>
|
|
<div style="margin-top:8px;font-size:16px;font-weight:bold;color:#555;">{po_number}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PO Details -->
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:20px;">
|
|
<div>
|
|
<div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;">Supplier</div>
|
|
<div style="font-size:16px;font-weight:bold;margin-top:4px;">{supplier_name}</div>
|
|
{f'<div style="font-size:13px;color:#666;margin-top:2px;">Account: {account_number}</div>' if account_number else ''}
|
|
</div>
|
|
<div style="text-align:right;">
|
|
<div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;">Date</div>
|
|
<div style="font-size:16px;font-weight:bold;margin-top:4px;">{order_date}</div>
|
|
<div style="font-size:13px;color:#666;margin-top:2px;">Status: {po.status}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Items -->
|
|
{items_html}
|
|
|
|
<!-- Total -->
|
|
<div style="text-align:right;margin-top:16px;padding:12px 10px;border-top:2px solid #1a1a2e;font-size:18px;">
|
|
<strong>Total: {currency}{total_amount:.2f}</strong>
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
{f'<div style="margin-top:20px;padding:12px;background:#fffef0;border-left:3px solid #e6c200;border-radius:4px;font-size:13px;"><strong>Notes:</strong><br>{notes}</div>' if notes else ''}
|
|
|
|
<!-- Print button (hidden on print) -->
|
|
<div class="no-print" style="margin-top:30px;text-align:center;">
|
|
<button onclick="window.print()" style="padding:10px 24px;background:#1a1a2e;color:white;border:none;border-radius:6px;font-size:14px;cursor:pointer;">
|
|
Print / Save PDF
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
@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))
|