kitchen/backend/services/duplicate_detector.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

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"