Add .gitignore, remove __pycache__ from tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8d688b459d
commit
c8fad1cf36
181 changed files with 8 additions and 29855 deletions
|
|
@ -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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue