Add .gitignore, remove __pycache__ from tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:16:04 +00:00
parent 8d688b459d
commit c8fad1cf36
181 changed files with 8 additions and 29855 deletions

View file

@ -1,835 +0,0 @@
"""
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))