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>
153 lines
4.9 KiB
Python
153 lines
4.9 KiB
Python
"""
|
|
Purchase Order matching service — finds pending POs for an invoice,
|
|
scores match confidence, and handles link/unlink operations.
|
|
"""
|
|
from datetime import timedelta
|
|
from decimal import Decimal
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from models.purchase_order import PurchaseOrder
|
|
from models.invoice import Invoice
|
|
|
|
|
|
async def find_matching_pos(
|
|
db: AsyncSession,
|
|
kitchen_id: int,
|
|
supplier_id: int,
|
|
invoice_date=None,
|
|
invoice_total=None,
|
|
) -> list[dict]:
|
|
"""Find pending POs for a supplier, ordered by match confidence."""
|
|
q = (
|
|
select(PurchaseOrder)
|
|
.where(
|
|
PurchaseOrder.kitchen_id == kitchen_id,
|
|
PurchaseOrder.supplier_id == supplier_id,
|
|
PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
|
|
)
|
|
.options(selectinload(PurchaseOrder.line_items))
|
|
.order_by(PurchaseOrder.order_date.desc())
|
|
)
|
|
result = await db.execute(q)
|
|
pos = result.scalars().all()
|
|
|
|
matches = []
|
|
for po in pos:
|
|
confidence = calculate_match_confidence(po, invoice_date, invoice_total)
|
|
matches.append({
|
|
"po_id": po.id,
|
|
"order_date": po.order_date.isoformat() if po.order_date else None,
|
|
"total_amount": float(po.total_amount) if po.total_amount else None,
|
|
"order_reference": po.order_reference,
|
|
"status": po.status,
|
|
"order_type": po.order_type,
|
|
"confidence": round(confidence, 2),
|
|
})
|
|
|
|
# Sort by confidence descending
|
|
matches.sort(key=lambda m: m["confidence"], reverse=True)
|
|
return matches
|
|
|
|
|
|
def calculate_match_confidence(po: PurchaseOrder, invoice_date=None, invoice_total=None) -> float:
|
|
"""Score 0-1: supplier already matched (+0.4), date proximity (+0.3), amount similarity (+0.3)."""
|
|
score = 0.0
|
|
|
|
# Supplier match is guaranteed since we filter by supplier_id — grant base score
|
|
score += 0.4
|
|
|
|
# Date proximity: full marks if same day, degrades over 7 days
|
|
if invoice_date and po.order_date:
|
|
try:
|
|
from datetime import date as date_type
|
|
if isinstance(invoice_date, str):
|
|
inv_date = date_type.fromisoformat(invoice_date)
|
|
else:
|
|
inv_date = invoice_date
|
|
days_apart = abs((inv_date - po.order_date).days)
|
|
if days_apart <= 7:
|
|
score += 0.3 * (1 - days_apart / 7)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Amount similarity: full marks if within 5%, degrades to 0 at 50% difference
|
|
if invoice_total is not None and po.total_amount:
|
|
try:
|
|
inv_total = float(invoice_total)
|
|
po_total = float(po.total_amount)
|
|
if po_total > 0:
|
|
pct_diff = abs(inv_total - po_total) / po_total
|
|
if pct_diff <= 0.05:
|
|
score += 0.3
|
|
elif pct_diff < 0.5:
|
|
score += 0.3 * (1 - pct_diff / 0.5)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
return score
|
|
|
|
|
|
async def link_po_to_invoice(
|
|
db: AsyncSession, po_id: int, invoice_id: int, kitchen_id: int, user_id: int
|
|
) -> PurchaseOrder:
|
|
"""Set PO status=LINKED, linked_invoice_id=invoice_id."""
|
|
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:
|
|
return None
|
|
|
|
# Verify invoice exists
|
|
inv_result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == kitchen_id,
|
|
)
|
|
)
|
|
if not inv_result.scalar_one_or_none():
|
|
return None
|
|
|
|
po.status = "LINKED"
|
|
po.linked_invoice_id = invoice_id
|
|
po.updated_by = user_id
|
|
await db.commit()
|
|
return po
|
|
|
|
|
|
async def unlink_po(
|
|
db: AsyncSession, po_id: int, kitchen_id: int, user_id: int
|
|
) -> PurchaseOrder:
|
|
"""Reset PO status=PENDING, linked_invoice_id=None."""
|
|
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:
|
|
return None
|
|
|
|
po.status = "PENDING"
|
|
po.linked_invoice_id = None
|
|
po.updated_by = user_id
|
|
await db.commit()
|
|
return po
|