kitchen/backend/services/po_matching.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
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>
2026-07-12 12:15:39 +00:00

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