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>
214 lines
8 KiB
Python
214 lines
8 KiB
Python
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"
|