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
214
backend/services/duplicate_detector.py
Normal file
214
backend/services/duplicate_detector.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from models.invoice import Invoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DuplicateDetector:
|
||||
"""Service for detecting duplicate and related invoices."""
|
||||
|
||||
# Configuration thresholds
|
||||
DATE_TOLERANCE_DAYS = 3
|
||||
AMOUNT_TOLERANCE_PERCENT = 5.0
|
||||
|
||||
def __init__(self, db: AsyncSession, kitchen_id: int):
|
||||
self.db = db
|
||||
self.kitchen_id = kitchen_id
|
||||
|
||||
async def check_duplicates(self, invoice: Invoice) -> dict:
|
||||
"""
|
||||
Check for duplicates of the given invoice.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"firm_duplicate": Invoice or None,
|
||||
"possible_duplicates": list[Invoice],
|
||||
"related_documents": list[Invoice]
|
||||
}
|
||||
"""
|
||||
logger.info(
|
||||
f"Checking duplicates for invoice {invoice.id}: "
|
||||
f"invoice_number={invoice.invoice_number}, "
|
||||
f"supplier_id={invoice.supplier_id}, "
|
||||
f"date={invoice.invoice_date}, "
|
||||
f"total={invoice.total}"
|
||||
)
|
||||
|
||||
result = {
|
||||
"firm_duplicate": None,
|
||||
"possible_duplicates": [],
|
||||
"related_documents": []
|
||||
}
|
||||
|
||||
# 1. FIRM DUPLICATE: Same invoice_number (with same supplier if available)
|
||||
if invoice.invoice_number:
|
||||
firm = await self._find_firm_duplicate(invoice)
|
||||
if firm:
|
||||
logger.info(f"Found firm duplicate: invoice {firm.id} (number={firm.invoice_number})")
|
||||
result["firm_duplicate"] = firm
|
||||
else:
|
||||
logger.info(f"No firm duplicate found for invoice_number={invoice.invoice_number}")
|
||||
|
||||
# 2. FUZZY/POSSIBLE DUPLICATE: Similar date + similar total (same supplier if available)
|
||||
if invoice.invoice_date and invoice.total:
|
||||
possible = await self._find_fuzzy_duplicates(invoice)
|
||||
result["possible_duplicates"] = possible
|
||||
|
||||
# 3. RELATED DOCUMENTS: Cross-match by order_number
|
||||
if invoice.order_number:
|
||||
related = await self._find_related_documents(invoice)
|
||||
result["related_documents"] = related
|
||||
|
||||
return result
|
||||
|
||||
async def _find_firm_duplicate(self, invoice: Invoice) -> Optional[Invoice]:
|
||||
"""Find exact match by invoice_number (same supplier if available)"""
|
||||
# Build conditions
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
Invoice.invoice_number == invoice.invoice_number,
|
||||
Invoice.id != invoice.id
|
||||
]
|
||||
|
||||
# If we have supplier_id, require same supplier for firm match
|
||||
# If no supplier_id, just match by invoice_number alone
|
||||
if invoice.supplier_id:
|
||||
conditions.append(Invoice.supplier_id == invoice.supplier_id)
|
||||
logger.debug(f"Searching for firm duplicate: invoice_number={invoice.invoice_number}, supplier_id={invoice.supplier_id}")
|
||||
else:
|
||||
logger.debug(f"Searching for firm duplicate (no supplier): invoice_number={invoice.invoice_number}")
|
||||
|
||||
# Order by ID to get the oldest duplicate first, and use first() instead
|
||||
# of scalar_one_or_none() since there may be multiple duplicates
|
||||
query = select(Invoice).where(and_(*conditions)).order_by(Invoice.id)
|
||||
result = await self.db.execute(query)
|
||||
found = result.scalars().first()
|
||||
|
||||
if not found:
|
||||
# Log what invoices exist with this number for debugging
|
||||
all_with_number = await self.db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
Invoice.invoice_number == invoice.invoice_number
|
||||
)
|
||||
)
|
||||
all_matches = list(all_with_number.scalars().all())
|
||||
logger.debug(f"All invoices with number {invoice.invoice_number}: {[(i.id, i.supplier_id) for i in all_matches]}")
|
||||
|
||||
return found
|
||||
|
||||
async def _find_fuzzy_duplicates(self, invoice: Invoice) -> list[Invoice]:
|
||||
"""Find similar invoices: close date + close total (same supplier if available)"""
|
||||
date_min = invoice.invoice_date - timedelta(days=self.DATE_TOLERANCE_DAYS)
|
||||
date_max = invoice.invoice_date + timedelta(days=self.DATE_TOLERANCE_DAYS)
|
||||
|
||||
# Calculate amount tolerance
|
||||
amount_tolerance = invoice.total * Decimal(str(self.AMOUNT_TOLERANCE_PERCENT / 100))
|
||||
amount_min = invoice.total - amount_tolerance
|
||||
amount_max = invoice.total + amount_tolerance
|
||||
|
||||
# Build conditions
|
||||
conditions = [
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
Invoice.id != invoice.id,
|
||||
Invoice.invoice_date.between(date_min, date_max),
|
||||
Invoice.total.between(amount_min, amount_max),
|
||||
# Exclude if it's the same invoice_number (already caught by firm)
|
||||
or_(
|
||||
Invoice.invoice_number == None,
|
||||
Invoice.invoice_number != invoice.invoice_number
|
||||
)
|
||||
]
|
||||
|
||||
# If we have supplier_id, require same supplier for fuzzy match
|
||||
if invoice.supplier_id:
|
||||
conditions.append(Invoice.supplier_id == invoice.supplier_id)
|
||||
|
||||
query = select(Invoice).where(and_(*conditions))
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _find_related_documents(self, invoice: Invoice) -> list[Invoice]:
|
||||
"""Find documents with same order_number but different invoice_number"""
|
||||
query = select(Invoice).where(
|
||||
and_(
|
||||
Invoice.kitchen_id == self.kitchen_id,
|
||||
Invoice.order_number == invoice.order_number,
|
||||
Invoice.id != invoice.id,
|
||||
# Must have different invoice_number to be related (not duplicate)
|
||||
or_(
|
||||
Invoice.invoice_number == None,
|
||||
Invoice.invoice_number != invoice.invoice_number
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def detect_document_type(raw_text: str, fields: dict) -> str:
|
||||
"""
|
||||
Detect if document is Invoice, Credit Note, or Delivery Note based on OCR text.
|
||||
|
||||
Args:
|
||||
raw_text: Full OCR text
|
||||
fields: Extracted fields dict from Azure
|
||||
|
||||
Returns:
|
||||
"invoice", "credit_note", or "delivery_note"
|
||||
"""
|
||||
if not raw_text:
|
||||
return "invoice"
|
||||
|
||||
text_upper = raw_text.upper()
|
||||
|
||||
# Check for credit note FIRST (highest priority)
|
||||
credit_keywords = [
|
||||
"CREDIT NOTE", "CREDIT MEMO", "CR NOTE", "C/N",
|
||||
"CREDIT INVOICE", "CN NO", "CN:", "REFUND"
|
||||
]
|
||||
|
||||
# Check if invoice number contains credit note indicator
|
||||
invoice_number = fields.get("invoice_number")
|
||||
if invoice_number:
|
||||
inv_num_upper = str(invoice_number).upper()
|
||||
if any(kw in inv_num_upper for kw in ["CREDIT", "CR NOTE", "CN", "C/N"]):
|
||||
return "credit_note"
|
||||
|
||||
# Check for credit note keywords in text
|
||||
if any(kw in text_upper for kw in credit_keywords):
|
||||
return "credit_note"
|
||||
|
||||
# Check for negative total (strong indicator of credit note)
|
||||
total = fields.get("total")
|
||||
net_total = fields.get("net_total")
|
||||
if (total is not None and total < 0) or (net_total is not None and net_total < 0):
|
||||
return "credit_note"
|
||||
|
||||
# Keywords suggesting delivery note
|
||||
dn_keywords = [
|
||||
"DELIVERY NOTE", "DELIVERY DOCKET", "DISPATCH NOTE",
|
||||
"DELIVERY ADVICE", "PACKING SLIP", "PACKING LIST",
|
||||
"DN NO", "DN:", "D/N"
|
||||
]
|
||||
|
||||
# Keywords suggesting invoice
|
||||
inv_keywords = [
|
||||
"TAX INVOICE", "VAT INVOICE", "INVOICE NO",
|
||||
"INVOICE DATE", "INVOICE TOTAL", "AMOUNT DUE",
|
||||
"PAYMENT DUE", "BALANCE DUE"
|
||||
]
|
||||
|
||||
dn_score = sum(1 for kw in dn_keywords if kw in text_upper)
|
||||
inv_score = sum(1 for kw in inv_keywords if kw in text_upper)
|
||||
|
||||
# Also check if there's no total amount (delivery notes often don't have)
|
||||
if not fields.get("total"):
|
||||
dn_score += 1
|
||||
|
||||
return "delivery_note" if dn_score > inv_score else "invoice"
|
||||
Loading…
Add table
Add a link
Reference in a new issue