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
172
backend/ocr/extractor.py
Normal file
172
backend/ocr/extractor.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from .parser import identify_supplier
|
||||
from .azure_extractor import process_invoice_with_azure
|
||||
from services.duplicate_detector import detect_document_type
|
||||
from services.pdf_rotation import rotate_pdf_pages
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_invoice_image(
|
||||
image_path: str,
|
||||
kitchen_id: int,
|
||||
db: AsyncSession
|
||||
) -> dict:
|
||||
"""
|
||||
Process an invoice image through Azure OCR and extract relevant fields.
|
||||
|
||||
Args:
|
||||
image_path: Path to the invoice image
|
||||
kitchen_id: ID of the kitchen for supplier matching
|
||||
db: Database session for supplier lookup
|
||||
|
||||
Returns:
|
||||
dict with extracted fields:
|
||||
- invoice_number: str or None
|
||||
- invoice_date: date or None
|
||||
- total: Decimal or None
|
||||
- supplier_id: int or None
|
||||
- supplier_match_type: str ("exact", "fuzzy") or None
|
||||
- vendor_name: str or None
|
||||
- order_number: str or None
|
||||
- document_type: str ("invoice" or "delivery_note")
|
||||
- line_items: list
|
||||
- raw_text: str
|
||||
- raw_json: dict (serialized Azure response)
|
||||
- confidence: float
|
||||
"""
|
||||
# Check kitchen settings for Azure credentials
|
||||
from models.settings import KitchenSettings
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
# Require Azure configuration
|
||||
if not settings or not settings.azure_endpoint or not settings.azure_key:
|
||||
logger.error("Azure credentials not configured for kitchen")
|
||||
return {
|
||||
"invoice_number": None,
|
||||
"invoice_date": None,
|
||||
"total": None,
|
||||
"net_total": None,
|
||||
"supplier_id": None,
|
||||
"supplier_match_type": None,
|
||||
"vendor_name": None,
|
||||
"order_number": None,
|
||||
"document_type": "invoice",
|
||||
"line_items": [],
|
||||
"raw_text": "Error: Azure credentials not configured. Please configure in Settings.",
|
||||
"raw_json": None,
|
||||
"confidence": 0.0
|
||||
}
|
||||
|
||||
logger.info("Using Azure Document Intelligence for OCR")
|
||||
|
||||
try:
|
||||
result = await process_invoice_with_azure(
|
||||
image_path,
|
||||
settings.azure_endpoint,
|
||||
settings.azure_key,
|
||||
clean_product_codes=settings.ocr_clean_product_codes,
|
||||
filter_subtotal_rows=settings.ocr_filter_subtotal_rows,
|
||||
use_weight_as_quantity=settings.ocr_use_weight_as_quantity
|
||||
)
|
||||
|
||||
# FIRST post-processing step: Rotate PDF pages with non-zero angles
|
||||
# This must happen BEFORE any other processing extracts data from raw_json
|
||||
# because coordinates need to be transformed to match the corrected orientation
|
||||
if image_path.lower().endswith('.pdf') and result.get('raw_json'):
|
||||
try:
|
||||
modified, updated_json = rotate_pdf_pages(image_path, result['raw_json'])
|
||||
if modified:
|
||||
result['raw_json'] = updated_json
|
||||
logger.info(f"PDF pages rotated and coordinates transformed for {image_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"PDF rotation failed (non-fatal): {e}")
|
||||
|
||||
# Try to identify/match supplier from vendor name
|
||||
supplier_id = None
|
||||
supplier_match_type = None
|
||||
if result.get("vendor_name"):
|
||||
supplier_id, supplier_match_type = await identify_supplier(result["vendor_name"], kitchen_id, db)
|
||||
if not supplier_id and result.get("raw_text"):
|
||||
supplier_id, supplier_match_type = await identify_supplier(result["raw_text"], kitchen_id, db)
|
||||
|
||||
# Use document_type from azure_extractor (already detected there)
|
||||
# Fall back to detect_document_type only if not provided
|
||||
document_type = result.get("document_type")
|
||||
if not document_type:
|
||||
document_type = detect_document_type(
|
||||
result.get("raw_text", ""),
|
||||
result
|
||||
)
|
||||
|
||||
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
||||
# Feature E: LLM fallback when Azure returns null for header fields
|
||||
invoice_number = result.get("invoice_number")
|
||||
invoice_date = result.get("invoice_date")
|
||||
total = result.get("total")
|
||||
any_null = invoice_number is None or invoice_date is None or total is None
|
||||
|
||||
if any_null and result.get("raw_text"):
|
||||
try:
|
||||
from services.llm_service import extract_invoice_fields_llm
|
||||
llm_result = await extract_invoice_fields_llm(
|
||||
db=db,
|
||||
kitchen_id=kitchen_id,
|
||||
raw_text=result["raw_text"],
|
||||
)
|
||||
if llm_result["status"] in ("success", "cached") and llm_result.get("fields"):
|
||||
fields = llm_result["fields"]
|
||||
if invoice_number is None and fields.get("invoice_number"):
|
||||
invoice_number = fields["invoice_number"]
|
||||
logger.info(f"LLM extracted invoice_number: {invoice_number}")
|
||||
if invoice_date is None and fields.get("invoice_date"):
|
||||
invoice_date = fields["invoice_date"]
|
||||
logger.info(f"LLM extracted invoice_date: {invoice_date}")
|
||||
if total is None and fields.get("total"):
|
||||
total = fields["total"]
|
||||
logger.info(f"LLM extracted total: {total}")
|
||||
except Exception as llm_err:
|
||||
logger.warning(f"LLM field extraction fallback failed (non-fatal): {llm_err}")
|
||||
|
||||
logger.info(f"Processed invoice: number={invoice_number}, "
|
||||
f"type={document_type}, supplier_id={supplier_id}, match_type={supplier_match_type}")
|
||||
|
||||
return {
|
||||
"invoice_number": invoice_number,
|
||||
"invoice_date": invoice_date,
|
||||
"total": total,
|
||||
"net_total": result.get("net_total"),
|
||||
"supplier_id": supplier_id,
|
||||
"supplier_match_type": supplier_match_type,
|
||||
"vendor_name": result.get("vendor_name"),
|
||||
"order_number": result.get("order_number"),
|
||||
"document_type": document_type,
|
||||
"line_items": result.get("line_items", []),
|
||||
"raw_text": result.get("raw_text", ""),
|
||||
"raw_json": result.get("raw_json"),
|
||||
"confidence": result.get("confidence", 0.0)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Azure OCR failed: {e}")
|
||||
return {
|
||||
"invoice_number": None,
|
||||
"invoice_date": None,
|
||||
"total": None,
|
||||
"net_total": None,
|
||||
"supplier_id": None,
|
||||
"supplier_match_type": None,
|
||||
"vendor_name": None,
|
||||
"order_number": None,
|
||||
"document_type": "invoice",
|
||||
"line_items": [],
|
||||
"raw_text": f"Error: Azure OCR failed - {str(e)}",
|
||||
"raw_json": None,
|
||||
"confidence": 0.0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue