Frontend sends lowercase status (e.g. 'confirmed'), but the DB enum values are uppercase (CONFIRMED). Uppercase the parameter before comparison. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4077 lines
165 KiB
Python
4077 lines
165 KiB
Python
import os
|
|
import re
|
|
import uuid
|
|
import logging
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, text
|
|
from pydantic import BaseModel
|
|
import aiofiles
|
|
|
|
from database import get_db
|
|
from models.user import User
|
|
from models.invoice import Invoice, InvoiceStatus
|
|
from models.line_item import LineItem
|
|
from models.product_definition import ProductDefinition
|
|
from models.settings import KitchenSettings
|
|
from auth import get_current_user, require_cap, get_current_user_from_token
|
|
from ocr.extractor import process_invoice_image
|
|
from ocr.azure_extractor import parse_pack_size
|
|
from services.duplicate_detector import DuplicateDetector
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def normalize_description(text: str | None) -> str:
|
|
"""Normalize description for matching - lowercase, collapse whitespace, strip"""
|
|
if not text:
|
|
return ""
|
|
return " ".join(text.lower().strip().split())
|
|
|
|
|
|
# Reuse the unit normalization map from parse_pack_size for unit field detection
|
|
from ocr.azure_extractor import _UNIT_NORMALIZE as UNIT_FIELD_MAP
|
|
|
|
|
|
def detect_pack_size(item) -> tuple:
|
|
"""
|
|
Auto-detect pack size from a line item using three tiers:
|
|
1. Text parsing of raw_content/description (e.g. "12x100g", "400ml")
|
|
2. Invoice unit field (e.g. unit="KG", qty=2.5 → 1x2.5kg)
|
|
Returns (pack_quantity, unit_size, unit_size_type) or the item's existing values.
|
|
"""
|
|
pq, us, ust = item.pack_quantity, item.unit_size, item.unit_size_type
|
|
if pq is not None:
|
|
return pq, us, ust
|
|
|
|
# Tier 1: Parse from text
|
|
detected = parse_pack_size(item.raw_content or item.description or "")
|
|
if detected["pack_quantity"]:
|
|
pq = detected["pack_quantity"]
|
|
us = Decimal(str(detected["unit_size"])) if detected["unit_size"] else us
|
|
ust = detected["unit_size_type"] or ust
|
|
return pq, us, ust
|
|
|
|
# Tier 2: Infer from invoice unit field (KG, LTR, etc.) + quantity
|
|
unit_str = (item.unit or "").strip().lower()
|
|
std_unit = UNIT_FIELD_MAP.get(unit_str)
|
|
if std_unit and item.quantity:
|
|
pq = 1
|
|
us = item.quantity # quantity IS the size when priced by weight/volume
|
|
ust = std_unit
|
|
|
|
return pq, us, ust
|
|
|
|
|
|
def get_total_pages_from_ocr(invoice: Invoice) -> int:
|
|
"""Extract total page count from OCR raw JSON."""
|
|
import json
|
|
if not invoice.ocr_raw_json:
|
|
return 1
|
|
try:
|
|
ocr_data = json.loads(invoice.ocr_raw_json)
|
|
pages = ocr_data.get('pages', [])
|
|
return len(pages) if pages else 1
|
|
except Exception:
|
|
return 1
|
|
|
|
|
|
def get_line_item_page_numbers_by_line_number(invoice: Invoice) -> dict[int, int]:
|
|
"""
|
|
Parse ocr_raw_json to extract page numbers for each line item.
|
|
Returns dict mapping line_number (1-based) -> page_number (1-based)
|
|
"""
|
|
import json
|
|
if not invoice.ocr_raw_json:
|
|
return {}
|
|
|
|
try:
|
|
ocr_data = json.loads(invoice.ocr_raw_json)
|
|
documents = ocr_data.get('documents', [])
|
|
if not documents:
|
|
return {}
|
|
|
|
items_field = documents[0].get('fields', {}).get('Items', {})
|
|
ocr_items = items_field.get('value', [])
|
|
|
|
# Build mapping: line_number (1-based) -> page_number
|
|
result = {}
|
|
for idx, ocr_item in enumerate(ocr_items):
|
|
bounding_regions = ocr_item.get('bounding_regions', [])
|
|
if bounding_regions:
|
|
page_num = bounding_regions[0].get('page_number', 1)
|
|
else:
|
|
page_num = 1
|
|
|
|
# line_number is 1-based (idx + 1)
|
|
result[idx + 1] = page_num
|
|
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse page numbers from OCR: {e}")
|
|
return {}
|
|
|
|
|
|
DATA_DIR = "/app/data"
|
|
|
|
|
|
# Response Models
|
|
class LineItemResponse(BaseModel):
|
|
id: int
|
|
product_code: str | None
|
|
description: str | None
|
|
description_alt: str | None # Alternative description (Azure content vs value mismatch)
|
|
unit: str | None
|
|
quantity: float | None
|
|
order_quantity: float | None
|
|
unit_price: float | None
|
|
tax_rate: str | None
|
|
tax_amount: float | None
|
|
amount: float | None
|
|
line_number: int
|
|
is_non_stock: bool
|
|
# Pack size fields
|
|
raw_content: str | None
|
|
pack_quantity: int | None
|
|
unit_size: float | None
|
|
unit_size_type: str | None
|
|
portions_per_unit: int | None # null = not defined yet
|
|
cost_per_item: float | None
|
|
cost_per_portion: float | None
|
|
# OCR warnings for values that needed correction
|
|
ocr_warnings: str | None
|
|
# Price change detection
|
|
price_change_status: str | None = None # "consistent", "amber", "red", "no_history", "acknowledged"
|
|
price_change_percent: float | None = None
|
|
previous_price: float | None = None
|
|
# Future price (for old invoices)
|
|
future_price: float | None = None
|
|
future_change_percent: float | None = None
|
|
# Page number from OCR (for multi-page invoices)
|
|
page_number: int | None = None
|
|
# Ingredient mapping
|
|
ingredient_id: int | None = None
|
|
ingredient_name: str | None = None
|
|
ingredient_unit: str | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class LineItemCreate(BaseModel):
|
|
product_code: Optional[str] = None
|
|
description: Optional[str] = None
|
|
unit: Optional[str] = None
|
|
quantity: Optional[float] = None
|
|
order_quantity: Optional[float] = None
|
|
unit_price: Optional[float] = None
|
|
tax_rate: Optional[str] = None
|
|
tax_amount: Optional[float] = None
|
|
amount: Optional[float] = None
|
|
is_non_stock: bool = False
|
|
# Pack size fields
|
|
raw_content: Optional[str] = None
|
|
pack_quantity: Optional[int] = None
|
|
unit_size: Optional[float] = None
|
|
unit_size_type: Optional[str] = None
|
|
portions_per_unit: Optional[int] = None # null = not defined yet
|
|
cost_per_item: Optional[float] = None
|
|
cost_per_portion: Optional[float] = None
|
|
|
|
|
|
class LineItemUpdate(BaseModel):
|
|
product_code: Optional[str] = None
|
|
description: Optional[str] = None
|
|
description_alt: Optional[str] = None # Alternative description (for swap UI)
|
|
unit: Optional[str] = None
|
|
quantity: Optional[float] = None
|
|
order_quantity: Optional[float] = None
|
|
unit_price: Optional[float] = None
|
|
tax_rate: Optional[str] = None
|
|
tax_amount: Optional[float] = None
|
|
amount: Optional[float] = None
|
|
is_non_stock: Optional[bool] = None
|
|
# Pack size fields
|
|
pack_quantity: Optional[int] = None
|
|
unit_size: Optional[float] = None
|
|
unit_size_type: Optional[str] = None
|
|
portions_per_unit: Optional[int] = None
|
|
cost_per_item: Optional[float] = None
|
|
cost_per_portion: Optional[float] = None
|
|
ingredient_id: Optional[int] = None
|
|
|
|
|
|
class DuplicateInfo(BaseModel):
|
|
id: int
|
|
invoice_number: str | None
|
|
invoice_date: date | None
|
|
total: Decimal | None
|
|
supplier_id: int | None
|
|
document_type: str | None
|
|
duplicate_type: str # "firm_duplicate", "possible_duplicate", "related_document"
|
|
|
|
|
|
class InvoiceResponse(BaseModel):
|
|
id: int
|
|
invoice_number: str | None
|
|
invoice_date: date | None
|
|
total: Decimal | None
|
|
net_total: Decimal | None
|
|
stock_total: Decimal | None # Sum of stock items only (non non-stock)
|
|
supplier_id: int | None
|
|
supplier_name: str | None
|
|
supplier_match_type: str | None # "exact", "fuzzy", or null - for highlighting fuzzy matches
|
|
supplier_skip_dext: bool = False # Whether supplier has skip_dext enabled
|
|
vendor_name: str | None # OCR-extracted vendor name (before supplier matching)
|
|
status: str
|
|
category: str | None
|
|
ocr_confidence: float | None
|
|
ocr_raw_text: str | None # OCR extracted text or error message if processing failed
|
|
image_path: str
|
|
created_at: str
|
|
# New fields
|
|
document_type: str | None
|
|
order_number: str | None
|
|
duplicate_status: str | None
|
|
duplicate_of_id: int | None
|
|
# Dext integration fields
|
|
notes: str | None
|
|
dext_sent_at: str | None # ISO datetime
|
|
dext_sent_by_username: str | None # Resolved from relationship
|
|
# Dispute tracking fields
|
|
dispute_count: int = 0
|
|
has_open_disputes: bool = False
|
|
disputes: list[dict] = []
|
|
disputed_line_item_ids: list[int] = [] # Line item IDs that are part of a dispute
|
|
# Source tracking fields
|
|
source: str = "upload"
|
|
source_reference: str | None = None
|
|
# Linked dispute (for credit notes)
|
|
linked_dispute_id: int | None = None
|
|
# Total pages from OCR (for multi-page invoices)
|
|
total_pages: int | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class InvoiceUpdate(BaseModel):
|
|
invoice_number: Optional[str] = None
|
|
invoice_date: Optional[date] = None
|
|
total: Optional[Decimal] = None
|
|
net_total: Optional[Decimal] = None
|
|
supplier_id: Optional[int] = None
|
|
category: Optional[str] = None
|
|
status: Optional[str] = None
|
|
# New fields
|
|
document_type: Optional[str] = None
|
|
order_number: Optional[str] = None
|
|
# Dext integration
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class InvoiceListResponse(BaseModel):
|
|
invoices: list[InvoiceResponse]
|
|
total: int
|
|
|
|
|
|
class DuplicateCompareResponse(BaseModel):
|
|
current_invoice: InvoiceResponse
|
|
firm_duplicate: InvoiceResponse | None
|
|
possible_duplicates: list[InvoiceResponse]
|
|
related_documents: list[InvoiceResponse]
|
|
|
|
|
|
# Product Definition Models (for persistent portion/pack data)
|
|
class ProductDefinitionResponse(BaseModel):
|
|
id: int
|
|
kitchen_id: int
|
|
supplier_id: int | None
|
|
product_code: str | None
|
|
description_pattern: str | None
|
|
pack_quantity: int | None
|
|
unit_size: float | None
|
|
unit_size_type: str | None
|
|
portions_per_unit: int | None
|
|
portion_description: str | None
|
|
# Saved by metadata
|
|
saved_by_user_id: int | None
|
|
saved_by_username: str | None # Resolved from saved_by_user relationship
|
|
source_invoice_id: int | None
|
|
source_invoice_number: str | None
|
|
updated_at: str | None # ISO datetime string
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ProductDefinitionCreate(BaseModel):
|
|
supplier_id: Optional[int] = None
|
|
product_code: Optional[str] = None
|
|
description_pattern: Optional[str] = None
|
|
pack_quantity: Optional[int] = None
|
|
unit_size: Optional[float] = None
|
|
unit_size_type: Optional[str] = None
|
|
portions_per_unit: Optional[int] = None
|
|
portion_description: Optional[str] = None
|
|
|
|
|
|
class ProductDefinitionUpdate(BaseModel):
|
|
pack_quantity: Optional[int] = None
|
|
unit_size: Optional[float] = None
|
|
unit_size_type: Optional[str] = None
|
|
portions_per_unit: Optional[int] = None
|
|
portion_description: Optional[str] = None
|
|
|
|
|
|
# Line Item Search Models
|
|
class LineItemSearchRequest(BaseModel):
|
|
query: str
|
|
exclude_invoice_id: Optional[int] = None
|
|
|
|
|
|
class SearchResultItem(BaseModel):
|
|
description: str
|
|
unit_price: float | None
|
|
unit: str | None
|
|
pack_info: str | None
|
|
last_invoice_date: str | None
|
|
invoice_id: int
|
|
similarity: float
|
|
|
|
|
|
class SupplierSearchGroup(BaseModel):
|
|
supplier_id: int | None
|
|
supplier_name: str
|
|
items: list[SearchResultItem]
|
|
|
|
|
|
class LineItemSearchResponse(BaseModel):
|
|
query: str
|
|
extracted_keywords: str
|
|
results: list[SupplierSearchGroup]
|
|
total_matches: int
|
|
|
|
|
|
def extract_search_keywords(description: str, supplier_words: list[str] | None = None) -> str:
|
|
"""Extract meaningful keywords from line item description.
|
|
|
|
Removes:
|
|
- Pack sizes (12x1L, 120x15g, 6x500ml)
|
|
- Quantity patterns (qty 12, case of 24)
|
|
- Product codes like (L-AG), [SKU-123]
|
|
- Generic terms (case, qty, un, pack, box, each, per, unit, etc.)
|
|
- Standalone numbers and weights
|
|
- Common English stop words (the, a, an, in, etc.)
|
|
- Supplier names and aliases (passed dynamically)
|
|
"""
|
|
if not description:
|
|
return ""
|
|
|
|
text = description
|
|
|
|
# 1. Remove pack size patterns (12x1L, 120x15g, 6x500ml)
|
|
text = re.sub(r'\b\d+\s*x\s*\d+(\.\d+)?\s*(g|kg|ml|ltr|l|oz|cl)?\b', '', text, flags=re.IGNORECASE)
|
|
|
|
# 2. Remove quantity patterns (qty 12, case of 24)
|
|
text = re.sub(r'\b(qty|quantity)\s*:?\s*\d+\b', '', text, flags=re.IGNORECASE)
|
|
text = re.sub(r'\bcase\s*(of\s*)?\d+\b', '', text, flags=re.IGNORECASE)
|
|
|
|
# 3. Remove product codes like (L-AG), [SKU-123], etc.
|
|
text = re.sub(r'\([A-Z]{1,3}-?[A-Z0-9]{1,5}\)', '', text, flags=re.IGNORECASE)
|
|
text = re.sub(r'\[[A-Z0-9-]+\]', '', text, flags=re.IGNORECASE)
|
|
|
|
# 4. Remove generic packaging/unit terms
|
|
generic_terms = ['case', 'qty', 'un', 'pack', 'box', 'each', 'per', 'unit', 'pkt', 'bag', 'bottle', 'tin', 'can', 'carton', 'tray', 'portion', 'portions']
|
|
pattern = r'\b(' + '|'.join(generic_terms) + r')\b'
|
|
text = re.sub(pattern, '', text, flags=re.IGNORECASE)
|
|
|
|
# 5. Remove standalone numbers and weights (500g, 1.5kg, just "12")
|
|
text = re.sub(r'\b\d+(\.\d+)?\s*(g|kg|ml|ltr|l|oz|cl|lb)?\b', '', text, flags=re.IGNORECASE)
|
|
|
|
# 6. Clean up whitespace and special characters
|
|
text = re.sub(r'[^\w\s]', ' ', text)
|
|
|
|
# 7. Remove common English stop words
|
|
stop_words = [
|
|
'the', 'a', 'an', 'in', 'on', 'at', 'by', 'for', 'with', 'to', 'of', 'and', 'or',
|
|
'is', 'it', 'as', 'be', 'are', 'was', 'been', 'being', 'have', 'has', 'had',
|
|
'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might',
|
|
'this', 'that', 'these', 'those', 'from', 'into', 'through', 'during',
|
|
'before', 'after', 'above', 'below', 'between', 'under', 'over'
|
|
]
|
|
stop_pattern = r'\b(' + '|'.join(stop_words) + r')\b'
|
|
text = re.sub(stop_pattern, '', text, flags=re.IGNORECASE)
|
|
|
|
# 8. Remove supplier names and aliases (dynamic list)
|
|
if supplier_words:
|
|
# Escape special regex chars and filter empty strings
|
|
safe_words = [re.escape(w) for w in supplier_words if w and len(w) > 1]
|
|
if safe_words:
|
|
supplier_pattern = r'\b(' + '|'.join(safe_words) + r')\b'
|
|
text = re.sub(supplier_pattern, '', text, flags=re.IGNORECASE)
|
|
|
|
# 9. Filter out very short words and collapse whitespace
|
|
words = [w for w in text.split() if len(w) > 1]
|
|
return ' '.join(words).strip()
|
|
|
|
|
|
# Helper function
|
|
async def get_invoice_or_404(
|
|
invoice_id: int,
|
|
current_user: User,
|
|
db: AsyncSession
|
|
) -> Invoice:
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
return invoice
|
|
|
|
|
|
def invoice_to_response(
|
|
invoice: Invoice,
|
|
supplier_name: str | None = None,
|
|
line_items: list | None = None,
|
|
dispute_count: int = 0,
|
|
has_open_disputes: bool = False,
|
|
disputes: list[dict] = None,
|
|
disputed_line_item_ids: list[int] = None
|
|
) -> InvoiceResponse:
|
|
from sqlalchemy import inspect
|
|
|
|
# Get supplier name from relationship if not provided
|
|
insp = inspect(invoice)
|
|
supplier_skip_dext = False
|
|
if supplier_name is None and 'supplier' in insp.dict and invoice.supplier:
|
|
supplier_name = invoice.supplier.name
|
|
supplier_skip_dext = invoice.supplier.skip_dext or False
|
|
elif 'supplier' in insp.dict and invoice.supplier:
|
|
supplier_skip_dext = invoice.supplier.skip_dext or False
|
|
|
|
# Calculate stock_total from line items (sum of items where is_non_stock=False)
|
|
stock_total = None
|
|
if line_items is not None:
|
|
stock_items = [item for item in line_items if not (item.is_non_stock or False)]
|
|
if stock_items:
|
|
stock_total = sum(item.amount or Decimal("0") for item in stock_items)
|
|
elif 'line_items' in insp.dict and invoice.line_items:
|
|
stock_items = [item for item in invoice.line_items if not (item.is_non_stock or False)]
|
|
if stock_items:
|
|
stock_total = sum(item.amount or Decimal("0") for item in stock_items)
|
|
|
|
# Get dext_sent_by_user name from relationship if loaded
|
|
dext_sent_by_username = None
|
|
if 'dext_sent_by_user' in insp.dict and invoice.dext_sent_by_user:
|
|
dext_sent_by_username = invoice.dext_sent_by_user.name
|
|
|
|
return InvoiceResponse(
|
|
id=invoice.id,
|
|
invoice_number=invoice.invoice_number,
|
|
invoice_date=invoice.invoice_date,
|
|
total=invoice.total,
|
|
net_total=invoice.net_total,
|
|
stock_total=stock_total,
|
|
supplier_id=invoice.supplier_id,
|
|
supplier_name=supplier_name,
|
|
supplier_match_type=invoice.supplier_match_type,
|
|
supplier_skip_dext=supplier_skip_dext,
|
|
vendor_name=invoice.vendor_name,
|
|
status=invoice.status.value,
|
|
category=invoice.category,
|
|
ocr_confidence=float(invoice.ocr_confidence) if invoice.ocr_confidence else None,
|
|
ocr_raw_text=invoice.ocr_raw_text,
|
|
image_path=invoice.image_path,
|
|
created_at=invoice.created_at.isoformat(),
|
|
document_type=invoice.document_type,
|
|
order_number=invoice.order_number,
|
|
duplicate_status=invoice.duplicate_status,
|
|
duplicate_of_id=invoice.duplicate_of_id,
|
|
# Dext integration
|
|
notes=invoice.notes,
|
|
dext_sent_at=invoice.dext_sent_at.isoformat() if invoice.dext_sent_at else None,
|
|
dext_sent_by_username=dext_sent_by_username,
|
|
# Dispute tracking
|
|
dispute_count=dispute_count,
|
|
has_open_disputes=has_open_disputes,
|
|
disputes=disputes or [],
|
|
disputed_line_item_ids=disputed_line_item_ids or [],
|
|
# Source tracking
|
|
source=invoice.source or "upload",
|
|
source_reference=invoice.source_reference,
|
|
# Linked dispute (for credit notes)
|
|
linked_dispute_id=invoice.linked_dispute_id,
|
|
# Total pages from OCR
|
|
total_pages=get_total_pages_from_ocr(invoice)
|
|
)
|
|
|
|
|
|
# Invoice endpoints
|
|
@router.post("/upload", response_model=InvoiceResponse)
|
|
async def upload_invoice(
|
|
background_tasks: BackgroundTasks,
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Upload an invoice image or PDF for OCR processing"""
|
|
allowed_types = ["image/jpeg", "image/png", "image/webp", "image/heic", "application/pdf"]
|
|
if file.content_type not in allowed_types:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"File type not allowed. Allowed: {allowed_types}"
|
|
)
|
|
|
|
ext = file.filename.split(".")[-1] if file.filename else "jpg"
|
|
filename = f"{uuid.uuid4()}.{ext}"
|
|
filepath = os.path.join(DATA_DIR, str(current_user.kitchen_id), filename)
|
|
|
|
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
|
|
|
async with aiofiles.open(filepath, "wb") as f:
|
|
content = await file.read()
|
|
await f.write(content)
|
|
|
|
invoice = Invoice(
|
|
kitchen_id=current_user.kitchen_id,
|
|
image_path=filepath,
|
|
status=InvoiceStatus.PENDING
|
|
)
|
|
db.add(invoice)
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
|
|
background_tasks.add_task(
|
|
process_invoice_background,
|
|
invoice.id,
|
|
filepath,
|
|
current_user.kitchen_id
|
|
)
|
|
|
|
return invoice_to_response(invoice)
|
|
|
|
|
|
async def apply_product_definitions(
|
|
line_items: list[dict],
|
|
kitchen_id: int,
|
|
supplier_id: int | None,
|
|
db
|
|
) -> list[dict]:
|
|
"""
|
|
Apply product definitions to line items.
|
|
Looks up existing definitions and auto-populates portions_per_unit.
|
|
Checks for supplier-specific definitions first, then kitchen-wide (supplier_id=NULL).
|
|
|
|
Matching priority:
|
|
1. product_code (exact match)
|
|
2. description_pattern (normalized contains match) - used when no product_code or no code match
|
|
"""
|
|
from sqlalchemy import or_
|
|
|
|
logger.info(f"apply_product_definitions: kitchen_id={kitchen_id}, supplier_id={supplier_id}, line_items_count={len(line_items)}")
|
|
|
|
# Build query to get definitions - supplier-specific OR kitchen-wide (supplier_id IS NULL)
|
|
conditions = [
|
|
ProductDefinition.kitchen_id == kitchen_id,
|
|
]
|
|
|
|
if supplier_id:
|
|
# Get both supplier-specific and kitchen-wide definitions
|
|
conditions.append(
|
|
or_(
|
|
ProductDefinition.supplier_id == supplier_id,
|
|
ProductDefinition.supplier_id.is_(None)
|
|
)
|
|
)
|
|
else:
|
|
# Only get kitchen-wide definitions
|
|
conditions.append(ProductDefinition.supplier_id.is_(None))
|
|
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(*conditions)
|
|
)
|
|
all_definitions = result.scalars().all()
|
|
logger.info(f"apply_product_definitions: found {len(all_definitions)} definitions")
|
|
|
|
# Build lookup dicts - prefer supplier-specific over kitchen-wide
|
|
# 1. By product_code (for items with codes)
|
|
definitions_by_code = {}
|
|
# 2. By description_pattern (for items without codes, or as fallback)
|
|
definitions_by_desc = [] # List of (normalized_pattern, definition) tuples
|
|
|
|
for d in all_definitions:
|
|
# Add to code lookup if has product_code
|
|
if d.product_code:
|
|
if d.product_code in definitions_by_code:
|
|
existing = definitions_by_code[d.product_code]
|
|
if existing.supplier_id and not d.supplier_id:
|
|
continue # Keep supplier-specific
|
|
definitions_by_code[d.product_code] = d
|
|
|
|
# Add to description lookup if has description_pattern
|
|
if d.description_pattern:
|
|
norm_pattern = normalize_description(d.description_pattern)
|
|
if norm_pattern:
|
|
definitions_by_desc.append((norm_pattern, d))
|
|
|
|
# Sort description patterns: prefer supplier-specific first, then by length (longer = more specific)
|
|
definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0])))
|
|
|
|
if not definitions_by_code and not definitions_by_desc:
|
|
logger.info("apply_product_definitions: no definitions found after filtering")
|
|
return line_items
|
|
|
|
logger.info(f"apply_product_definitions: {len(definitions_by_code)} code definitions, {len(definitions_by_desc)} description definitions")
|
|
|
|
def find_definition(item: dict) -> ProductDefinition | None:
|
|
"""Find matching definition for a line item"""
|
|
product_code = item.get("product_code")
|
|
description = item.get("description")
|
|
|
|
# Priority 1: Match by product_code
|
|
if product_code and product_code in definitions_by_code:
|
|
return definitions_by_code[product_code]
|
|
|
|
# Priority 2: Match by description_pattern (for items without codes, or when no code match)
|
|
if description:
|
|
item_desc_norm = normalize_description(description)
|
|
for pattern, defn in definitions_by_desc:
|
|
# Check if pattern is contained in item description
|
|
if pattern in item_desc_norm:
|
|
return defn
|
|
|
|
return None
|
|
|
|
for item in line_items:
|
|
defn = find_definition(item)
|
|
if not defn:
|
|
continue
|
|
|
|
match_type = "product_code" if item.get("product_code") and item.get("product_code") in definitions_by_code else "description"
|
|
logger.info(f"apply_product_definitions: applying to item (matched by {match_type}): code={item.get('product_code')}, desc={item.get('description', '')[:50]}")
|
|
|
|
# Apply portions_per_unit if not already set
|
|
if item.get("portions_per_unit") is None and defn.portions_per_unit:
|
|
item["portions_per_unit"] = defn.portions_per_unit
|
|
# Recalculate cost_per_portion
|
|
if item.get("pack_quantity") and item.get("unit_price"):
|
|
item["cost_per_portion"] = round(
|
|
item["unit_price"] / (item["pack_quantity"] * defn.portions_per_unit), 4
|
|
)
|
|
# Optionally override pack_quantity if definition has it but OCR didn't find it
|
|
if item.get("pack_quantity") is None and defn.pack_quantity:
|
|
item["pack_quantity"] = defn.pack_quantity
|
|
# Calculate cost_per_item
|
|
if item.get("unit_price"):
|
|
item["cost_per_item"] = round(item["unit_price"] / defn.pack_quantity, 4)
|
|
# Recalculate cost_per_portion now that pack_quantity is available
|
|
if item.get("portions_per_unit") and item.get("unit_price"):
|
|
item["cost_per_portion"] = round(
|
|
item["unit_price"] / (defn.pack_quantity * item["portions_per_unit"]), 4
|
|
)
|
|
# Apply unit_size and unit_size_type if definition has them but OCR didn't find them
|
|
if item.get("unit_size") is None and defn.unit_size:
|
|
item["unit_size"] = float(defn.unit_size)
|
|
if item.get("unit_size_type") is None and defn.unit_size_type:
|
|
item["unit_size_type"] = defn.unit_size_type
|
|
|
|
return line_items
|
|
|
|
|
|
async def process_invoice_background(invoice_id: int, image_path: str, kitchen_id: int):
|
|
"""Background task to process invoice OCR, save line items, and detect duplicates"""
|
|
from database import AsyncSessionLocal
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
result = await process_invoice_image(image_path, kitchen_id, db)
|
|
|
|
stmt = select(Invoice).where(Invoice.id == invoice_id)
|
|
db_result = await db.execute(stmt)
|
|
invoice = db_result.scalar_one()
|
|
|
|
# Update basic fields
|
|
invoice.invoice_number = result.get("invoice_number")
|
|
invoice.invoice_date = result.get("invoice_date")
|
|
invoice.total = result.get("total")
|
|
invoice.net_total = result.get("net_total")
|
|
invoice.supplier_id = result.get("supplier_id")
|
|
invoice.supplier_match_type = result.get("supplier_match_type")
|
|
invoice.vendor_name = result.get("vendor_name")
|
|
invoice.ocr_raw_text = result.get("raw_text")
|
|
invoice.ocr_confidence = result.get("confidence")
|
|
invoice.document_type = result.get("document_type", "invoice")
|
|
invoice.order_number = result.get("order_number")
|
|
|
|
# Store raw Azure JSON for debugging/remapping
|
|
raw_json = result.get("raw_json")
|
|
if raw_json:
|
|
import json
|
|
invoice.ocr_raw_json = json.dumps(raw_json)
|
|
|
|
# Delete existing line items before creating new ones
|
|
from sqlalchemy import text
|
|
await db.execute(
|
|
text("DELETE FROM line_items WHERE invoice_id = :invoice_id"),
|
|
{"invoice_id": invoice_id}
|
|
)
|
|
await db.flush()
|
|
|
|
# Save line items (apply product definitions for auto-population)
|
|
line_items = result.get("line_items", [])
|
|
supplier_id = result.get("supplier_id")
|
|
# Always try to apply definitions - handles both supplier-specific and kitchen-wide
|
|
line_items = await apply_product_definitions(
|
|
line_items, kitchen_id, supplier_id, db
|
|
)
|
|
|
|
for idx, item_data in enumerate(line_items):
|
|
line_item = LineItem(
|
|
invoice_id=invoice.id,
|
|
product_code=item_data.get("product_code"),
|
|
description=item_data.get("description"),
|
|
description_alt=item_data.get("description_alt"),
|
|
unit=item_data.get("unit"),
|
|
quantity=Decimal(str(item_data["quantity"])) if item_data.get("quantity") else None,
|
|
order_quantity=Decimal(str(item_data["order_quantity"])) if item_data.get("order_quantity") else None,
|
|
unit_price=Decimal(str(item_data["unit_price"])) if item_data.get("unit_price") else None,
|
|
tax_rate=item_data.get("tax_rate"),
|
|
tax_amount=Decimal(str(item_data["tax_amount"])) if item_data.get("tax_amount") else None,
|
|
amount=Decimal(str(item_data["amount"])) if item_data.get("amount") else None,
|
|
line_number=item_data.get("ocr_index", idx),
|
|
# Pack size fields from OCR extraction + product definitions
|
|
raw_content=item_data.get("raw_content"),
|
|
pack_quantity=item_data.get("pack_quantity"),
|
|
unit_size=Decimal(str(item_data["unit_size"])) if item_data.get("unit_size") else None,
|
|
unit_size_type=item_data.get("unit_size_type"),
|
|
portions_per_unit=item_data.get("portions_per_unit"), # From product definitions
|
|
cost_per_item=Decimal(str(item_data["cost_per_item"])) if item_data.get("cost_per_item") else None,
|
|
cost_per_portion=Decimal(str(item_data["cost_per_portion"])) if item_data.get("cost_per_portion") else None,
|
|
# OCR warnings for values that needed manual review
|
|
ocr_warnings=item_data.get("ocr_warnings")
|
|
)
|
|
db.add(line_item)
|
|
|
|
# Auto-normalize: fill missing product codes + rename alias descriptions
|
|
if supplier_id:
|
|
try:
|
|
from api.ingredients import auto_normalize_line_items
|
|
norm_count = await auto_normalize_line_items(invoice_id, kitchen_id, supplier_id, db)
|
|
if norm_count:
|
|
logger.info(f"Auto-normalized {norm_count} line items on invoice {invoice_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Auto-normalize failed (non-critical): {e}")
|
|
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
|
|
# Auto-update ingredient source prices from matched line items
|
|
try:
|
|
from api.ingredients import update_ingredient_prices_for_invoice
|
|
updated_ingredients = await update_ingredient_prices_for_invoice(invoice_id, kitchen_id, db)
|
|
if updated_ingredients:
|
|
from api.recipes import snapshot_recipes_using_ingredient
|
|
for ing_id, price_info in updated_ingredients.items():
|
|
await snapshot_recipes_using_ingredient(
|
|
ing_id, db,
|
|
trigger_source=f"ingredient_price_update: invoice #{invoice_id}",
|
|
price_info=price_info,
|
|
invoice_id=invoice_id,
|
|
)
|
|
await db.commit()
|
|
logger.info(f"Auto-updated ingredient prices for {len(updated_ingredients)} ingredients from invoice {invoice_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Ingredient price auto-update failed (non-critical): {e}")
|
|
|
|
# Run duplicate detection
|
|
detector = DuplicateDetector(db, kitchen_id)
|
|
duplicates = await detector.check_duplicates(invoice)
|
|
|
|
if duplicates["firm_duplicate"]:
|
|
invoice.duplicate_status = "firm_duplicate"
|
|
invoice.duplicate_of_id = duplicates["firm_duplicate"].id
|
|
elif duplicates["possible_duplicates"]:
|
|
invoice.duplicate_status = "possible_duplicate"
|
|
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
|
|
|
|
if duplicates["related_documents"]:
|
|
invoice.related_document_id = duplicates["related_documents"][0].id
|
|
|
|
invoice.status = InvoiceStatus.PROCESSED
|
|
await db.commit()
|
|
|
|
logger.info(f"Invoice {invoice_id} processed: number={invoice.invoice_number}, "
|
|
f"duplicate_status={invoice.duplicate_status}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"OCR processing error for invoice {invoice_id}: {e}")
|
|
stmt = select(Invoice).where(Invoice.id == invoice_id)
|
|
db_result = await db.execute(stmt)
|
|
invoice = db_result.scalar_one()
|
|
invoice.status = InvoiceStatus.PROCESSED
|
|
invoice.ocr_raw_text = f"Error: {str(e)}"
|
|
await db.commit()
|
|
|
|
|
|
@router.get("/", response_model=InvoiceListResponse)
|
|
async def list_invoices(
|
|
status: Optional[str] = None,
|
|
supplier_id: Optional[int] = None,
|
|
date_from: Optional[date] = None,
|
|
date_to: Optional[date] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""List invoices for the current kitchen with optional filters
|
|
|
|
Status filter supports:
|
|
- pending, processed, reviewed, confirmed: filter by specific status
|
|
- pending_confirmation: filter by processed OR reviewed (awaiting confirmation)
|
|
"""
|
|
from sqlalchemy.orm import selectinload
|
|
from sqlalchemy import or_
|
|
|
|
# Only load supplier for list view - line_items not needed and slows query significantly
|
|
query = select(Invoice).options(
|
|
selectinload(Invoice.supplier)
|
|
).where(Invoice.kitchen_id == current_user.kitchen_id)
|
|
|
|
# Handle special "pending_confirmation" filter (all non-confirmed: pending, processed, reviewed)
|
|
if status == "pending_confirmation":
|
|
query = query.where(or_(
|
|
Invoice.status == InvoiceStatus.PENDING,
|
|
Invoice.status == InvoiceStatus.PROCESSED,
|
|
Invoice.status == InvoiceStatus.REVIEWED
|
|
))
|
|
elif status:
|
|
query = query.where(Invoice.status == status.upper())
|
|
if supplier_id:
|
|
query = query.where(Invoice.supplier_id == supplier_id)
|
|
if date_from:
|
|
query = query.where(Invoice.created_at >= date_from)
|
|
if date_to:
|
|
# Add one day to include the entire end date
|
|
query = query.where(Invoice.created_at < date_to + timedelta(days=1))
|
|
|
|
query = query.order_by(Invoice.created_at.desc()).offset(offset).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
invoices = result.scalars().all()
|
|
|
|
# Count query with same filters
|
|
count_query = select(func.count(Invoice.id)).where(Invoice.kitchen_id == current_user.kitchen_id)
|
|
if status == "pending_confirmation":
|
|
count_query = count_query.where(or_(
|
|
Invoice.status == InvoiceStatus.PENDING,
|
|
Invoice.status == InvoiceStatus.PROCESSED,
|
|
Invoice.status == InvoiceStatus.REVIEWED
|
|
))
|
|
elif status:
|
|
count_query = count_query.where(Invoice.status == status.upper())
|
|
if supplier_id:
|
|
count_query = count_query.where(Invoice.supplier_id == supplier_id)
|
|
if date_from:
|
|
count_query = count_query.where(Invoice.created_at >= date_from)
|
|
if date_to:
|
|
# Add one day to include the entire end date
|
|
count_query = count_query.where(Invoice.created_at < date_to + timedelta(days=1))
|
|
count_result = await db.execute(count_query)
|
|
total = count_result.scalar() or 0
|
|
|
|
return InvoiceListResponse(
|
|
invoices=[invoice_to_response(inv) for inv in invoices],
|
|
total=total
|
|
)
|
|
|
|
|
|
@router.post("/line-items/search", response_model=LineItemSearchResponse)
|
|
async def search_line_items(
|
|
request: LineItemSearchRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Search line items across all invoices for the kitchen.
|
|
Uses ILIKE keyword matching for search.
|
|
Results grouped by supplier with price comparison info.
|
|
"""
|
|
# Fetch supplier names and aliases for keyword extraction
|
|
from models.supplier import Supplier
|
|
supplier_result = await db.execute(
|
|
select(Supplier.name, Supplier.aliases).where(Supplier.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
supplier_rows = supplier_result.fetchall()
|
|
|
|
# Build list of supplier words (names + aliases, split into individual words)
|
|
supplier_words = []
|
|
for row in supplier_rows:
|
|
# Add each word from supplier name
|
|
if row.name:
|
|
supplier_words.extend(row.name.lower().split())
|
|
# Add each alias and its words
|
|
if row.aliases:
|
|
for alias in row.aliases:
|
|
if alias:
|
|
supplier_words.extend(alias.lower().split())
|
|
# Remove duplicates
|
|
supplier_words = list(set(supplier_words))
|
|
|
|
# Extract keywords from query
|
|
keywords = extract_search_keywords(request.query, supplier_words) if request.query else request.query
|
|
|
|
if not keywords or len(keywords) < 2:
|
|
return LineItemSearchResponse(
|
|
query=request.query,
|
|
extracted_keywords=keywords or "",
|
|
results=[],
|
|
total_matches=0
|
|
)
|
|
|
|
# Build ILIKE patterns from keywords - match ANY keyword (OR logic for better results)
|
|
keyword_list = keywords.split()
|
|
like_conditions = " OR ".join([f"LOWER(li.description) LIKE LOWER(:kw{i})" for i in range(len(keyword_list))])
|
|
|
|
# Build exclude condition only if exclude_id is provided (avoids asyncpg type inference issue)
|
|
exclude_condition = ""
|
|
if request.exclude_invoice_id is not None:
|
|
exclude_condition = "AND i.id != :exclude_id"
|
|
|
|
query = text(f"""
|
|
SELECT DISTINCT ON (i.supplier_id, li.description)
|
|
li.description,
|
|
li.unit_price,
|
|
li.unit,
|
|
li.pack_quantity,
|
|
li.unit_size,
|
|
li.unit_size_type,
|
|
i.invoice_date,
|
|
i.id as invoice_id,
|
|
i.supplier_id,
|
|
s.name as supplier_name
|
|
FROM line_items li
|
|
JOIN invoices i ON li.invoice_id = i.id
|
|
LEFT JOIN suppliers s ON i.supplier_id = s.id
|
|
WHERE i.kitchen_id = :kitchen_id
|
|
AND li.description IS NOT NULL
|
|
AND li.description != ''
|
|
AND ({like_conditions})
|
|
{exclude_condition}
|
|
ORDER BY i.supplier_id, li.description, i.invoice_date DESC
|
|
LIMIT 100
|
|
""")
|
|
|
|
params = {
|
|
"kitchen_id": current_user.kitchen_id,
|
|
}
|
|
if request.exclude_invoice_id is not None:
|
|
params["exclude_id"] = request.exclude_invoice_id
|
|
for i, kw in enumerate(keyword_list):
|
|
params[f"kw{i}"] = f"%{kw}%"
|
|
|
|
result = await db.execute(query, params)
|
|
rows = result.fetchall()
|
|
|
|
# Group results by supplier
|
|
supplier_groups: dict[int | None, SupplierSearchGroup] = {}
|
|
total_matches = 0
|
|
|
|
for row in rows:
|
|
supplier_id = row.supplier_id
|
|
supplier_name = row.supplier_name or "Unknown Supplier"
|
|
|
|
# Format pack info
|
|
pack_info = None
|
|
if row.pack_quantity and row.unit_size and row.unit_size_type:
|
|
pack_info = f"{row.pack_quantity}x{row.unit_size}{row.unit_size_type}"
|
|
elif row.pack_quantity:
|
|
pack_info = f"{row.pack_quantity} pack"
|
|
|
|
# Format invoice date
|
|
last_invoice_date = row.invoice_date.isoformat() if row.invoice_date else None
|
|
|
|
item = SearchResultItem(
|
|
description=row.description,
|
|
unit_price=float(row.unit_price) if row.unit_price else None,
|
|
unit=row.unit,
|
|
pack_info=pack_info,
|
|
last_invoice_date=last_invoice_date,
|
|
invoice_id=row.invoice_id,
|
|
similarity=1.0 # ILIKE match (no similarity score available)
|
|
)
|
|
|
|
if supplier_id not in supplier_groups:
|
|
supplier_groups[supplier_id] = SupplierSearchGroup(
|
|
supplier_id=supplier_id,
|
|
supplier_name=supplier_name,
|
|
items=[]
|
|
)
|
|
|
|
supplier_groups[supplier_id].items.append(item)
|
|
total_matches += 1
|
|
|
|
# Sort groups by supplier name
|
|
sorted_groups = sorted(supplier_groups.values(), key=lambda g: g.supplier_name)
|
|
|
|
return LineItemSearchResponse(
|
|
query=request.query,
|
|
extracted_keywords=keywords,
|
|
results=sorted_groups,
|
|
total_matches=total_matches
|
|
)
|
|
|
|
|
|
@router.get("/{invoice_id}", response_model=InvoiceResponse)
|
|
async def get_invoice(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get a single invoice by ID.
|
|
Re-applies latest product definitions to ensure users see current defaults.
|
|
"""
|
|
from sqlalchemy.orm import selectinload
|
|
from sqlalchemy import or_
|
|
|
|
result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.supplier),
|
|
selectinload(Invoice.line_items),
|
|
selectinload(Invoice.dext_sent_by_user)
|
|
).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Re-apply latest product definitions when opening invoice
|
|
# This ensures users always see the most current defaults
|
|
if invoice.supplier_id and invoice.line_items:
|
|
logger.info(f"get_invoice: Re-applying latest product definitions for invoice {invoice_id}")
|
|
|
|
# Fetch current product definitions
|
|
conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id]
|
|
conditions.append(
|
|
or_(
|
|
ProductDefinition.supplier_id == invoice.supplier_id,
|
|
ProductDefinition.supplier_id.is_(None)
|
|
)
|
|
)
|
|
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(*conditions)
|
|
)
|
|
all_definitions = result.scalars().all()
|
|
|
|
if all_definitions:
|
|
# Build lookup dicts
|
|
definitions_by_code = {}
|
|
definitions_by_desc = []
|
|
|
|
for d in all_definitions:
|
|
if d.product_code:
|
|
if d.product_code in definitions_by_code:
|
|
existing = definitions_by_code[d.product_code]
|
|
if existing.supplier_id and not d.supplier_id:
|
|
continue
|
|
definitions_by_code[d.product_code] = d
|
|
if d.description_pattern:
|
|
norm_pattern = normalize_description(d.description_pattern)
|
|
if norm_pattern:
|
|
definitions_by_desc.append((norm_pattern, d))
|
|
|
|
definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0])))
|
|
|
|
def find_definition_for_item(item: LineItem) -> ProductDefinition | None:
|
|
if item.product_code and item.product_code in definitions_by_code:
|
|
return definitions_by_code[item.product_code]
|
|
if item.description:
|
|
item_desc_norm = normalize_description(item.description)
|
|
for pattern, defn in definitions_by_desc:
|
|
if pattern in item_desc_norm:
|
|
return defn
|
|
return None
|
|
|
|
# Apply definitions to line items
|
|
updated_count = 0
|
|
for item in invoice.line_items:
|
|
defn = find_definition_for_item(item)
|
|
if not defn:
|
|
continue
|
|
|
|
# Re-apply portions_per_unit from latest definition
|
|
if defn.portions_per_unit:
|
|
item.portions_per_unit = defn.portions_per_unit
|
|
updated_count += 1
|
|
|
|
# Recalculate cost_per_portion
|
|
if item.pack_quantity and item.unit_price:
|
|
item.cost_per_portion = Decimal(str(
|
|
round(float(item.unit_price) / (item.pack_quantity * defn.portions_per_unit), 4)
|
|
))
|
|
|
|
# Also re-apply pack_quantity if definition has it but line item doesn't
|
|
if item.pack_quantity is None and defn.pack_quantity:
|
|
item.pack_quantity = defn.pack_quantity
|
|
if item.unit_price:
|
|
item.cost_per_item = Decimal(str(round(float(item.unit_price) / defn.pack_quantity, 4)))
|
|
if item.portions_per_unit:
|
|
item.cost_per_portion = Decimal(str(
|
|
round(float(item.unit_price) / (defn.pack_quantity * item.portions_per_unit), 4)
|
|
))
|
|
|
|
# Re-apply unit_size and unit_size_type if definition has them
|
|
if item.unit_size is None and defn.unit_size:
|
|
item.unit_size = defn.unit_size
|
|
if item.unit_size_type is None and defn.unit_size_type:
|
|
item.unit_size_type = defn.unit_size_type
|
|
|
|
if updated_count > 0:
|
|
logger.info(f"get_invoice: Re-applied definitions to {updated_count} line items")
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
|
|
# Query disputes for this invoice
|
|
from models.dispute import InvoiceDispute, DisputeStatus
|
|
|
|
# Fetch all disputes for this invoice (with line_items for disputed line item IDs)
|
|
result = await db.execute(
|
|
select(InvoiceDispute).options(
|
|
selectinload(InvoiceDispute.line_items)
|
|
).where(
|
|
InvoiceDispute.invoice_id == invoice_id
|
|
).order_by(InvoiceDispute.opened_at.desc())
|
|
)
|
|
disputes_list = result.scalars().all()
|
|
|
|
dispute_count = len(disputes_list)
|
|
has_open_disputes = any(
|
|
d.status in [DisputeStatus.NEW, DisputeStatus.CONTACTED, DisputeStatus.AWAITING_CREDIT, DisputeStatus.AWAITING_REPLACEMENT]
|
|
for d in disputes_list
|
|
)
|
|
|
|
# Collect disputed line item IDs from all disputes
|
|
disputed_line_item_ids = []
|
|
for d in disputes_list:
|
|
for dli in d.line_items:
|
|
if dli.invoice_line_item_id and dli.invoice_line_item_id not in disputed_line_item_ids:
|
|
disputed_line_item_ids.append(dli.invoice_line_item_id)
|
|
|
|
# Format disputes for response
|
|
disputes = [
|
|
{
|
|
"id": d.id,
|
|
"dispute_type": d.dispute_type.value,
|
|
"status": d.status.value,
|
|
"title": d.title,
|
|
"disputed_amount": float(d.disputed_amount) if d.disputed_amount else 0,
|
|
"opened_at": d.opened_at.isoformat()
|
|
}
|
|
for d in disputes_list
|
|
]
|
|
|
|
return invoice_to_response(invoice, dispute_count=dispute_count, has_open_disputes=has_open_disputes, disputes=disputes, disputed_line_item_ids=disputed_line_item_ids)
|
|
|
|
|
|
@router.get("/{invoice_id}/image")
|
|
async def get_invoice_image(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get the invoice image or PDF file (requires auth header)"""
|
|
from starlette.responses import Response
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
# Use archival service to get file content (handles local and Nextcloud)
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
success, result = await archival_service.get_file_content(invoice)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail=f"File not found: {result}")
|
|
|
|
ext = invoice.image_path.split(".")[-1].lower()
|
|
media_types = {
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"png": "image/png",
|
|
"webp": "image/webp",
|
|
"heic": "image/heic",
|
|
"pdf": "application/pdf",
|
|
}
|
|
media_type = media_types.get(ext, "application/octet-stream")
|
|
|
|
return Response(
|
|
content=result,
|
|
media_type=media_type,
|
|
headers={
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
"Cache-Control": "max-age=3600",
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/{invoice_id}/file")
|
|
async def get_invoice_file(
|
|
invoice_id: int,
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get invoice file (image or PDF) with token in query param - works through proxies"""
|
|
from auth import get_current_user, require_cap_from_token
|
|
from starlette.responses import Response
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
# Verify token and get user
|
|
current_user = await get_current_user_from_token(token, db)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
# Use archival service to get file content (handles local and Nextcloud)
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
success, result = await archival_service.get_file_content(invoice)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail=f"File not found: {result}")
|
|
|
|
ext = invoice.image_path.split(".")[-1].lower()
|
|
media_types = {
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"png": "image/png",
|
|
"webp": "image/webp",
|
|
"heic": "image/heic",
|
|
"pdf": "application/pdf",
|
|
}
|
|
media_type = media_types.get(ext, "application/octet-stream")
|
|
|
|
return Response(
|
|
content=result,
|
|
media_type=media_type,
|
|
headers={
|
|
"Content-Disposition": "inline",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
"X-Frame-Options": "SAMEORIGIN",
|
|
"Content-Security-Policy": "frame-ancestors 'self'",
|
|
"Cache-Control": "no-cache",
|
|
}
|
|
)
|
|
|
|
|
|
@router.get("/{invoice_id}/pdf")
|
|
async def get_invoice_pdf(
|
|
invoice_id: int,
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get invoice PDF with token in query param (for iframe embedding) - DEPRECATED, use /file"""
|
|
from auth import get_current_user, require_cap_from_token
|
|
from starlette.responses import Response
|
|
|
|
# Verify token and get user
|
|
current_user = await get_current_user_from_token(token, db)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
if not os.path.exists(invoice.image_path):
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
# Read file and return with headers that allow iframe/object embedding through proxies
|
|
with open(invoice.image_path, "rb") as f:
|
|
content = f.read()
|
|
|
|
return Response(
|
|
content=content,
|
|
media_type="application/pdf",
|
|
headers={
|
|
"Content-Disposition": "inline",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
"Cache-Control": "no-cache",
|
|
}
|
|
)
|
|
|
|
|
|
@router.patch("/{invoice_id}", response_model=InvoiceResponse)
|
|
async def update_invoice(
|
|
invoice_id: int,
|
|
update: InvoiceUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update invoice data (for manual corrections)"""
|
|
from sqlalchemy import or_
|
|
from models.dispute import InvoiceDispute, DisputeStatus
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
# Track if supplier is being changed
|
|
old_supplier_id = invoice.supplier_id
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
new_supplier_id = update_data.get("supplier_id")
|
|
supplier_changed = new_supplier_id is not None and new_supplier_id != old_supplier_id
|
|
|
|
# Check for open disputes before confirming invoice
|
|
if update_data.get("status") == "CONFIRMED":
|
|
result = await db.execute(
|
|
select(func.count(InvoiceDispute.id)).where(
|
|
InvoiceDispute.invoice_id == invoice_id,
|
|
InvoiceDispute.status.in_([
|
|
DisputeStatus.OPEN,
|
|
DisputeStatus.CONTACTED,
|
|
DisputeStatus.IN_PROGRESS,
|
|
DisputeStatus.AWAITING_CREDIT,
|
|
DisputeStatus.AWAITING_REPLACEMENT
|
|
])
|
|
)
|
|
)
|
|
open_dispute_count = result.scalar() or 0
|
|
|
|
if open_dispute_count > 0:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"error": "cannot_confirm_with_disputes",
|
|
"message": f"Cannot confirm invoice with {open_dispute_count} open dispute(s). Resolve all disputes before confirming.",
|
|
"dispute_count": open_dispute_count
|
|
}
|
|
)
|
|
|
|
# Check invoice has a date set before confirming
|
|
# Use the new date if being updated, otherwise check existing
|
|
new_date = update_data.get("invoice_date")
|
|
effective_date = new_date if new_date is not None else invoice.invoice_date
|
|
if not effective_date:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"error": "cannot_confirm_without_date",
|
|
"message": "Cannot confirm invoice without a date. Please set the invoice date before confirming."
|
|
}
|
|
)
|
|
|
|
for field, value in update_data.items():
|
|
if field == "status" and value:
|
|
setattr(invoice, field, InvoiceStatus(value))
|
|
else:
|
|
setattr(invoice, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
|
|
# If supplier changed, auto-apply product definitions to line items
|
|
if supplier_changed and new_supplier_id:
|
|
logger.info(f"update_invoice: supplier changed from {old_supplier_id} to {new_supplier_id}, applying definitions")
|
|
|
|
# Get all line items for this invoice
|
|
result = await db.execute(
|
|
select(LineItem)
|
|
.where(LineItem.invoice_id == invoice_id)
|
|
)
|
|
line_items = result.scalars().all()
|
|
|
|
if line_items:
|
|
# Get definitions for the new supplier (or kitchen-wide)
|
|
conditions = [
|
|
ProductDefinition.kitchen_id == current_user.kitchen_id,
|
|
or_(
|
|
ProductDefinition.supplier_id == new_supplier_id,
|
|
ProductDefinition.supplier_id.is_(None)
|
|
)
|
|
]
|
|
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(*conditions)
|
|
)
|
|
all_definitions = result.scalars().all()
|
|
|
|
# Build lookup dicts - prefer supplier-specific over kitchen-wide
|
|
definitions_by_code = {}
|
|
definitions_by_desc = [] # List of (normalized_pattern, definition) tuples
|
|
|
|
for d in all_definitions:
|
|
if d.product_code:
|
|
if d.product_code in definitions_by_code:
|
|
existing = definitions_by_code[d.product_code]
|
|
if existing.supplier_id and not d.supplier_id:
|
|
continue
|
|
definitions_by_code[d.product_code] = d
|
|
if d.description_pattern:
|
|
norm_pattern = normalize_description(d.description_pattern)
|
|
if norm_pattern:
|
|
definitions_by_desc.append((norm_pattern, d))
|
|
|
|
# Sort description patterns: prefer supplier-specific first, then by length
|
|
definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0])))
|
|
|
|
logger.info(f"update_invoice: found {len(definitions_by_code)} code definitions, {len(definitions_by_desc)} description definitions for supplier {new_supplier_id}")
|
|
|
|
def find_definition_for_item(item: LineItem) -> ProductDefinition | None:
|
|
if item.product_code and item.product_code in definitions_by_code:
|
|
return definitions_by_code[item.product_code]
|
|
if item.description:
|
|
item_desc_norm = normalize_description(item.description)
|
|
for pattern, defn in definitions_by_desc:
|
|
if pattern in item_desc_norm:
|
|
return defn
|
|
return None
|
|
|
|
# Apply definitions to line items
|
|
for item in line_items:
|
|
defn = find_definition_for_item(item)
|
|
if not defn:
|
|
continue
|
|
|
|
match_type = "product_code" if item.product_code and item.product_code in definitions_by_code else "description"
|
|
logger.info(f"update_invoice: applying definition (matched by {match_type}) to item: code={item.product_code}, desc={item.description[:50] if item.description else ''}")
|
|
|
|
# Only update if portions_per_unit is not already set
|
|
if item.portions_per_unit is None and defn.portions_per_unit:
|
|
item.portions_per_unit = defn.portions_per_unit
|
|
|
|
# Recalculate cost_per_portion
|
|
if item.pack_quantity and item.unit_price:
|
|
item.cost_per_portion = Decimal(str(
|
|
round(float(item.unit_price) / (item.pack_quantity * defn.portions_per_unit), 4)
|
|
))
|
|
|
|
# Also apply pack_quantity if OCR didn't find it
|
|
if item.pack_quantity is None and defn.pack_quantity:
|
|
item.pack_quantity = defn.pack_quantity
|
|
if item.unit_price:
|
|
item.cost_per_item = Decimal(str(
|
|
round(float(item.unit_price) / defn.pack_quantity, 4)
|
|
))
|
|
if item.portions_per_unit and item.unit_price:
|
|
item.cost_per_portion = Decimal(str(
|
|
round(float(item.unit_price) / (defn.pack_quantity * item.portions_per_unit), 4)
|
|
))
|
|
# Apply unit_size and unit_size_type if definition has them
|
|
if item.unit_size is None and defn.unit_size:
|
|
item.unit_size = defn.unit_size
|
|
if item.unit_size_type is None and defn.unit_size_type:
|
|
item.unit_size_type = defn.unit_size_type
|
|
|
|
await db.commit()
|
|
|
|
# Auto-update PDF highlights/notes overlay when notes change (if annotations enabled)
|
|
if "notes" in update_data:
|
|
try:
|
|
from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items
|
|
from sqlalchemy.orm import selectinload
|
|
from models.settings import KitchenSettings
|
|
import json
|
|
|
|
# Check if PDF annotations are enabled in settings
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
|
|
if settings and settings.pdf_annotations_enabled:
|
|
# Load full invoice with all line items and OCR data
|
|
invoice_result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.line_items)
|
|
).where(Invoice.id == invoice_id)
|
|
)
|
|
full_invoice = invoice_result.scalar_one_or_none()
|
|
|
|
if full_invoice and full_invoice.ocr_raw_json and os.path.exists(full_invoice.image_path):
|
|
ocr_data = json.loads(full_invoice.ocr_raw_json)
|
|
ocr_line_items = parse_azure_ocr_line_items(ocr_data)
|
|
|
|
non_stock_items = [item for item in full_invoice.line_items if item.is_non_stock]
|
|
|
|
# Create backup if doesn't exist (safety measure)
|
|
import shutil
|
|
backup_path = full_invoice.image_path + '.original'
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(full_invoice.image_path, backup_path)
|
|
|
|
# Regenerate all highlights/notes (clears existing annotations first)
|
|
# This works even with empty ocr_line_items - notes overlay still gets added
|
|
highlighter = PDFHighlighter(full_invoice.image_path)
|
|
highlighter.highlight_items_with_ocr_data(
|
|
ocr_line_items=ocr_line_items,
|
|
non_stock_line_items=non_stock_items,
|
|
output_path=full_invoice.image_path,
|
|
notes=full_invoice.notes,
|
|
ocr_data=ocr_data
|
|
)
|
|
logger.info(f"Auto-updated PDF notes overlay: notes={'updated' if full_invoice.notes else 'cleared'}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to auto-update PDF notes overlay: {e}")
|
|
|
|
# Auto-send to Dext when invoice is confirmed (if setting enabled)
|
|
# Only send if status is changing TO confirmed and not already sent
|
|
if update_data.get("status") == "CONFIRMED" and not invoice.dext_sent_at:
|
|
try:
|
|
from models.settings import KitchenSettings
|
|
from services.email_service import EmailService, generate_dext_email_html, generate_dext_email_plain
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
# Check if auto-send is enabled
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
|
|
if settings and settings.dext_auto_send_enabled and settings.dext_email:
|
|
# Validate SMTP is configured
|
|
if settings.smtp_host and settings.smtp_from_email:
|
|
# Load full invoice with line items and supplier
|
|
invoice_result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.line_items),
|
|
selectinload(Invoice.supplier)
|
|
).where(Invoice.id == invoice_id)
|
|
)
|
|
full_invoice = invoice_result.scalar_one_or_none()
|
|
|
|
# Skip if supplier has skip_dext enabled
|
|
if full_invoice and full_invoice.supplier and full_invoice.supplier.skip_dext:
|
|
logger.info(f"Skipping Dext auto-send for invoice {invoice_id}: supplier '{full_invoice.supplier.name}' has skip_dext enabled")
|
|
elif full_invoice and full_invoice.image_path and os.path.exists(full_invoice.image_path):
|
|
# Update PDF highlights if annotations enabled
|
|
if settings.dext_include_annotations:
|
|
try:
|
|
from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items
|
|
import json
|
|
|
|
ocr_data = json.loads(full_invoice.ocr_raw_json) if full_invoice.ocr_raw_json else {}
|
|
ocr_line_items = parse_azure_ocr_line_items(ocr_data)
|
|
|
|
if ocr_line_items:
|
|
non_stock_items = [item for item in full_invoice.line_items if item.is_non_stock]
|
|
|
|
import shutil
|
|
backup_path = full_invoice.image_path + '.original'
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(full_invoice.image_path, backup_path)
|
|
|
|
highlighter = PDFHighlighter(full_invoice.image_path)
|
|
highlighter.highlight_items_with_ocr_data(
|
|
ocr_line_items=ocr_line_items,
|
|
non_stock_line_items=non_stock_items,
|
|
output_path=full_invoice.image_path,
|
|
notes=full_invoice.notes,
|
|
ocr_data=ocr_data
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Dext auto-send: PDF highlighting failed: {e}")
|
|
|
|
# Read file
|
|
with open(full_invoice.image_path, 'rb') as f:
|
|
file_bytes = f.read()
|
|
|
|
ext = full_invoice.image_path.split('.')[-1].lower()
|
|
filename = f"{full_invoice.invoice_number or 'invoice'}_{full_invoice.invoice_date.strftime('%Y%m%d') if full_invoice.invoice_date else 'unknown'}.{ext}"
|
|
|
|
supplier_name = full_invoice.supplier.name if full_invoice.supplier else None
|
|
html_body = generate_dext_email_html(
|
|
invoice=full_invoice,
|
|
supplier_name=supplier_name,
|
|
line_items=full_invoice.line_items,
|
|
notes=full_invoice.notes,
|
|
include_notes=settings.dext_include_notes,
|
|
include_non_stock=settings.dext_include_non_stock
|
|
)
|
|
plain_body = generate_dext_email_plain(
|
|
invoice=full_invoice,
|
|
supplier_name=supplier_name,
|
|
line_items=full_invoice.line_items,
|
|
notes=full_invoice.notes,
|
|
include_notes=settings.dext_include_notes,
|
|
include_non_stock=settings.dext_include_non_stock
|
|
)
|
|
|
|
email_service = EmailService(settings)
|
|
subject = f"Invoice {full_invoice.invoice_number or 'N/A'} - {supplier_name or 'Unknown Supplier'}"
|
|
|
|
success = email_service.send_email(
|
|
to_email=settings.dext_email,
|
|
subject=subject,
|
|
html_body=html_body,
|
|
plain_body=plain_body,
|
|
attachments=[(filename, file_bytes)]
|
|
)
|
|
|
|
if success:
|
|
# Update dext status in database
|
|
from datetime import datetime
|
|
now = datetime.utcnow()
|
|
logger.info(f"Dext auto-send success, updating dext_sent_at for invoice {invoice_id}")
|
|
|
|
# Use raw SQL update to ensure it's committed properly
|
|
from sqlalchemy import update as sql_update
|
|
update_result = await db.execute(
|
|
sql_update(Invoice)
|
|
.where(Invoice.id == invoice_id)
|
|
.values(dext_sent_at=now, dext_sent_by_user_id=current_user.id)
|
|
)
|
|
logger.info(f"SQL update affected {update_result.rowcount} rows")
|
|
await db.commit()
|
|
|
|
# Verify the update by querying fresh
|
|
verify_result = await db.execute(
|
|
select(Invoice.dext_sent_at).where(Invoice.id == invoice_id)
|
|
)
|
|
verified_value = verify_result.scalar_one_or_none()
|
|
logger.info(f"Verified dext_sent_at in DB: {verified_value}")
|
|
|
|
# Update the invoice object for the response
|
|
invoice.dext_sent_at = now
|
|
invoice.dext_sent_by_user_id = current_user.id
|
|
logger.info(f"Auto-sent invoice {invoice_id} to Dext on confirm, invoice.dext_sent_at={invoice.dext_sent_at}")
|
|
else:
|
|
logger.warning(f"Dext auto-send failed for invoice {invoice_id}")
|
|
else:
|
|
logger.warning(f"Dext auto-send: Invoice file not found for {invoice_id}")
|
|
else:
|
|
logger.debug("Dext auto-send: SMTP not configured")
|
|
except Exception as e:
|
|
logger.warning(f"Dext auto-send failed: {e}")
|
|
|
|
return invoice_to_response(invoice)
|
|
|
|
|
|
@router.delete("/{invoice_id}")
|
|
async def delete_invoice(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Delete an invoice"""
|
|
from sqlalchemy import update
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
# Clear any references from other invoices pointing to this one
|
|
await db.execute(
|
|
update(Invoice)
|
|
.where(Invoice.duplicate_of_id == invoice_id)
|
|
.values(duplicate_of_id=None, duplicate_status=None)
|
|
)
|
|
await db.execute(
|
|
update(Invoice)
|
|
.where(Invoice.related_document_id == invoice_id)
|
|
.values(related_document_id=None)
|
|
)
|
|
|
|
# Handle file deletion (copies to deleted folder if on Nextcloud)
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
await archival_service.handle_invoice_deletion(invoice)
|
|
|
|
await db.delete(invoice)
|
|
await db.commit()
|
|
|
|
return {"message": "Invoice deleted"}
|
|
|
|
|
|
# Duplicate detection endpoint
|
|
@router.get("/{invoice_id}/duplicates", response_model=DuplicateCompareResponse)
|
|
async def get_invoice_duplicates(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get duplicate comparison info for an invoice"""
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
detector = DuplicateDetector(db, current_user.kitchen_id)
|
|
duplicates = await detector.check_duplicates(invoice)
|
|
|
|
return DuplicateCompareResponse(
|
|
current_invoice=invoice_to_response(invoice),
|
|
firm_duplicate=invoice_to_response(duplicates["firm_duplicate"]) if duplicates["firm_duplicate"] else None,
|
|
possible_duplicates=[invoice_to_response(d) for d in duplicates["possible_duplicates"]],
|
|
related_documents=[invoice_to_response(d) for d in duplicates["related_documents"]]
|
|
)
|
|
|
|
|
|
# Line item endpoints
|
|
@router.get("/{invoice_id}/line-items", response_model=list[LineItemResponse])
|
|
async def get_line_items(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get line items for an invoice with price change detection"""
|
|
from services.price_history import PriceHistoryService
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
from sqlalchemy.orm import selectinload
|
|
result = await db.execute(
|
|
select(LineItem)
|
|
.options(selectinload(LineItem.ingredient))
|
|
.where(LineItem.invoice_id == invoice_id)
|
|
.order_by(LineItem.line_number)
|
|
)
|
|
items = result.scalars().all()
|
|
|
|
# Get page numbers from OCR data (maps line_number -> page_number)
|
|
page_numbers = get_line_item_page_numbers_by_line_number(invoice)
|
|
|
|
# Initialize price history service for price status calculation
|
|
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
|
|
|
responses = []
|
|
for item in items:
|
|
# Calculate price status if item has unit_price and supplier
|
|
price_change_status = None
|
|
price_change_percent = None
|
|
previous_price = None
|
|
future_price = None
|
|
future_change_percent = None
|
|
|
|
if item.unit_price and invoice.supplier_id:
|
|
try:
|
|
status = await price_service.get_price_status(
|
|
supplier_id=invoice.supplier_id,
|
|
product_code=item.product_code,
|
|
description=item.description,
|
|
current_price=item.unit_price,
|
|
unit=item.unit,
|
|
current_invoice_id=invoice_id,
|
|
reference_date=invoice.invoice_date
|
|
)
|
|
price_change_status = status.status
|
|
price_change_percent = status.change_percent
|
|
previous_price = float(status.previous_price) if status.previous_price else None
|
|
future_price = float(status.future_price) if status.future_price else None
|
|
future_change_percent = status.future_change_percent
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get price status for line item {item.id}: {e}")
|
|
|
|
# Auto-detect pack size from description/raw_content/unit field
|
|
pq, us, ust = detect_pack_size(item)
|
|
|
|
responses.append(LineItemResponse(
|
|
id=item.id,
|
|
product_code=item.product_code,
|
|
description=item.description,
|
|
description_alt=item.description_alt,
|
|
unit=item.unit,
|
|
quantity=float(item.quantity) if item.quantity else None,
|
|
order_quantity=float(item.order_quantity) if item.order_quantity else None,
|
|
unit_price=float(item.unit_price) if item.unit_price else None,
|
|
tax_rate=item.tax_rate,
|
|
tax_amount=float(item.tax_amount) if item.tax_amount else None,
|
|
amount=float(item.amount) if item.amount else None,
|
|
line_number=item.line_number,
|
|
is_non_stock=item.is_non_stock or False,
|
|
raw_content=item.raw_content,
|
|
pack_quantity=pq,
|
|
unit_size=float(us) if us else None,
|
|
unit_size_type=ust,
|
|
portions_per_unit=item.portions_per_unit,
|
|
cost_per_item=float(item.cost_per_item) if item.cost_per_item else None,
|
|
cost_per_portion=float(item.cost_per_portion) if item.cost_per_portion else None,
|
|
ocr_warnings=item.ocr_warnings,
|
|
price_change_status=price_change_status,
|
|
price_change_percent=price_change_percent,
|
|
previous_price=previous_price,
|
|
future_price=future_price,
|
|
future_change_percent=future_change_percent,
|
|
page_number=page_numbers.get(item.line_number, 1),
|
|
ingredient_id=item.ingredient_id,
|
|
ingredient_name=item.ingredient.name if item.ingredient else None,
|
|
ingredient_unit=item.ingredient.standard_unit if item.ingredient else None,
|
|
))
|
|
|
|
return responses
|
|
|
|
|
|
@router.post("/{invoice_id}/line-items", response_model=LineItemResponse)
|
|
async def add_line_item(
|
|
invoice_id: int,
|
|
item: LineItemCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Add a new line item to an invoice"""
|
|
await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
result = await db.execute(
|
|
select(func.max(LineItem.line_number))
|
|
.where(LineItem.invoice_id == invoice_id)
|
|
)
|
|
max_num = result.scalar() or -1
|
|
|
|
# Calculate costs if pack_quantity and unit_price provided
|
|
cost_per_item = None
|
|
cost_per_portion = None
|
|
if item.pack_quantity and item.unit_price:
|
|
cost_per_item = round(item.unit_price / item.pack_quantity, 4)
|
|
# Only calculate cost_per_portion if portions_per_unit is explicitly set
|
|
if item.portions_per_unit:
|
|
cost_per_portion = round(item.unit_price / (item.pack_quantity * item.portions_per_unit), 4)
|
|
|
|
line_item = LineItem(
|
|
invoice_id=invoice_id,
|
|
product_code=item.product_code,
|
|
description=item.description,
|
|
unit=item.unit,
|
|
quantity=Decimal(str(item.quantity)) if item.quantity else None,
|
|
order_quantity=Decimal(str(item.order_quantity)) if item.order_quantity else None,
|
|
unit_price=Decimal(str(item.unit_price)) if item.unit_price else None,
|
|
tax_rate=item.tax_rate,
|
|
tax_amount=Decimal(str(item.tax_amount)) if item.tax_amount else None,
|
|
amount=Decimal(str(item.amount)) if item.amount else None,
|
|
line_number=max_num + 1,
|
|
is_non_stock=item.is_non_stock,
|
|
raw_content=item.raw_content,
|
|
pack_quantity=item.pack_quantity,
|
|
unit_size=Decimal(str(item.unit_size)) if item.unit_size else None,
|
|
unit_size_type=item.unit_size_type,
|
|
portions_per_unit=item.portions_per_unit,
|
|
cost_per_item=Decimal(str(cost_per_item)) if cost_per_item else None,
|
|
cost_per_portion=Decimal(str(cost_per_portion)) if cost_per_portion else None
|
|
)
|
|
db.add(line_item)
|
|
await db.commit()
|
|
await db.refresh(line_item)
|
|
|
|
# Auto-detect pack size from description/raw_content/unit field
|
|
pq, us, ust = detect_pack_size(line_item)
|
|
|
|
return LineItemResponse(
|
|
id=line_item.id,
|
|
product_code=line_item.product_code,
|
|
description=line_item.description,
|
|
description_alt=line_item.description_alt,
|
|
unit=line_item.unit,
|
|
quantity=float(line_item.quantity) if line_item.quantity else None,
|
|
order_quantity=float(line_item.order_quantity) if line_item.order_quantity else None,
|
|
unit_price=float(line_item.unit_price) if line_item.unit_price else None,
|
|
tax_rate=line_item.tax_rate,
|
|
tax_amount=float(line_item.tax_amount) if line_item.tax_amount else None,
|
|
amount=float(line_item.amount) if line_item.amount else None,
|
|
line_number=line_item.line_number,
|
|
is_non_stock=line_item.is_non_stock or False,
|
|
raw_content=line_item.raw_content,
|
|
pack_quantity=pq,
|
|
unit_size=float(us) if us else None,
|
|
unit_size_type=ust,
|
|
portions_per_unit=line_item.portions_per_unit, # Return actual value (null if not defined)
|
|
cost_per_item=float(line_item.cost_per_item) if line_item.cost_per_item else None,
|
|
cost_per_portion=float(line_item.cost_per_portion) if line_item.cost_per_portion else None,
|
|
ocr_warnings=line_item.ocr_warnings
|
|
)
|
|
|
|
|
|
@router.patch("/{invoice_id}/line-items/{item_id}", response_model=LineItemResponse)
|
|
async def update_line_item(
|
|
invoice_id: int,
|
|
item_id: int,
|
|
update: LineItemUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Update a line item"""
|
|
await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
result = await db.execute(
|
|
select(LineItem).where(
|
|
LineItem.id == item_id,
|
|
LineItem.invoice_id == invoice_id
|
|
)
|
|
)
|
|
line_item = result.scalar_one_or_none()
|
|
if not line_item:
|
|
raise HTTPException(status_code=404, detail="Line item not found")
|
|
|
|
update_data = update.model_dump(exclude_unset=True)
|
|
decimal_fields = ["quantity", "order_quantity", "unit_price", "tax_amount", "amount", "unit_size", "cost_per_item", "cost_per_portion"]
|
|
for field, value in update_data.items():
|
|
if value is not None and field in decimal_fields:
|
|
setattr(line_item, field, Decimal(str(value)))
|
|
else:
|
|
setattr(line_item, field, value)
|
|
|
|
# Clear OCR warnings if user manually corrects any of the affected fields
|
|
ocr_warning_fields = {"quantity", "unit_price", "amount", "unit_size", "pack_quantity"}
|
|
if ocr_warning_fields & set(update_data.keys()):
|
|
line_item.ocr_warnings = None
|
|
|
|
# Recalculate costs if pack fields or unit_price changed
|
|
recalc_fields = {"pack_quantity", "portions_per_unit", "unit_price"}
|
|
if recalc_fields & set(update_data.keys()):
|
|
if line_item.pack_quantity and line_item.unit_price:
|
|
line_item.cost_per_item = Decimal(str(
|
|
round(float(line_item.unit_price) / line_item.pack_quantity, 4)
|
|
))
|
|
# Only calculate cost_per_portion if portions_per_unit is explicitly set
|
|
if line_item.portions_per_unit:
|
|
line_item.cost_per_portion = Decimal(str(
|
|
round(float(line_item.unit_price) / (line_item.pack_quantity * line_item.portions_per_unit), 4)
|
|
))
|
|
else:
|
|
line_item.cost_per_portion = None
|
|
|
|
await db.commit()
|
|
await db.refresh(line_item)
|
|
|
|
# Auto-update ingredient source price when unit_price changes
|
|
if "unit_price" in update_data:
|
|
try:
|
|
from api.ingredients import update_ingredient_prices_for_invoice
|
|
from api.recipes import snapshot_recipes_using_ingredient
|
|
updated_ingredients = await update_ingredient_prices_for_invoice(invoice_id, current_user.kitchen_id, db)
|
|
if updated_ingredients:
|
|
for ing_id, price_info in updated_ingredients.items():
|
|
await snapshot_recipes_using_ingredient(
|
|
ing_id, db,
|
|
trigger_source=f"line_item_update: #{item_id}",
|
|
price_info=price_info,
|
|
invoice_id=invoice_id,
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning(f"Ingredient price auto-update failed: {e}")
|
|
|
|
# Auto-update PDF highlights when is_non_stock status changes (if annotations enabled)
|
|
if "is_non_stock" in update_data:
|
|
try:
|
|
from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items
|
|
from sqlalchemy.orm import selectinload
|
|
from models.settings import KitchenSettings
|
|
import json
|
|
|
|
# Check if PDF annotations are enabled in settings
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
|
|
if settings and settings.pdf_annotations_enabled:
|
|
# Load full invoice with all line items and OCR data
|
|
invoice_result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.line_items)
|
|
).where(Invoice.id == invoice_id)
|
|
)
|
|
invoice = invoice_result.scalar_one_or_none()
|
|
|
|
if invoice and invoice.ocr_raw_json and os.path.exists(invoice.image_path):
|
|
ocr_data = json.loads(invoice.ocr_raw_json)
|
|
ocr_line_items = parse_azure_ocr_line_items(ocr_data)
|
|
|
|
non_stock_items = [item for item in invoice.line_items if item.is_non_stock]
|
|
|
|
# Create backup if doesn't exist (safety measure)
|
|
import shutil
|
|
backup_path = invoice.image_path + '.original'
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(invoice.image_path, backup_path)
|
|
|
|
# Regenerate all highlights (clears existing, adds for current non-stock items)
|
|
# Works even with empty ocr_line_items - notes overlay still preserved
|
|
highlighter = PDFHighlighter(invoice.image_path)
|
|
highlighter.highlight_items_with_ocr_data(
|
|
ocr_line_items=ocr_line_items,
|
|
non_stock_line_items=non_stock_items,
|
|
output_path=invoice.image_path,
|
|
notes=invoice.notes,
|
|
ocr_data=ocr_data
|
|
)
|
|
logger.info(f"Auto-updated PDF highlights: {len(non_stock_items)} non-stock items")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to auto-update PDF highlights: {e}")
|
|
|
|
# Auto-detect pack size from description/raw_content/unit field
|
|
pq, us, ust = detect_pack_size(line_item)
|
|
|
|
return LineItemResponse(
|
|
id=line_item.id,
|
|
product_code=line_item.product_code,
|
|
description=line_item.description,
|
|
description_alt=line_item.description_alt,
|
|
unit=line_item.unit,
|
|
quantity=float(line_item.quantity) if line_item.quantity else None,
|
|
order_quantity=float(line_item.order_quantity) if line_item.order_quantity else None,
|
|
unit_price=float(line_item.unit_price) if line_item.unit_price else None,
|
|
tax_rate=line_item.tax_rate,
|
|
tax_amount=float(line_item.tax_amount) if line_item.tax_amount else None,
|
|
amount=float(line_item.amount) if line_item.amount else None,
|
|
line_number=line_item.line_number,
|
|
is_non_stock=line_item.is_non_stock or False,
|
|
raw_content=line_item.raw_content,
|
|
pack_quantity=pq,
|
|
unit_size=float(us) if us else None,
|
|
unit_size_type=ust,
|
|
portions_per_unit=line_item.portions_per_unit, # Return actual value (null if not defined)
|
|
cost_per_item=float(line_item.cost_per_item) if line_item.cost_per_item else None,
|
|
cost_per_portion=float(line_item.cost_per_portion) if line_item.cost_per_portion else None,
|
|
ocr_warnings=line_item.ocr_warnings
|
|
)
|
|
|
|
|
|
@router.delete("/{invoice_id}/line-items/{item_id}")
|
|
async def delete_line_item(
|
|
invoice_id: int,
|
|
item_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Delete a line item"""
|
|
await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
result = await db.execute(
|
|
select(LineItem).where(
|
|
LineItem.id == item_id,
|
|
LineItem.invoice_id == invoice_id
|
|
)
|
|
)
|
|
line_item = result.scalar_one_or_none()
|
|
if not line_item:
|
|
raise HTTPException(status_code=404, detail="Line item not found")
|
|
|
|
await db.delete(line_item)
|
|
await db.commit()
|
|
|
|
return {"message": "Line item deleted"}
|
|
|
|
|
|
# Raw OCR data endpoint
|
|
@router.get("/{invoice_id}/ocr-data")
|
|
async def get_invoice_ocr_data(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get the raw OCR data (text and JSON) for an invoice"""
|
|
import json as json_module
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
raw_json = None
|
|
if invoice.ocr_raw_json:
|
|
try:
|
|
raw_json = json_module.loads(invoice.ocr_raw_json)
|
|
except json_module.JSONDecodeError:
|
|
raw_json = None
|
|
|
|
return {
|
|
"invoice_id": invoice.id,
|
|
"raw_text": invoice.ocr_raw_text,
|
|
"raw_json": raw_json,
|
|
"confidence": float(invoice.ocr_confidence) if invoice.ocr_confidence else None
|
|
}
|
|
|
|
|
|
@router.get("/{invoice_id}/line-items/{line_number}/preview")
|
|
async def get_line_item_preview(
|
|
invoice_id: int,
|
|
line_number: int,
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get a cropped image preview of a specific line item from the invoice OCR bounding box."""
|
|
import json as json_module
|
|
import io
|
|
from auth import get_current_user, require_cap_from_token
|
|
from starlette.responses import Response
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
current_user = await get_current_user_from_token(token, db)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
# Parse OCR JSON to get bounding box
|
|
if not invoice.ocr_raw_json:
|
|
raise HTTPException(status_code=404, detail="No OCR data")
|
|
|
|
try:
|
|
ocr_json = json_module.loads(invoice.ocr_raw_json)
|
|
except json_module.JSONDecodeError:
|
|
raise HTTPException(status_code=404, detail="Invalid OCR data")
|
|
|
|
# Navigate to line item bounding box
|
|
try:
|
|
items = ocr_json["documents"][0]["fields"]["Items"]["value"]
|
|
if line_number < 0 or line_number >= len(items):
|
|
raise HTTPException(status_code=404, detail="Line number out of range")
|
|
region = items[line_number].get("bounding_regions", [{}])[0]
|
|
polygon = region.get("polygon")
|
|
page_number = region.get("page_number", 1)
|
|
if not polygon or len(polygon) < 4:
|
|
raise HTTPException(status_code=404, detail="No bounding box")
|
|
except (KeyError, IndexError):
|
|
raise HTTPException(status_code=404, detail="No bounding box data")
|
|
|
|
# Calculate bounding box from polygon
|
|
xs = [p[0] for p in polygon]
|
|
ys = [p[1] for p in polygon]
|
|
x0_inches, x1_inches = min(xs), max(xs)
|
|
y0_inches, y1_inches = min(ys), max(ys)
|
|
|
|
# Get page dimensions
|
|
pages = ocr_json.get("pages", [])
|
|
page_info = pages[page_number - 1] if page_number <= len(pages) else {}
|
|
page_w_inches = page_info.get("width", 8.5)
|
|
page_h_inches = page_info.get("height", 11)
|
|
|
|
# Padding in inches
|
|
pad = 0.15
|
|
|
|
# Get the file content
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
success, file_bytes = await archival_service.get_file_content(invoice)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
ext = invoice.image_path.split(".")[-1].lower()
|
|
|
|
if ext == "pdf":
|
|
# PDF: use PyMuPDF to render page crop
|
|
import fitz
|
|
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
|
page = doc[page_number - 1]
|
|
|
|
# Convert inches to points (72 pts/inch)
|
|
rect = fitz.Rect(
|
|
(x0_inches - pad) * 72,
|
|
(y0_inches - pad) * 72,
|
|
(x1_inches + pad) * 72,
|
|
(y1_inches + pad) * 72,
|
|
)
|
|
rect = rect & page.rect # clip to page bounds
|
|
|
|
pixmap = page.get_pixmap(clip=rect, dpi=200)
|
|
img_bytes = pixmap.tobytes("png")
|
|
doc.close()
|
|
return Response(content=img_bytes, media_type="image/png", headers={"Cache-Control": "max-age=3600"})
|
|
else:
|
|
# Image: use Pillow to crop
|
|
from PIL import Image
|
|
img = Image.open(io.BytesIO(file_bytes))
|
|
w, h = img.size
|
|
|
|
# Convert inch percentages to pixels
|
|
crop_box = (
|
|
max(0, int((x0_inches / page_w_inches - pad / page_w_inches) * w)),
|
|
max(0, int((y0_inches / page_h_inches - pad / page_h_inches) * h)),
|
|
min(w, int((x1_inches / page_w_inches + pad / page_w_inches) * w)),
|
|
min(h, int((y1_inches / page_h_inches + pad / page_h_inches) * h)),
|
|
)
|
|
cropped = img.crop(crop_box)
|
|
buf = io.BytesIO()
|
|
cropped.save(buf, format="PNG")
|
|
return Response(content=buf.getvalue(), media_type="image/png", headers={"Cache-Control": "max-age=3600"})
|
|
|
|
|
|
# Field name mapping: URL param -> Azure OCR field key
|
|
_FIELD_KEY_MAP = {
|
|
"product_code": "ProductCode",
|
|
"description": "Description",
|
|
"unit_price": "UnitPrice",
|
|
"amount": "Amount",
|
|
"quantity": "Quantity",
|
|
}
|
|
|
|
|
|
@router.get("/{invoice_id}/line-items/{line_number}/preview/field/{field_name}")
|
|
async def get_line_item_field_preview(
|
|
invoice_id: int,
|
|
line_number: int,
|
|
field_name: str,
|
|
token: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get a cropped image preview of a specific field within a line item (e.g. product_code)."""
|
|
import json as json_module
|
|
import io
|
|
from auth import get_current_user, require_cap_from_token
|
|
from starlette.responses import Response
|
|
from services.file_archival_service import FileArchivalService
|
|
|
|
azure_key = _FIELD_KEY_MAP.get(field_name)
|
|
if not azure_key:
|
|
raise HTTPException(status_code=400, detail=f"Unknown field: {field_name}")
|
|
|
|
current_user = await get_current_user_from_token(token, db)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
if not invoice.ocr_raw_json:
|
|
raise HTTPException(status_code=404, detail="No OCR data")
|
|
|
|
try:
|
|
ocr_json = json_module.loads(invoice.ocr_raw_json)
|
|
except json_module.JSONDecodeError:
|
|
raise HTTPException(status_code=404, detail="Invalid OCR data")
|
|
|
|
# Navigate to the specific field's bounding box within the line item
|
|
try:
|
|
items = ocr_json["documents"][0]["fields"]["Items"]["value"]
|
|
if line_number < 0 or line_number >= len(items):
|
|
raise HTTPException(status_code=404, detail="Line number out of range")
|
|
item_value = items[line_number].get("value", {})
|
|
field_data = item_value.get(azure_key)
|
|
if not field_data:
|
|
raise HTTPException(status_code=404, detail=f"Field '{field_name}' not found in line item")
|
|
region = field_data.get("bounding_regions", [{}])[0]
|
|
polygon = region.get("polygon")
|
|
page_number = region.get("page_number", 1)
|
|
if not polygon or len(polygon) < 4:
|
|
raise HTTPException(status_code=404, detail="No bounding box for field")
|
|
except (KeyError, IndexError):
|
|
raise HTTPException(status_code=404, detail="No bounding box data")
|
|
|
|
xs = [p[0] for p in polygon]
|
|
ys = [p[1] for p in polygon]
|
|
x0_inches, x1_inches = min(xs), max(xs)
|
|
y0_inches, y1_inches = min(ys), max(ys)
|
|
|
|
pages = ocr_json.get("pages", [])
|
|
page_info = pages[page_number - 1] if page_number <= len(pages) else {}
|
|
page_w_inches = page_info.get("width", 8.5)
|
|
page_h_inches = page_info.get("height", 11)
|
|
|
|
pad = 0.05 # smaller padding for individual fields
|
|
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
success, file_bytes = await archival_service.get_file_content(invoice)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
ext = invoice.image_path.split(".")[-1].lower()
|
|
|
|
if ext == "pdf":
|
|
import fitz
|
|
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
|
page = doc[page_number - 1]
|
|
rect = fitz.Rect(
|
|
(x0_inches - pad) * 72,
|
|
(y0_inches - pad) * 72,
|
|
(x1_inches + pad) * 72,
|
|
(y1_inches + pad) * 72,
|
|
)
|
|
rect = rect & page.rect
|
|
pixmap = page.get_pixmap(clip=rect, dpi=200)
|
|
img_bytes = pixmap.tobytes("png")
|
|
doc.close()
|
|
return Response(content=img_bytes, media_type="image/png", headers={"Cache-Control": "max-age=3600"})
|
|
else:
|
|
from PIL import Image
|
|
img = Image.open(io.BytesIO(file_bytes))
|
|
w, h = img.size
|
|
crop_box = (
|
|
max(0, int((x0_inches / page_w_inches - pad / page_w_inches) * w)),
|
|
max(0, int((y0_inches / page_h_inches - pad / page_h_inches) * h)),
|
|
min(w, int((x1_inches / page_w_inches + pad / page_w_inches) * w)),
|
|
min(h, int((y1_inches / page_h_inches + pad / page_h_inches) * h)),
|
|
)
|
|
cropped = img.crop(crop_box)
|
|
buf = io.BytesIO()
|
|
cropped.save(buf, format="PNG")
|
|
return Response(content=buf.getvalue(), media_type="image/png", headers={"Cache-Control": "max-age=3600"})
|
|
|
|
|
|
@router.get("/{invoice_id}/parse-dates")
|
|
async def parse_dates_from_ocr(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Parse potential dates from invoice OCR raw text content"""
|
|
import json as _json
|
|
from datetime import datetime
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
raw_text = invoice.ocr_raw_text or ""
|
|
raw_json: dict = {}
|
|
if invoice.ocr_raw_json:
|
|
try:
|
|
raw_json = _json.loads(invoice.ocr_raw_json)
|
|
except Exception:
|
|
pass
|
|
|
|
# Date patterns to look for (UK/EU formats primarily)
|
|
# DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD
|
|
date_patterns = [
|
|
(r'\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b', 'dmy'), # DD.MM.YYYY or DD/MM/YYYY
|
|
(r'\b(\d{1,2})-(\d{1,2})-(\d{4})\b', 'dmy'), # DD-MM-YYYY
|
|
(r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b', 'ymd'), # YYYY-MM-DD (ISO)
|
|
]
|
|
|
|
found_dates = []
|
|
seen_dates = set() # Avoid duplicates
|
|
|
|
for pattern, fmt in date_patterns:
|
|
matches = re.finditer(pattern, raw_text)
|
|
for match in matches:
|
|
try:
|
|
if fmt == 'dmy':
|
|
day, month, year = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
else: # ymd
|
|
year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
|
|
# Validate date
|
|
if 1 <= day <= 31 and 1 <= month <= 12 and 1900 <= year <= 2100:
|
|
parsed_date = datetime(year, month, day).date()
|
|
date_str = parsed_date.isoformat()
|
|
|
|
if date_str not in seen_dates:
|
|
seen_dates.add(date_str)
|
|
|
|
start = max(0, match.start() - 50)
|
|
end = min(len(raw_text), match.end() + 20)
|
|
context = raw_text[start:end].replace('\n', ' ').strip()
|
|
|
|
found_dates.append({
|
|
"date": date_str,
|
|
"original": match.group(0),
|
|
"context": context,
|
|
"bbox": _find_word_bbox(raw_json, match.group(0)),
|
|
})
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
# Sort by date (most recent first might be invoice date)
|
|
found_dates.sort(key=lambda x: x["date"], reverse=True)
|
|
|
|
return {
|
|
"invoice_id": invoice.id,
|
|
"current_date": invoice.invoice_date.isoformat() if invoice.invoice_date else None,
|
|
"found_dates": found_dates
|
|
}
|
|
|
|
|
|
def _generalize_invoice_number_pattern(sample: str) -> str:
|
|
"""
|
|
Convert a known invoice number into a regex that matches similar-shaped numbers.
|
|
Uses tight ±1 range on digit runs to avoid matching phone/VAT/postcode numbers.
|
|
e.g. 'ID304574' → r'\bID\d{5,7}\b'
|
|
'INV-00123' → r'\bINV-\d{4,6}\b'
|
|
"""
|
|
parts = []
|
|
i = 0
|
|
while i < len(sample):
|
|
c = sample[i]
|
|
if c.isdigit():
|
|
j = i
|
|
while j < len(sample) and sample[j].isdigit():
|
|
j += 1
|
|
run = j - i
|
|
# Tight ±1 to avoid false positives like phone/VAT numbers
|
|
parts.append(rf'\d{{{max(1, run - 1)},{run + 1}}}')
|
|
i = j
|
|
elif c.isalpha():
|
|
j = i
|
|
while j < len(sample) and sample[j].isalpha():
|
|
j += 1
|
|
run = j - i
|
|
letters = sample[i:j]
|
|
if run <= 4:
|
|
parts.append(re.escape(letters)) # short prefix — exact match
|
|
else:
|
|
parts.append(rf'[A-Za-z]{{{run - 1},{run + 1}}}')
|
|
i = j
|
|
else:
|
|
parts.append(re.escape(c))
|
|
i += 1
|
|
return r'\b' + ''.join(parts) + r'\b'
|
|
|
|
|
|
def _find_word_bbox(raw_json: dict, text: str) -> dict | None:
|
|
"""
|
|
Search Azure OCR page words for a token exactly matching `text`.
|
|
Returns bbox as percentages of page dimensions so it can be used with
|
|
the same canvas-crop logic as field bounding boxes.
|
|
Handles both flat polygon [x1,y1,x2,y2,...] and nested [[x1,y1],...] formats.
|
|
"""
|
|
text_norm = text.strip().upper()
|
|
for page_idx, page in enumerate(raw_json.get('pages', [])):
|
|
page_width = page.get('width', 8.5) or 8.5
|
|
page_height = page.get('height', 11.0) or 11.0
|
|
for word in page.get('words', []):
|
|
if word.get('content', '').strip().upper() != text_norm:
|
|
continue
|
|
polygon = word.get('polygon', [])
|
|
if not polygon:
|
|
continue
|
|
# Flat: [x1,y1,x2,y2,...] vs nested: [[x1,y1],[x2,y2],...]
|
|
if isinstance(polygon[0], (int, float)):
|
|
xs = polygon[0::2]
|
|
ys = polygon[1::2]
|
|
else:
|
|
xs = [p[0] for p in polygon]
|
|
ys = [p[1] for p in polygon]
|
|
if not xs or not ys:
|
|
continue
|
|
return {
|
|
'x': (min(xs) / page_width) * 100,
|
|
'y': (min(ys) / page_height) * 100,
|
|
'width': ((max(xs) - min(xs)) / page_width) * 100,
|
|
'height': ((max(ys) - min(ys)) / page_height) * 100,
|
|
'pageNumber': page_idx + 1,
|
|
}
|
|
return None
|
|
|
|
|
|
def _field_bbox(raw_json: dict, field_name: str) -> dict | None:
|
|
"""Extract bbox from an Azure structured document field."""
|
|
try:
|
|
region = raw_json['documents'][0]['fields'][field_name]['bounding_regions'][0]
|
|
polygon = region['polygon']
|
|
page_number = region.get('page_number', 1)
|
|
page = raw_json['pages'][page_number - 1]
|
|
page_width = page.get('width', 8.5) or 8.5
|
|
page_height = page.get('height', 11.0) or 11.0
|
|
if isinstance(polygon[0], (int, float)):
|
|
xs = polygon[0::2]; ys = polygon[1::2]
|
|
else:
|
|
xs = [p[0] for p in polygon]; ys = [p[1] for p in polygon]
|
|
return {
|
|
'x': (min(xs) / page_width) * 100,
|
|
'y': (min(ys) / page_height) * 100,
|
|
'width': ((max(xs) - min(xs)) / page_width) * 100,
|
|
'height': ((max(ys) - min(ys)) / page_height) * 100,
|
|
'pageNumber': page_number,
|
|
}
|
|
except (KeyError, IndexError, TypeError):
|
|
return None
|
|
|
|
|
|
# Context signals that indicate a match is NOT an invoice number
|
|
_INVOICE_NUM_EXCLUDE = re.compile(
|
|
r'\b(?:tel|fax|mob(?:ile)?|phone|'
|
|
r'vat\s*reg|vat\s*no|vat\s*number|vat\s*registration|'
|
|
r'[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}|' # email address
|
|
r'[A-Z]{1,2}\d{1,2}[A-Z]?\s?\d[A-Z]{2})', # UK postcode
|
|
re.IGNORECASE
|
|
)
|
|
|
|
|
|
@router.get("/{invoice_id}/parse-invoice-number")
|
|
async def parse_invoice_number_from_ocr(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Search invoice OCR text for candidate invoice numbers.
|
|
Uses past confirmed invoice numbers from the same supplier to derive
|
|
a format pattern, then scans the raw text for matches.
|
|
Falls back to keyword-proximity heuristics when no history is available.
|
|
"""
|
|
import json as _json
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
raw_text = invoice.ocr_raw_text or ""
|
|
|
|
candidates = []
|
|
seen = set()
|
|
raw_json: dict = {}
|
|
if invoice.ocr_raw_json:
|
|
try:
|
|
raw_json = _json.loads(invoice.ocr_raw_json)
|
|
except Exception:
|
|
pass
|
|
|
|
def add_candidate(value: str, context: str, source: str, bbox=None):
|
|
value = value.strip()
|
|
if not value or value in seen:
|
|
return
|
|
seen.add(value)
|
|
entry = {"value": value, "context": context, "source": source}
|
|
if bbox:
|
|
entry["bbox"] = bbox
|
|
candidates.append(entry)
|
|
|
|
# ── 1. Check Azure OCR structured fields that might contain the number ──────
|
|
if raw_json:
|
|
try:
|
|
docs = raw_json.get("documents", [{}])
|
|
fields = docs[0].get("fields", {}) if docs else {}
|
|
for field_name in ("InvoiceId", "TransactionId", "PurchaseOrder", "PaymentRef",
|
|
"OrderId", "DocumentNumber", "BillingReference"):
|
|
field = fields.get(field_name)
|
|
if field and field.get("value"):
|
|
val = str(field["value"]).strip()
|
|
bbox = _field_bbox(raw_json, field_name) or _find_word_bbox(raw_json, val)
|
|
add_candidate(val, f"Azure OCR field: {field_name}", "ocr_field", bbox)
|
|
except Exception:
|
|
pass
|
|
|
|
# ── 2. Derive format pattern from past confirmed invoice numbers ─────────────
|
|
past_numbers: list[str] = []
|
|
if invoice.supplier_id:
|
|
result = await db.execute(
|
|
select(Invoice.invoice_number)
|
|
.where(
|
|
Invoice.kitchen_id == current_user.kitchen_id,
|
|
Invoice.supplier_id == invoice.supplier_id,
|
|
Invoice.invoice_number.isnot(None),
|
|
Invoice.id != invoice_id,
|
|
Invoice.status == InvoiceStatus.CONFIRMED,
|
|
)
|
|
.order_by(Invoice.id.desc())
|
|
.limit(15)
|
|
)
|
|
past_numbers = [r[0] for r in result.fetchall() if r[0]]
|
|
|
|
if past_numbers and raw_text:
|
|
seen_shapes: set[str] = set()
|
|
patterns_to_try: list[str] = []
|
|
for num in past_numbers:
|
|
shape = re.sub(r'\d', 'N', re.sub(r'[A-Za-z]', 'A', num))
|
|
if shape not in seen_shapes:
|
|
seen_shapes.add(shape)
|
|
patterns_to_try.append(_generalize_invoice_number_pattern(num))
|
|
if len(patterns_to_try) >= 5:
|
|
break
|
|
|
|
for pattern in patterns_to_try:
|
|
try:
|
|
for match in re.finditer(pattern, raw_text, re.IGNORECASE):
|
|
val = match.group(0).strip()
|
|
if len(val) < 4:
|
|
continue
|
|
if val in past_numbers:
|
|
continue
|
|
ctx_start = max(0, match.start() - 80)
|
|
ctx_end = min(len(raw_text), match.end() + 80)
|
|
if _INVOICE_NUM_EXCLUDE.search(raw_text[ctx_start:ctx_end]):
|
|
continue
|
|
start = max(0, match.start() - 50)
|
|
end = min(len(raw_text), match.end() + 30)
|
|
context = raw_text[start:end].replace('\n', ' ').strip()
|
|
bbox = _find_word_bbox(raw_json, val)
|
|
add_candidate(val, context, "supplier_pattern", bbox)
|
|
except re.error:
|
|
continue
|
|
|
|
# ── 3. Keyword-proximity fallback ────────────────────────────────────────────
|
|
keyword_pattern = re.compile(
|
|
r'(?:invoice\s*(?:no|number|num|#|ref)?|inv\s*(?:no|#)|'
|
|
r'our\s*ref|tax\s*point|order\s*ref|reference|ref\s*no|'
|
|
r'document\s*(?:no|number))[:\s#]*([A-Z0-9][A-Z0-9\-/\.]{2,24})',
|
|
re.IGNORECASE
|
|
)
|
|
for match in re.finditer(keyword_pattern, raw_text):
|
|
val = match.group(1).strip().rstrip('.')
|
|
start = max(0, match.start() - 10)
|
|
end = min(len(raw_text), match.end() + 20)
|
|
context = raw_text[start:end].replace('\n', ' ').strip()
|
|
bbox = _find_word_bbox(raw_json, val)
|
|
add_candidate(val, context, "keyword_match", bbox)
|
|
|
|
return {
|
|
"invoice_id": invoice.id,
|
|
"current_number": invoice.invoice_number,
|
|
"supplier_examples": past_numbers[:5],
|
|
"candidates": candidates,
|
|
}
|
|
|
|
|
|
@router.post("/reprocess-all")
|
|
async def reprocess_all_invoices(
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Reprocess all non-confirmed invoices through OCR.
|
|
Clears existing extracted data and line items, then re-runs OCR processing.
|
|
"""
|
|
# Get all non-confirmed invoices
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.kitchen_id == current_user.kitchen_id,
|
|
Invoice.status != InvoiceStatus.CONFIRMED
|
|
)
|
|
)
|
|
invoices = result.scalars().all()
|
|
count = len(invoices)
|
|
|
|
if count == 0:
|
|
return {"message": "No invoices to reprocess", "count": 0}
|
|
|
|
# Queue each invoice for reprocessing
|
|
for invoice in invoices:
|
|
# Clear existing line items
|
|
await db.execute(
|
|
select(LineItem).where(LineItem.invoice_id == invoice.id)
|
|
)
|
|
# Delete line items for this invoice
|
|
from sqlalchemy import delete
|
|
await db.execute(
|
|
delete(LineItem).where(LineItem.invoice_id == invoice.id)
|
|
)
|
|
|
|
# Reset invoice status to pending
|
|
invoice.status = InvoiceStatus.PENDING
|
|
invoice.supplier_id = None
|
|
invoice.supplier_match_type = None
|
|
invoice.invoice_number = None
|
|
invoice.invoice_date = None
|
|
invoice.total = None
|
|
invoice.net_total = None
|
|
invoice.vendor_name = None
|
|
invoice.ocr_raw_text = None
|
|
invoice.ocr_raw_json = None
|
|
invoice.ocr_confidence = None
|
|
invoice.document_type = None
|
|
invoice.order_number = None
|
|
invoice.duplicate_status = None
|
|
invoice.duplicate_of_id = None
|
|
|
|
# Queue background processing
|
|
background_tasks.add_task(
|
|
process_invoice_background,
|
|
invoice.id,
|
|
invoice.image_path,
|
|
current_user.kitchen_id
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
return {"message": f"Queued {count} invoices for reprocessing", "count": count}
|
|
|
|
|
|
# Product Definition endpoints
|
|
@router.get("/product-definitions/", response_model=list[ProductDefinitionResponse])
|
|
async def list_product_definitions(
|
|
supplier_id: Optional[int] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""List product definitions for the current kitchen"""
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
query = select(ProductDefinition).options(
|
|
selectinload(ProductDefinition.saved_by_user),
|
|
selectinload(ProductDefinition.source_invoice)
|
|
).where(
|
|
ProductDefinition.kitchen_id == current_user.kitchen_id
|
|
)
|
|
if supplier_id:
|
|
query = query.where(ProductDefinition.supplier_id == supplier_id)
|
|
|
|
result = await db.execute(query.order_by(ProductDefinition.product_code))
|
|
definitions = result.scalars().all()
|
|
|
|
return [
|
|
ProductDefinitionResponse(
|
|
id=d.id,
|
|
kitchen_id=d.kitchen_id,
|
|
supplier_id=d.supplier_id,
|
|
product_code=d.product_code,
|
|
description_pattern=d.description_pattern,
|
|
pack_quantity=d.pack_quantity,
|
|
unit_size=float(d.unit_size) if d.unit_size else None,
|
|
unit_size_type=d.unit_size_type,
|
|
portions_per_unit=d.portions_per_unit,
|
|
portion_description=d.portion_description,
|
|
saved_by_user_id=d.saved_by_user_id,
|
|
saved_by_username=d.saved_by_user.name if d.saved_by_user else None,
|
|
source_invoice_id=d.source_invoice_id,
|
|
source_invoice_number=d.source_invoice.invoice_number if d.source_invoice else None,
|
|
updated_at=d.updated_at.isoformat() if d.updated_at else None
|
|
)
|
|
for d in definitions
|
|
]
|
|
|
|
|
|
@router.post("/product-definitions/", response_model=ProductDefinitionResponse)
|
|
async def create_product_definition(
|
|
definition: ProductDefinitionCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Create a new product definition"""
|
|
# Check if definition already exists for this product_code + supplier
|
|
if definition.product_code:
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(
|
|
ProductDefinition.kitchen_id == current_user.kitchen_id,
|
|
ProductDefinition.supplier_id == definition.supplier_id,
|
|
ProductDefinition.product_code == definition.product_code
|
|
)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
if existing:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Product definition already exists for this product code and supplier"
|
|
)
|
|
|
|
prod_def = ProductDefinition(
|
|
kitchen_id=current_user.kitchen_id,
|
|
supplier_id=definition.supplier_id,
|
|
product_code=definition.product_code,
|
|
description_pattern=definition.description_pattern,
|
|
pack_quantity=definition.pack_quantity,
|
|
unit_size=Decimal(str(definition.unit_size)) if definition.unit_size else None,
|
|
unit_size_type=definition.unit_size_type,
|
|
portions_per_unit=definition.portions_per_unit,
|
|
portion_description=definition.portion_description,
|
|
saved_by_user_id=current_user.id # Record who created this definition
|
|
)
|
|
db.add(prod_def)
|
|
await db.commit()
|
|
await db.refresh(prod_def)
|
|
|
|
return ProductDefinitionResponse(
|
|
id=prod_def.id,
|
|
kitchen_id=prod_def.kitchen_id,
|
|
supplier_id=prod_def.supplier_id,
|
|
product_code=prod_def.product_code,
|
|
description_pattern=prod_def.description_pattern,
|
|
pack_quantity=prod_def.pack_quantity,
|
|
unit_size=float(prod_def.unit_size) if prod_def.unit_size else None,
|
|
unit_size_type=prod_def.unit_size_type,
|
|
portions_per_unit=prod_def.portions_per_unit,
|
|
portion_description=prod_def.portion_description,
|
|
saved_by_user_id=prod_def.saved_by_user_id,
|
|
saved_by_username=current_user.name,
|
|
source_invoice_id=prod_def.source_invoice_id,
|
|
source_invoice_number=prod_def.source_invoice_number,
|
|
updated_at=prod_def.updated_at.isoformat() if prod_def.updated_at else None
|
|
)
|
|
|
|
|
|
@router.post("/{invoice_id}/apply-definitions")
|
|
async def apply_definitions_to_invoice(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Re-apply product definitions to all line items on an invoice.
|
|
Useful after manually setting/changing the supplier on an invoice.
|
|
Only updates line items where portions_per_unit is not already set.
|
|
"""
|
|
from sqlalchemy import or_
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
if not invoice.supplier_id:
|
|
return {"message": "No supplier set on invoice - no definitions to apply", "updated": 0}
|
|
|
|
# Get all line items for this invoice
|
|
result = await db.execute(
|
|
select(LineItem)
|
|
.where(LineItem.invoice_id == invoice_id)
|
|
.order_by(LineItem.line_number)
|
|
)
|
|
line_items = result.scalars().all()
|
|
|
|
if not line_items:
|
|
return {"message": "No line items on invoice", "updated": 0}
|
|
|
|
# Get definitions for this supplier (or kitchen-wide)
|
|
conditions = [
|
|
ProductDefinition.kitchen_id == current_user.kitchen_id,
|
|
or_(
|
|
ProductDefinition.supplier_id == invoice.supplier_id,
|
|
ProductDefinition.supplier_id.is_(None)
|
|
)
|
|
]
|
|
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(*conditions)
|
|
)
|
|
all_definitions = result.scalars().all()
|
|
|
|
logger.info(f"apply_definitions_to_invoice: invoice_id={invoice_id}, supplier_id={invoice.supplier_id}, found {len(all_definitions)} definitions")
|
|
|
|
# Build lookup dicts - prefer supplier-specific over kitchen-wide
|
|
definitions_by_code = {}
|
|
definitions_by_desc = [] # List of (normalized_pattern, definition) tuples
|
|
|
|
for d in all_definitions:
|
|
if d.product_code:
|
|
if d.product_code in definitions_by_code:
|
|
existing = definitions_by_code[d.product_code]
|
|
if existing.supplier_id and not d.supplier_id:
|
|
continue
|
|
definitions_by_code[d.product_code] = d
|
|
if d.description_pattern:
|
|
norm_pattern = normalize_description(d.description_pattern)
|
|
if norm_pattern:
|
|
definitions_by_desc.append((norm_pattern, d))
|
|
|
|
# Sort description patterns: prefer supplier-specific first, then by length
|
|
definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0])))
|
|
|
|
if not definitions_by_code and not definitions_by_desc:
|
|
return {"message": "No product definitions found for this supplier", "updated": 0}
|
|
|
|
logger.info(f"apply_definitions_to_invoice: {len(definitions_by_code)} code definitions, {len(definitions_by_desc)} description definitions")
|
|
|
|
def find_definition_for_item(item: LineItem) -> ProductDefinition | None:
|
|
if item.product_code and item.product_code in definitions_by_code:
|
|
return definitions_by_code[item.product_code]
|
|
if item.description:
|
|
item_desc_norm = normalize_description(item.description)
|
|
for pattern, defn in definitions_by_desc:
|
|
if pattern in item_desc_norm:
|
|
return defn
|
|
return None
|
|
|
|
updated_count = 0
|
|
for item in line_items:
|
|
defn = find_definition_for_item(item)
|
|
if not defn:
|
|
continue
|
|
|
|
match_type = "product_code" if item.product_code and item.product_code in definitions_by_code else "description"
|
|
logger.info(f"apply_definitions_to_invoice: applying definition (matched by {match_type}) to item: code={item.product_code}, desc={item.description[:50] if item.description else ''}")
|
|
|
|
# Only update if portions_per_unit is not already set
|
|
if item.portions_per_unit is None and defn.portions_per_unit:
|
|
item.portions_per_unit = defn.portions_per_unit
|
|
|
|
# Recalculate cost_per_portion
|
|
if item.pack_quantity and item.unit_price:
|
|
item.cost_per_portion = Decimal(str(
|
|
round(float(item.unit_price) / (item.pack_quantity * defn.portions_per_unit), 4)
|
|
))
|
|
updated_count += 1
|
|
|
|
# Also apply pack_quantity if OCR didn't find it
|
|
if item.pack_quantity is None and defn.pack_quantity:
|
|
item.pack_quantity = defn.pack_quantity
|
|
if item.unit_price:
|
|
item.cost_per_item = Decimal(str(
|
|
round(float(item.unit_price) / defn.pack_quantity, 4)
|
|
))
|
|
# Recalculate cost_per_portion if portions now available
|
|
if item.portions_per_unit and item.unit_price:
|
|
item.cost_per_portion = Decimal(str(
|
|
round(float(item.unit_price) / (defn.pack_quantity * item.portions_per_unit), 4)
|
|
))
|
|
updated_count += 1
|
|
# Apply unit_size and unit_size_type if definition has them
|
|
if item.unit_size is None and defn.unit_size:
|
|
item.unit_size = defn.unit_size
|
|
if item.unit_size_type is None and defn.unit_size_type:
|
|
item.unit_size_type = defn.unit_size_type
|
|
|
|
await db.commit()
|
|
|
|
return {"message": f"Applied definitions to {updated_count} line items", "updated": updated_count}
|
|
|
|
|
|
class SaveDefinitionRequest(BaseModel):
|
|
portion_description: Optional[str] = None
|
|
|
|
|
|
@router.post("/{invoice_id}/line-items/{item_id}/save-definition", response_model=ProductDefinitionResponse)
|
|
async def save_line_item_as_definition(
|
|
invoice_id: int,
|
|
item_id: int,
|
|
request: Optional[SaveDefinitionRequest] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Save a line item's pack/portion data as a product definition.
|
|
Creates or updates the definition for future invoices.
|
|
|
|
Matching priority when saving:
|
|
- If line item has product_code: save with product_code (preferred)
|
|
- If no product_code but has description: save with description_pattern
|
|
"""
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
result = await db.execute(
|
|
select(LineItem).where(
|
|
LineItem.id == item_id,
|
|
LineItem.invoice_id == invoice_id
|
|
)
|
|
)
|
|
line_item = result.scalar_one_or_none()
|
|
if not line_item:
|
|
raise HTTPException(status_code=404, detail="Line item not found")
|
|
|
|
# Need either product_code or description to save a definition
|
|
if not line_item.product_code and not line_item.description:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot save definition: line item has no product code or description"
|
|
)
|
|
|
|
existing = None
|
|
|
|
# Check if definition already exists - by product_code if available, otherwise by description
|
|
if line_item.product_code:
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(
|
|
ProductDefinition.kitchen_id == current_user.kitchen_id,
|
|
ProductDefinition.supplier_id == invoice.supplier_id,
|
|
ProductDefinition.product_code == line_item.product_code
|
|
)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
elif line_item.description:
|
|
# Match by normalized description pattern
|
|
norm_desc = normalize_description(line_item.description)
|
|
result = await db.execute(
|
|
select(ProductDefinition).where(
|
|
ProductDefinition.kitchen_id == current_user.kitchen_id,
|
|
ProductDefinition.supplier_id == invoice.supplier_id,
|
|
ProductDefinition.product_code.is_(None) # Only match description-based definitions
|
|
)
|
|
)
|
|
all_desc_defs = result.scalars().all()
|
|
for d in all_desc_defs:
|
|
if d.description_pattern and normalize_description(d.description_pattern) == norm_desc:
|
|
existing = d
|
|
break
|
|
|
|
# Get portion_description from request if provided
|
|
portion_desc = request.portion_description if request else None
|
|
|
|
if existing:
|
|
# Update existing definition
|
|
existing.pack_quantity = line_item.pack_quantity
|
|
existing.unit_size = line_item.unit_size
|
|
existing.unit_size_type = line_item.unit_size_type
|
|
existing.portions_per_unit = line_item.portions_per_unit
|
|
existing.description_pattern = line_item.description
|
|
if portion_desc is not None:
|
|
existing.portion_description = portion_desc
|
|
# Update saved by metadata
|
|
existing.saved_by_user_id = current_user.id
|
|
existing.source_invoice_id = invoice.id
|
|
await db.commit()
|
|
await db.refresh(existing)
|
|
prod_def = existing
|
|
else:
|
|
# Create new definition
|
|
prod_def = ProductDefinition(
|
|
kitchen_id=current_user.kitchen_id,
|
|
supplier_id=invoice.supplier_id,
|
|
product_code=line_item.product_code, # May be None for description-only definitions
|
|
description_pattern=line_item.description,
|
|
pack_quantity=line_item.pack_quantity,
|
|
unit_size=line_item.unit_size,
|
|
unit_size_type=line_item.unit_size_type,
|
|
portions_per_unit=line_item.portions_per_unit,
|
|
portion_description=portion_desc,
|
|
# Saved by metadata
|
|
saved_by_user_id=current_user.id,
|
|
source_invoice_id=invoice.id
|
|
)
|
|
db.add(prod_def)
|
|
await db.commit()
|
|
await db.refresh(prod_def)
|
|
|
|
return ProductDefinitionResponse(
|
|
id=prod_def.id,
|
|
kitchen_id=prod_def.kitchen_id,
|
|
supplier_id=prod_def.supplier_id,
|
|
product_code=prod_def.product_code,
|
|
description_pattern=prod_def.description_pattern,
|
|
pack_quantity=prod_def.pack_quantity,
|
|
unit_size=float(prod_def.unit_size) if prod_def.unit_size else None,
|
|
unit_size_type=prod_def.unit_size_type,
|
|
portions_per_unit=prod_def.portions_per_unit,
|
|
portion_description=prod_def.portion_description,
|
|
saved_by_user_id=prod_def.saved_by_user_id,
|
|
saved_by_username=current_user.name, # We have the user in scope
|
|
source_invoice_id=prod_def.source_invoice_id,
|
|
source_invoice_number=invoice.invoice_number, # Get from current invoice
|
|
updated_at=prod_def.updated_at.isoformat() if prod_def.updated_at else None
|
|
)
|
|
|
|
|
|
@router.get("/{invoice_id}/line-items/{item_id}/definition", response_model=ProductDefinitionResponse | None)
|
|
async def get_line_item_definition(
|
|
invoice_id: int,
|
|
item_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get the saved product definition that applies to a specific line item.
|
|
Returns null if no definition exists.
|
|
Used by frontend to compare current values with saved values.
|
|
"""
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
result = await db.execute(
|
|
select(LineItem).where(
|
|
LineItem.id == item_id,
|
|
LineItem.invoice_id == invoice_id
|
|
)
|
|
)
|
|
line_item = result.scalar_one_or_none()
|
|
if not line_item:
|
|
raise HTTPException(status_code=404, detail="Line item not found")
|
|
|
|
# Get definitions for this supplier (or kitchen-wide)
|
|
conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id]
|
|
if invoice.supplier_id:
|
|
conditions.append(
|
|
or_(
|
|
ProductDefinition.supplier_id == invoice.supplier_id,
|
|
ProductDefinition.supplier_id.is_(None)
|
|
)
|
|
)
|
|
else:
|
|
conditions.append(ProductDefinition.supplier_id.is_(None))
|
|
|
|
result = await db.execute(
|
|
select(ProductDefinition).options(
|
|
selectinload(ProductDefinition.saved_by_user),
|
|
selectinload(ProductDefinition.source_invoice)
|
|
).where(*conditions)
|
|
)
|
|
all_definitions = result.scalars().all()
|
|
|
|
# Build lookup dicts - prefer supplier-specific over kitchen-wide
|
|
definitions_by_code = {}
|
|
definitions_by_desc = []
|
|
|
|
for d in all_definitions:
|
|
if d.product_code:
|
|
if d.product_code in definitions_by_code:
|
|
existing = definitions_by_code[d.product_code]
|
|
if existing.supplier_id and not d.supplier_id:
|
|
continue
|
|
definitions_by_code[d.product_code] = d
|
|
if d.description_pattern:
|
|
norm_pattern = normalize_description(d.description_pattern)
|
|
if norm_pattern:
|
|
definitions_by_desc.append((norm_pattern, d))
|
|
|
|
definitions_by_desc.sort(key=lambda x: (0 if x[1].supplier_id else 1, -len(x[0])))
|
|
|
|
# Find matching definition
|
|
defn = None
|
|
if line_item.product_code and line_item.product_code in definitions_by_code:
|
|
defn = definitions_by_code[line_item.product_code]
|
|
elif line_item.description:
|
|
item_desc_norm = normalize_description(line_item.description)
|
|
for pattern, d in definitions_by_desc:
|
|
if pattern in item_desc_norm:
|
|
defn = d
|
|
break
|
|
|
|
if not defn:
|
|
return None
|
|
|
|
return ProductDefinitionResponse(
|
|
id=defn.id,
|
|
kitchen_id=defn.kitchen_id,
|
|
supplier_id=defn.supplier_id,
|
|
product_code=defn.product_code,
|
|
description_pattern=defn.description_pattern,
|
|
pack_quantity=defn.pack_quantity,
|
|
unit_size=float(defn.unit_size) if defn.unit_size else None,
|
|
unit_size_type=defn.unit_size_type,
|
|
portions_per_unit=defn.portions_per_unit,
|
|
portion_description=defn.portion_description,
|
|
saved_by_user_id=defn.saved_by_user_id,
|
|
saved_by_username=defn.saved_by_user.name if defn.saved_by_user else None,
|
|
source_invoice_id=defn.source_invoice_id,
|
|
source_invoice_number=defn.source_invoice.invoice_number if defn.source_invoice else None,
|
|
updated_at=defn.updated_at.isoformat() if defn.updated_at else None
|
|
)
|
|
|
|
|
|
# ============ Stock History Endpoint ============
|
|
|
|
@router.get("/{invoice_id}/stock-history")
|
|
async def get_invoice_stock_history(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Get stock status history for all line items in an invoice.
|
|
|
|
Returns a mapping of line_item_id to stock history info,
|
|
indicating if items were previously marked as non-stock.
|
|
"""
|
|
from services.stock_history import StockHistoryService
|
|
|
|
invoice = await get_invoice_or_404(invoice_id, current_user, db)
|
|
|
|
stock_service = StockHistoryService(db, current_user.kitchen_id)
|
|
history_map = await stock_service.check_all_line_items(invoice_id)
|
|
|
|
# Convert to JSON-serializable format
|
|
result = {}
|
|
for item_id, history in history_map.items():
|
|
result[str(item_id)] = {
|
|
'has_history': history.has_history,
|
|
'previously_non_stock': history.previously_non_stock,
|
|
'total_occurrences': history.total_occurrences,
|
|
'non_stock_occurrences': history.non_stock_occurrences,
|
|
'most_recent_status': history.most_recent_status
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
# ============ Dext Integration Endpoint ============
|
|
|
|
@router.post("/{invoice_id}/send-to-dext")
|
|
async def send_invoice_to_dext(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Send invoice to Dext via email
|
|
|
|
- Loads invoice with line items
|
|
- Generates HTML email with notes and non-stock items (if configured)
|
|
- Attaches invoice PDF/image
|
|
- Sends via SMTP
|
|
- Records sent timestamp and user
|
|
"""
|
|
from datetime import datetime
|
|
from sqlalchemy.orm import selectinload
|
|
from models.settings import KitchenSettings
|
|
from services.email_service import EmailService, generate_dext_email_html
|
|
|
|
# Load invoice with relationships
|
|
result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.supplier),
|
|
selectinload(Invoice.line_items)
|
|
).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Must be confirmed to send
|
|
if invoice.status != InvoiceStatus.CONFIRMED:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invoice must be confirmed before sending to Dext"
|
|
)
|
|
|
|
# Check if supplier has skip_dext enabled
|
|
if invoice.supplier and invoice.supplier.skip_dext:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Supplier '{invoice.supplier.name}' is configured to skip Dext forwarding"
|
|
)
|
|
|
|
# Load settings
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(
|
|
KitchenSettings.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
|
|
if not settings:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Kitchen settings not found"
|
|
)
|
|
|
|
# Validate SMTP configuration
|
|
if not all([settings.smtp_host, settings.smtp_from_email]):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SMTP not configured. Please configure email settings first."
|
|
)
|
|
|
|
# Validate Dext configuration
|
|
if not settings.dext_email:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Dext email not configured. Please configure Dext settings first."
|
|
)
|
|
|
|
# Check if file exists
|
|
if not os.path.exists(invoice.image_path):
|
|
raise HTTPException(status_code=404, detail="Invoice file not found")
|
|
|
|
# Update PDF highlights for non-stock items (if annotations are enabled in settings)
|
|
# Highlights are annotations (overlays) - not burnt in - so can be updated anytime
|
|
if settings.dext_include_annotations:
|
|
try:
|
|
from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items
|
|
import json
|
|
|
|
# Parse OCR JSON to get line items with bounding regions
|
|
ocr_data = json.loads(invoice.ocr_raw_json) if invoice.ocr_raw_json else {}
|
|
ocr_line_items = parse_azure_ocr_line_items(ocr_data)
|
|
|
|
if ocr_line_items:
|
|
non_stock_items = [item for item in invoice.line_items if item.is_non_stock]
|
|
|
|
# Create backup if doesn't exist (safety measure)
|
|
import shutil
|
|
backup_path = invoice.image_path + '.original'
|
|
if not os.path.exists(backup_path):
|
|
shutil.copy2(invoice.image_path, backup_path)
|
|
|
|
# Regenerate highlights (clears existing, adds current non-stock items)
|
|
highlighter = PDFHighlighter(invoice.image_path)
|
|
highlighter.highlight_items_with_ocr_data(
|
|
ocr_line_items=ocr_line_items,
|
|
non_stock_line_items=non_stock_items,
|
|
output_path=invoice.image_path,
|
|
notes=invoice.notes,
|
|
ocr_data=ocr_data
|
|
)
|
|
|
|
if non_stock_items:
|
|
logger.info(f"Updated PDF with {len(non_stock_items)} highlighted non-stock items")
|
|
else:
|
|
logger.info("Cleared all highlights from PDF (no non-stock items)")
|
|
else:
|
|
logger.debug("No OCR line item data available for highlighting")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"PDF highlighting failed, using original: {e}")
|
|
else:
|
|
logger.info("PDF annotations disabled in settings, skipping highlights")
|
|
|
|
# Read file (now contains highlights if successful)
|
|
try:
|
|
with open(invoice.image_path, 'rb') as f:
|
|
file_bytes = f.read()
|
|
except Exception as e:
|
|
logger.error(f"Failed to read invoice file: {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to read invoice file")
|
|
|
|
# Determine filename from invoice data
|
|
ext = invoice.image_path.split('.')[-1].lower()
|
|
filename = f"{invoice.invoice_number or 'invoice'}_{invoice.invoice_date.strftime('%Y%m%d') if invoice.invoice_date else 'unknown'}.{ext}"
|
|
|
|
# Generate email HTML and plain text
|
|
from services.email_service import generate_dext_email_plain
|
|
html_body = generate_dext_email_html(
|
|
invoice=invoice,
|
|
supplier_name=invoice.supplier.name if invoice.supplier else None,
|
|
line_items=invoice.line_items,
|
|
notes=invoice.notes,
|
|
include_notes=settings.dext_include_notes,
|
|
include_non_stock=settings.dext_include_non_stock
|
|
)
|
|
plain_body = generate_dext_email_plain(
|
|
invoice=invoice,
|
|
supplier_name=invoice.supplier.name if invoice.supplier else None,
|
|
line_items=invoice.line_items,
|
|
notes=invoice.notes,
|
|
include_notes=settings.dext_include_notes,
|
|
include_non_stock=settings.dext_include_non_stock
|
|
)
|
|
|
|
# Send email
|
|
email_service = EmailService(settings)
|
|
subject = f"Invoice {invoice.invoice_number or 'N/A'} - {invoice.supplier.name if invoice.supplier else 'Unknown Supplier'}"
|
|
|
|
success = email_service.send_email(
|
|
to_email=settings.dext_email,
|
|
subject=subject,
|
|
html_body=html_body,
|
|
plain_body=plain_body,
|
|
attachments=[(filename, file_bytes)]
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=500, detail="Failed to send email")
|
|
|
|
# Record sent status
|
|
invoice.dext_sent_at = datetime.utcnow()
|
|
invoice.dext_sent_by_user_id = current_user.id
|
|
await db.commit()
|
|
|
|
return {
|
|
"message": "Invoice sent to Dext successfully",
|
|
"sent_at": invoice.dext_sent_at.isoformat(),
|
|
"sent_to": settings.dext_email
|
|
}
|
|
|
|
|
|
# ============ PDF Highlighting Endpoint ============
|
|
|
|
@router.post("/{invoice_id}/regenerate-highlights")
|
|
async def regenerate_highlights(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Regenerate PDF highlights for non-stock items.
|
|
|
|
Clears any existing highlights and adds new ones for current non-stock items.
|
|
Useful for testing or manually triggering highlight updates.
|
|
"""
|
|
from sqlalchemy.orm import selectinload
|
|
from models.settings import KitchenSettings
|
|
import json
|
|
|
|
# Check if PDF annotations are enabled in settings
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
if not settings or not settings.pdf_annotations_enabled:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="PDF annotations are disabled in settings. Enable them first to regenerate highlights."
|
|
)
|
|
|
|
# Load invoice with line items
|
|
result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.line_items)
|
|
).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Check file exists
|
|
if not os.path.exists(invoice.image_path):
|
|
raise HTTPException(status_code=404, detail="Invoice file not found")
|
|
|
|
# Check OCR data exists
|
|
if not invoice.ocr_raw_json:
|
|
raise HTTPException(status_code=400, detail="No OCR data available for this invoice")
|
|
|
|
try:
|
|
from services.pdf_highlighter import PDFHighlighter, parse_azure_ocr_line_items
|
|
|
|
# Parse OCR JSON using Azure format parser
|
|
ocr_data = json.loads(invoice.ocr_raw_json)
|
|
ocr_line_items = parse_azure_ocr_line_items(ocr_data)
|
|
|
|
if not ocr_line_items:
|
|
raise HTTPException(status_code=400, detail="No line items with bounding regions in OCR data")
|
|
|
|
# Get non-stock items
|
|
non_stock_items = [item for item in invoice.line_items if item.is_non_stock]
|
|
|
|
import shutil
|
|
|
|
# Create backup of original if it doesn't exist (safety measure)
|
|
original_backup = invoice.image_path + '.original'
|
|
if not os.path.exists(original_backup):
|
|
shutil.copy2(invoice.image_path, original_backup)
|
|
logger.info(f"Created original backup: {original_backup}")
|
|
else:
|
|
# Restore from backup first to clear any burnt-in content
|
|
shutil.copy2(original_backup, invoice.image_path)
|
|
logger.info(f"Restored from original backup before regenerating highlights")
|
|
|
|
# Regenerate highlights
|
|
highlighter = PDFHighlighter(invoice.image_path)
|
|
result_path = highlighter.highlight_items_with_ocr_data(
|
|
ocr_line_items=ocr_line_items,
|
|
non_stock_line_items=non_stock_items,
|
|
output_path=invoice.image_path,
|
|
notes=invoice.notes,
|
|
ocr_data=ocr_data
|
|
)
|
|
|
|
return {
|
|
"message": "Highlights regenerated successfully",
|
|
"non_stock_count": len(non_stock_items),
|
|
"ocr_line_items_count": len(ocr_line_items),
|
|
"has_notes": bool(invoice.notes),
|
|
"pdf_path": result_path
|
|
}
|
|
|
|
except ImportError as e:
|
|
logger.error(f"PyMuPDF not installed: {e}")
|
|
raise HTTPException(status_code=500, detail="PDF highlighting library not installed. Run: pip install PyMuPDF")
|
|
except Exception as e:
|
|
logger.error(f"Highlight regeneration failed: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to regenerate highlights: {str(e)}")
|
|
|
|
|
|
# ============ Admin Manual Control Endpoints ============
|
|
|
|
@router.post("/{invoice_id}/mark-dext-sent")
|
|
async def mark_dext_sent(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Admin only: Mark invoice as sent to Dext without actually sending.
|
|
Also triggers Nextcloud archival if configured.
|
|
|
|
For edge cases where invoice was uploaded directly to Dext.
|
|
"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
from datetime import datetime
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
# Load invoice
|
|
result = await db.execute(
|
|
select(Invoice).options(
|
|
selectinload(Invoice.supplier)
|
|
).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Check if supplier has skip_dext enabled
|
|
if invoice.supplier and invoice.supplier.skip_dext:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Supplier '{invoice.supplier.name}' is configured to skip Dext forwarding"
|
|
)
|
|
|
|
# Mark as sent
|
|
invoice.dext_sent_at = datetime.utcnow()
|
|
invoice.dext_sent_by_user_id = current_user.id
|
|
await db.commit()
|
|
await db.refresh(invoice)
|
|
|
|
# Try to archive to Nextcloud if enabled
|
|
archival_message = None
|
|
try:
|
|
from services.file_archival_service import FileArchivalService
|
|
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
|
|
|
if await archival_service.is_ready_for_archival(invoice):
|
|
success, result_msg = await archival_service.archive_invoice_file(invoice)
|
|
if success:
|
|
archival_message = f"Archived to Nextcloud: {result_msg}"
|
|
await db.commit()
|
|
else:
|
|
archival_message = f"Archival skipped: {result_msg}"
|
|
else:
|
|
archival_message = "Not ready for archival (Nextcloud may not be enabled or configured)"
|
|
except Exception as e:
|
|
logger.error(f"Archival failed after marking Dext sent: {e}")
|
|
archival_message = f"Archival error: {str(e)}"
|
|
|
|
return {
|
|
"message": "Invoice marked as sent to Dext",
|
|
"sent_at": invoice.dext_sent_at.isoformat(),
|
|
"archival_status": archival_message
|
|
}
|
|
|
|
|
|
@router.post("/bulk/mark-all-dext-sent")
|
|
async def mark_all_dext_sent(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Admin only: Mark all invoices not yet sent to Dext as sent without actually sending.
|
|
|
|
Useful for migrating to the system or for invoices sent outside the system.
|
|
"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
from datetime import datetime
|
|
from sqlalchemy import update
|
|
|
|
now = datetime.utcnow()
|
|
|
|
# Update all invoices where dext_sent_at is null
|
|
result = await db.execute(
|
|
update(Invoice)
|
|
.where(
|
|
Invoice.kitchen_id == current_user.kitchen_id,
|
|
Invoice.dext_sent_at.is_(None)
|
|
)
|
|
.values(
|
|
dext_sent_at=now,
|
|
dext_sent_by_user_id=current_user.id
|
|
)
|
|
)
|
|
|
|
updated_count = result.rowcount
|
|
await db.commit()
|
|
|
|
logger.info(f"Bulk marked {updated_count} invoices as sent to Dext for kitchen {current_user.kitchen_id}")
|
|
|
|
return {
|
|
"message": f"Marked {updated_count} invoice(s) as sent to Dext",
|
|
"count": updated_count,
|
|
"marked_at": now.isoformat()
|
|
}
|
|
|
|
|
|
@router.post("/{invoice_id}/reprocess")
|
|
async def reprocess_invoice(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Admin only: Reprocess existing OCR data without re-sending to Azure.
|
|
|
|
Re-runs:
|
|
- Supplier identification
|
|
- Document type detection
|
|
- Line item creation (with product definitions)
|
|
- Duplicate detection
|
|
"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
# Load invoice
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Check if we have OCR data
|
|
if not invoice.ocr_raw_json:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No OCR data available. Use 'Resend to Azure' instead."
|
|
)
|
|
|
|
try:
|
|
import json
|
|
import re
|
|
from ocr.parser import identify_supplier
|
|
from services.duplicate_detector import detect_document_type, DuplicateDetector
|
|
|
|
# Load kitchen settings for post-processing options
|
|
settings_result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
|
)
|
|
settings = settings_result.scalar_one_or_none()
|
|
|
|
# Parse stored OCR data
|
|
raw_json = json.loads(invoice.ocr_raw_json)
|
|
|
|
# Re-identify supplier
|
|
supplier_id = None
|
|
supplier_match_type = None
|
|
if invoice.vendor_name:
|
|
supplier_id, supplier_match_type = await identify_supplier(invoice.vendor_name, current_user.kitchen_id, db)
|
|
if not supplier_id and invoice.ocr_raw_text:
|
|
supplier_id, supplier_match_type = await identify_supplier(invoice.ocr_raw_text, current_user.kitchen_id, db)
|
|
|
|
# Use document_type from stored raw_json if available (from azure_extractor)
|
|
# Otherwise re-detect it
|
|
document_type = raw_json.get("document_type")
|
|
if not document_type:
|
|
document_type = detect_document_type(
|
|
invoice.ocr_raw_text or "",
|
|
raw_json
|
|
)
|
|
|
|
# Update invoice fields
|
|
invoice.supplier_id = supplier_id
|
|
invoice.supplier_match_type = supplier_match_type
|
|
invoice.document_type = document_type
|
|
|
|
# Delete existing line items
|
|
await db.execute(
|
|
text("DELETE FROM line_items WHERE invoice_id = :invoice_id"),
|
|
{"invoice_id": invoice_id}
|
|
)
|
|
await db.flush()
|
|
|
|
# Re-create line items from stored OCR data
|
|
# The stored raw_json is the Azure response, which has Items in Azure format
|
|
# We need to extract line_items like the Azure extractor does
|
|
line_items_data = []
|
|
|
|
# Check if we have the processed line_items (newer format)
|
|
if "line_items" in raw_json:
|
|
line_items_data = raw_json["line_items"]
|
|
# Otherwise parse from Azure raw format (older invoices)
|
|
elif "documents" in raw_json:
|
|
# Helper to extract numeric value from Azure field (handles currency objects)
|
|
def get_numeric_value(field_data):
|
|
if not field_data:
|
|
return None
|
|
value = field_data.get("value")
|
|
if value is None:
|
|
return None
|
|
# Currency fields have {"code": "GBP", "amount": 54.2, "symbol": null}
|
|
if isinstance(value, dict) and "amount" in value:
|
|
return value["amount"]
|
|
return value
|
|
|
|
# Extract from Azure format - simplified extraction
|
|
for doc in raw_json.get("documents", []):
|
|
fields = doc.get("fields", {})
|
|
if "Items" in fields:
|
|
items = fields["Items"].get("value", [])
|
|
for ocr_idx, item in enumerate(items):
|
|
item_fields = item.get("value", {})
|
|
line_items_data.append({
|
|
"ocr_index": ocr_idx,
|
|
"product_code": item_fields.get("ProductCode", {}).get("value"),
|
|
"description": item_fields.get("Description", {}).get("value"),
|
|
"unit": item_fields.get("Unit", {}).get("value"),
|
|
"quantity": get_numeric_value(item_fields.get("Quantity", {})),
|
|
"unit_price": get_numeric_value(item_fields.get("UnitPrice", {})),
|
|
"amount": get_numeric_value(item_fields.get("Amount", {})),
|
|
"tax_rate": get_numeric_value(item_fields.get("TaxRate", {})),
|
|
"raw_content": item.get("content"), # Raw text for weight extraction
|
|
})
|
|
|
|
# Apply product definitions
|
|
line_items_data = await apply_product_definitions(
|
|
line_items_data, current_user.kitchen_id, supplier_id, db
|
|
)
|
|
|
|
# Apply OCR post-processing settings
|
|
if settings:
|
|
processed_items = []
|
|
for item in line_items_data:
|
|
# 1. Clean product codes (strip section headers like "CHILL/AMBIENT")
|
|
if settings.ocr_clean_product_codes and item.get("product_code"):
|
|
if '\n' in item["product_code"]:
|
|
item["product_code"] = item["product_code"].split('\n')[-1].strip()
|
|
|
|
# 1b. Fallback: extract product code from raw_content if Azure missed it
|
|
ocr_warnings = list(item.get("ocr_warnings", "").split("; ")) if item.get("ocr_warnings") else []
|
|
if not item.get("product_code") and item.get("raw_content"):
|
|
first_line = item["raw_content"].split('\n')[0].strip()
|
|
# Valid product code patterns:
|
|
# - All digits, 2-6 chars (e.g., "1646", "955")
|
|
# - Alphanumeric with optional hyphens, 2-15 chars (e.g., "01SAL4K06", "ABC-123")
|
|
# - Must NOT look like a price (no decimal point with 2 digits after)
|
|
is_numeric_code = re.match(r'^\d{2,6}$', first_line)
|
|
is_alphanum_code = re.match(r'^[A-Z0-9][A-Z0-9\-]{1,14}$', first_line, re.IGNORECASE)
|
|
is_price = re.match(r'^\d+\.\d{2}$', first_line) # e.g., "14.64"
|
|
|
|
if (is_numeric_code or is_alphanum_code) and not is_price:
|
|
item["product_code"] = first_line
|
|
ocr_warnings.append(f"SKU extracted from raw content: {first_line}")
|
|
logger.info(f"Extracted product code from raw_content fallback: '{first_line}'")
|
|
|
|
# Update ocr_warnings
|
|
if ocr_warnings:
|
|
item["ocr_warnings"] = "; ".join([w for w in ocr_warnings if w])
|
|
|
|
# 2. Filter subtotal rows
|
|
if settings.ocr_filter_subtotal_rows:
|
|
desc = (item.get("description") or "").lower()
|
|
has_total_keyword = "sub total" in desc or desc.endswith("total")
|
|
no_product_code = not item.get("product_code")
|
|
no_quantity = item.get("quantity") is None
|
|
if has_total_keyword and no_product_code and no_quantity:
|
|
continue # Skip subtotal rows
|
|
|
|
# 3. Use weight as quantity for KG items
|
|
if settings.ocr_use_weight_as_quantity:
|
|
unit = (item.get("unit") or "").upper()
|
|
qty = item.get("quantity")
|
|
price = item.get("unit_price")
|
|
amount = item.get("amount")
|
|
|
|
if unit == "KG" and qty is not None and price is not None and amount is not None:
|
|
expected = qty * price
|
|
if abs(expected - amount) > 0.02: # Mismatch detected
|
|
raw = item.get("raw_content") or ""
|
|
# Pattern: weight on its own line (after newline) with space before KG
|
|
# This avoids matching product sizes like "1.25-1.65KG" in descriptions
|
|
weight_match = re.search(r'\n(\d+\.\d+)\s+KG', raw, re.IGNORECASE)
|
|
# Fallback: try matching at start of content (if weight is first)
|
|
if not weight_match:
|
|
weight_match = re.search(r'^(\d+\.\d+)\s+KG', raw, re.IGNORECASE)
|
|
if weight_match:
|
|
weight = float(weight_match.group(1))
|
|
weight_expected = weight * price
|
|
if abs(weight_expected - amount) <= 0.02: # Validated
|
|
item["order_quantity"] = qty
|
|
item["quantity"] = weight
|
|
|
|
processed_items.append(item)
|
|
line_items_data = processed_items
|
|
|
|
# Create line items
|
|
for idx, item_data in enumerate(line_items_data):
|
|
line_item = LineItem(
|
|
invoice_id=invoice.id,
|
|
product_code=item_data.get("product_code"),
|
|
description=item_data.get("description"),
|
|
description_alt=item_data.get("description_alt"),
|
|
unit=item_data.get("unit"),
|
|
quantity=Decimal(str(item_data["quantity"])) if item_data.get("quantity") else None,
|
|
order_quantity=Decimal(str(item_data["order_quantity"])) if item_data.get("order_quantity") else None,
|
|
unit_price=Decimal(str(item_data["unit_price"])) if item_data.get("unit_price") else None,
|
|
tax_rate=item_data.get("tax_rate"),
|
|
tax_amount=Decimal(str(item_data["tax_amount"])) if item_data.get("tax_amount") else None,
|
|
amount=Decimal(str(item_data["amount"])) if item_data.get("amount") else None,
|
|
line_number=item_data.get("ocr_index", idx),
|
|
raw_content=item_data.get("raw_content"),
|
|
pack_quantity=item_data.get("pack_quantity"),
|
|
unit_size=Decimal(str(item_data["unit_size"])) if item_data.get("unit_size") else None,
|
|
unit_size_type=item_data.get("unit_size_type"),
|
|
portions_per_unit=item_data.get("portions_per_unit"),
|
|
cost_per_item=Decimal(str(item_data["cost_per_item"])) if item_data.get("cost_per_item") else None,
|
|
cost_per_portion=Decimal(str(item_data["cost_per_portion"])) if item_data.get("cost_per_portion") else None,
|
|
ocr_warnings=item_data.get("ocr_warnings")
|
|
)
|
|
db.add(line_item)
|
|
|
|
await db.flush()
|
|
|
|
# Auto-normalize: fill missing product codes + rename alias descriptions
|
|
if supplier_id:
|
|
try:
|
|
from api.ingredients import auto_normalize_line_items
|
|
norm_count = await auto_normalize_line_items(invoice.id, current_user.kitchen_id, supplier_id, db)
|
|
if norm_count:
|
|
logger.info(f"Auto-normalized {norm_count} line items on remapped invoice {invoice_id}")
|
|
except Exception as e:
|
|
logger.warning(f"Auto-normalize failed on remap (non-critical): {e}")
|
|
|
|
# Re-run duplicate detection
|
|
detector = DuplicateDetector(db, current_user.kitchen_id)
|
|
duplicates = await detector.check_duplicates(invoice)
|
|
|
|
if duplicates["firm_duplicate"]:
|
|
invoice.duplicate_status = "firm_duplicate"
|
|
invoice.duplicate_of_id = duplicates["firm_duplicate"].id
|
|
elif duplicates["possible_duplicates"]:
|
|
invoice.duplicate_status = "possible_duplicate"
|
|
invoice.duplicate_of_id = duplicates["possible_duplicates"][0].id
|
|
else:
|
|
invoice.duplicate_status = None
|
|
invoice.duplicate_of_id = None
|
|
|
|
if duplicates["related_documents"]:
|
|
invoice.related_document_id = duplicates["related_documents"][0].id
|
|
|
|
await db.commit()
|
|
|
|
logger.info(f"Invoice {invoice_id} reprocessed by admin {current_user.id}")
|
|
|
|
return {
|
|
"message": "Invoice reprocessed successfully",
|
|
"supplier_id": supplier_id,
|
|
"document_type": document_type,
|
|
"line_items_count": len(line_items_data),
|
|
"duplicate_status": invoice.duplicate_status
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Reprocessing failed for invoice {invoice_id}: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Reprocessing failed: {str(e)}")
|
|
|
|
|
|
@router.post("/{invoice_id}/resend-to-azure")
|
|
async def resend_to_azure(
|
|
invoice_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Admin only: Re-send invoice to Azure for OCR extraction.
|
|
|
|
Fully re-processes the invoice:
|
|
- Re-extracts from Azure Document Intelligence
|
|
- Updates all invoice fields
|
|
- Re-creates line items (with product definitions)
|
|
- Re-runs duplicate detection
|
|
"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
# Load invoice
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Check if file exists
|
|
if not os.path.exists(invoice.image_path):
|
|
raise HTTPException(status_code=404, detail="Invoice file not found")
|
|
|
|
# Reset status to processing
|
|
invoice.status = InvoiceStatus.PENDING
|
|
await db.commit()
|
|
|
|
# Run background processing
|
|
background_tasks.add_task(
|
|
process_invoice_background,
|
|
invoice_id,
|
|
invoice.image_path,
|
|
current_user.kitchen_id
|
|
)
|
|
|
|
logger.info(f"Invoice {invoice_id} re-sent to Azure by admin {current_user.id}")
|
|
|
|
return {
|
|
"message": "Invoice re-sent to Azure for processing",
|
|
"status": "pending"
|
|
}
|
|
|
|
|
|
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
|
@router.post("/{invoice_id}/ai-assist")
|
|
async def ai_assist_invoice(
|
|
invoice_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Run AI analysis on an invoice to suggest corrections and enhancements.
|
|
Returns suggestions for: supplier matching, line item corrections,
|
|
description recommendations, subtotal detection, total mismatch analysis.
|
|
"""
|
|
from services.llm_service import assist_invoice_ocr, reconcile_line_items
|
|
|
|
# Load invoice
|
|
result = await db.execute(
|
|
select(Invoice).where(
|
|
Invoice.id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id
|
|
)
|
|
)
|
|
invoice = result.scalar_one_or_none()
|
|
if not invoice:
|
|
raise HTTPException(status_code=404, detail="Invoice not found")
|
|
|
|
# Load line items
|
|
li_result = await db.execute(
|
|
select(LineItem).where(LineItem.invoice_id == invoice_id).order_by(LineItem.line_number)
|
|
)
|
|
line_items_db = li_result.scalars().all()
|
|
|
|
# Build data for LLM
|
|
invoice_data = {
|
|
"invoice_number": invoice.invoice_number,
|
|
"invoice_date": str(invoice.invoice_date) if invoice.invoice_date else None,
|
|
"total": str(invoice.total) if invoice.total else None,
|
|
"net_total": str(invoice.net_total) if invoice.net_total else None,
|
|
"vendor_name": invoice.vendor_name,
|
|
"raw_text": invoice.ocr_raw_text[:3000] if invoice.ocr_raw_text else "",
|
|
}
|
|
|
|
items_for_llm = [
|
|
{
|
|
"idx": i,
|
|
"product_code": li.product_code,
|
|
"description": li.description,
|
|
"description_alt": li.description_alt,
|
|
"quantity": float(li.quantity) if li.quantity else None,
|
|
"unit_price": float(li.unit_price) if li.unit_price else None,
|
|
"amount": float(li.amount) if li.amount else None,
|
|
"raw_content": li.raw_content,
|
|
"ocr_warnings": li.ocr_warnings,
|
|
}
|
|
for i, li in enumerate(line_items_db)
|
|
]
|
|
|
|
# Load suppliers for matching
|
|
from models.supplier import Supplier
|
|
sup_result = await db.execute(
|
|
select(Supplier.id, Supplier.name).where(
|
|
Supplier.kitchen_id == current_user.kitchen_id,
|
|
)
|
|
)
|
|
supplier_list = [{"id": s.id, "name": s.name} for s in sup_result.all()]
|
|
|
|
# Run OCR assist
|
|
ocr_result = await assist_invoice_ocr(
|
|
db=db,
|
|
kitchen_id=current_user.kitchen_id,
|
|
invoice_data=invoice_data,
|
|
line_items=items_for_llm,
|
|
supplier_list=supplier_list,
|
|
)
|
|
|
|
# Run line item reconciliation for unmatched items
|
|
reconciliation_result = {"status": "unavailable", "matches": None, "error": None}
|
|
if invoice.supplier_id:
|
|
unmatched = [
|
|
{"idx": i, "description": li.description, "product_code": li.product_code}
|
|
for i, li in enumerate(line_items_db)
|
|
if not li.ingredient_id and li.description
|
|
]
|
|
|
|
if unmatched:
|
|
# Get supplier history: past line items mapped to ingredients (last 90 days)
|
|
from datetime import timedelta
|
|
cutoff = date.today() - timedelta(days=90)
|
|
history_result = await db.execute(
|
|
select(
|
|
LineItem.description,
|
|
LineItem.ingredient_id,
|
|
).select_from(LineItem).join(
|
|
Invoice, LineItem.invoice_id == Invoice.id
|
|
).where(
|
|
Invoice.kitchen_id == current_user.kitchen_id,
|
|
Invoice.supplier_id == invoice.supplier_id,
|
|
Invoice.invoice_date >= cutoff,
|
|
LineItem.ingredient_id.isnot(None),
|
|
LineItem.description.isnot(None),
|
|
).distinct()
|
|
)
|
|
history_rows = history_result.all()
|
|
|
|
if history_rows:
|
|
# Resolve ingredient names
|
|
ingredient_ids = list({r.ingredient_id for r in history_rows})
|
|
from models.ingredient import Ingredient
|
|
ing_result = await db.execute(
|
|
select(Ingredient.id, Ingredient.name).where(Ingredient.id.in_(ingredient_ids))
|
|
)
|
|
ing_names = {r.id: r.name for r in ing_result.all()}
|
|
|
|
supplier_history = [
|
|
{
|
|
"description": r.description,
|
|
"ingredient_id": r.ingredient_id,
|
|
"ingredient_name": ing_names.get(r.ingredient_id, "Unknown"),
|
|
}
|
|
for r in history_rows
|
|
if r.ingredient_id in ing_names
|
|
]
|
|
|
|
reconciliation_result = await reconcile_line_items(
|
|
db=db,
|
|
kitchen_id=current_user.kitchen_id,
|
|
unmatched_items=unmatched,
|
|
supplier_history=supplier_history,
|
|
)
|
|
|
|
return {
|
|
"llm_status": ocr_result["status"],
|
|
"suggestions": ocr_result.get("suggestions"),
|
|
"reconciliation_status": reconciliation_result["status"],
|
|
"reconciliation_matches": reconciliation_result.get("matches"),
|
|
"error": ocr_result.get("error") or reconciliation_result.get("error"),
|
|
}
|
|
|
|
|
|
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
|
@router.get("/{invoice_id}/line-items/{item_id}/ai-pack-size")
|
|
async def ai_deduce_pack_size(
|
|
invoice_id: int,
|
|
item_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Deduce pack size for a line item using regex first, then LLM fallback.
|
|
Returns pack_quantity, unit_size, unit_size_type.
|
|
"""
|
|
# Load line item
|
|
result = await db.execute(
|
|
select(LineItem).join(Invoice).where(
|
|
LineItem.id == item_id,
|
|
LineItem.invoice_id == invoice_id,
|
|
Invoice.kitchen_id == current_user.kitchen_id,
|
|
)
|
|
)
|
|
line_item = result.scalar_one_or_none()
|
|
if not line_item:
|
|
raise HTTPException(status_code=404, detail="Line item not found")
|
|
|
|
# Tier 1: Regex parse (free, instant)
|
|
parsed = parse_pack_size(line_item.raw_content or line_item.description or "")
|
|
if parsed["pack_quantity"]:
|
|
return {
|
|
"source": "regex",
|
|
"pack_quantity": parsed["pack_quantity"],
|
|
"unit_size": parsed["unit_size"],
|
|
"unit_size_type": parsed["unit_size_type"],
|
|
"reason": "Parsed from product description",
|
|
}
|
|
|
|
# Tier 2: Infer from invoice unit field
|
|
unit_str = (line_item.unit or "").strip().lower()
|
|
std_unit = UNIT_FIELD_MAP.get(unit_str)
|
|
if std_unit and line_item.quantity:
|
|
return {
|
|
"source": "unit_field",
|
|
"pack_quantity": 1,
|
|
"unit_size": float(line_item.quantity),
|
|
"unit_size_type": std_unit,
|
|
"reason": f"Inferred from invoice unit field ({line_item.unit})",
|
|
}
|
|
|
|
# Tier 3: LLM deduction (cached, uses product knowledge)
|
|
from services.llm_service import deduce_pack_size
|
|
llm_result = await deduce_pack_size(
|
|
db=db,
|
|
kitchen_id=current_user.kitchen_id,
|
|
description=line_item.description or "",
|
|
raw_content=line_item.raw_content,
|
|
unit=line_item.unit,
|
|
)
|
|
|
|
if llm_result["status"] in ("success", "cached") and llm_result["pack_quantity"]:
|
|
return {
|
|
"source": "ai",
|
|
"pack_quantity": llm_result["pack_quantity"],
|
|
"unit_size": llm_result["unit_size"],
|
|
"unit_size_type": llm_result["unit_size_type"],
|
|
"reason": llm_result.get("reason", "AI deduction"),
|
|
}
|
|
|
|
return {
|
|
"source": None,
|
|
"pack_quantity": None,
|
|
"unit_size": None,
|
|
"unit_size_type": None,
|
|
"reason": llm_result.get("error") or "Could not determine pack size",
|
|
}
|