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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
153
backend/services/po_matching.py
Normal file
153
backend/services/po_matching.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue