Issue Description
+{description.replace(chr(10), '
')}
A screenshot of the page is attached to this email.
+diff --git a/backend/api/__init__.py b/backend/api/__init__.py
new file mode 100644
index 0000000..72f2cd4
--- /dev/null
+++ b/backend/api/__init__.py
@@ -0,0 +1,3 @@
+from . import invoices, suppliers, reports, newbook, resos
+
+__all__ = ["invoices", "suppliers", "reports", "newbook", "resos"]
diff --git a/backend/api/backup.py b/backend/api/backup.py
new file mode 100644
index 0000000..da6b2ab
--- /dev/null
+++ b/backend/api/backup.py
@@ -0,0 +1,374 @@
+"""
+Backup management API endpoints.
+"""
+import os
+import tempfile
+import shutil
+from datetime import datetime
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
+from fastapi.responses import FileResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+from typing import Optional
+
+from database import get_db
+from models.user import User
+from models.settings import KitchenSettings
+from models.backup import BackupHistory
+from auth import get_current_user, require_cap
+from services.backup_service import BackupService
+
+router = APIRouter()
+
+
+# ============ Pydantic Models ============
+
+class BackupSettingsResponse(BaseModel):
+ backup_frequency: str | None
+ backup_retention_count: int
+ backup_destination: str | None
+ backup_time: str | None
+ backup_nextcloud_path: str | None
+ backup_smb_host: str | None
+ backup_smb_share: str | None
+ backup_smb_username: str | None
+ backup_smb_password_set: bool
+ backup_smb_path: str | None
+ backup_last_run_at: str | None
+ backup_last_status: str | None
+ backup_last_error: str | None
+
+ class Config:
+ from_attributes = True
+
+
+class BackupSettingsUpdate(BaseModel):
+ backup_frequency: str | None = None # "daily", "weekly", "manual"
+ backup_retention_count: int | None = None
+ backup_destination: str | None = None # "local", "nextcloud", "smb"
+ backup_time: str | None = None # "HH:MM"
+ backup_nextcloud_path: str | None = None
+ backup_smb_host: str | None = None
+ backup_smb_share: str | None = None
+ backup_smb_username: str | None = None
+ backup_smb_password: str | None = None
+ backup_smb_path: str | None = None
+
+
+class BackupHistoryResponse(BaseModel):
+ id: int
+ backup_type: str
+ destination: str
+ status: str
+ filename: str
+ file_size_bytes: int | None
+ invoice_count: int | None
+ file_count: int | None
+ started_at: str
+ completed_at: str | None
+ error_message: str | None
+ triggered_by_username: str | None
+
+ class Config:
+ from_attributes = True
+
+
+class BackupCreateResponse(BaseModel):
+ message: str
+ status: str
+ backup_id: int | None = None
+
+
+class BackupRestoreResponse(BaseModel):
+ status: str
+ message: str
+
+
+# ============ Settings Endpoints ============
+
+@router.get("/settings", response_model=BackupSettingsResponse)
+async def get_backup_settings(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get backup settings"""
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ return BackupSettingsResponse(
+ backup_frequency="manual",
+ backup_retention_count=7,
+ backup_destination="local",
+ backup_time="03:00",
+ backup_nextcloud_path="/Backups",
+ backup_smb_host=None,
+ backup_smb_share=None,
+ backup_smb_username=None,
+ backup_smb_password_set=False,
+ backup_smb_path="/backups",
+ backup_last_run_at=None,
+ backup_last_status=None,
+ backup_last_error=None
+ )
+
+ return BackupSettingsResponse(
+ backup_frequency=settings.backup_frequency,
+ backup_retention_count=settings.backup_retention_count,
+ backup_destination=settings.backup_destination,
+ backup_time=settings.backup_time,
+ backup_nextcloud_path=settings.backup_nextcloud_path,
+ backup_smb_host=settings.backup_smb_host,
+ backup_smb_share=settings.backup_smb_share,
+ backup_smb_username=settings.backup_smb_username,
+ backup_smb_password_set=bool(settings.backup_smb_password),
+ backup_smb_path=settings.backup_smb_path,
+ backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
+ backup_last_status=settings.backup_last_status,
+ backup_last_error=settings.backup_last_error
+ )
+
+
+@router.patch("/settings", response_model=BackupSettingsResponse)
+async def update_backup_settings(
+ update: BackupSettingsUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update backup settings"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
+ db.add(settings)
+
+ update_data = update.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ if field == 'backup_smb_password' and value:
+ setattr(settings, field, value)
+ elif value is not None:
+ setattr(settings, field, value)
+
+ await db.commit()
+ await db.refresh(settings)
+
+ return BackupSettingsResponse(
+ backup_frequency=settings.backup_frequency,
+ backup_retention_count=settings.backup_retention_count,
+ backup_destination=settings.backup_destination,
+ backup_time=settings.backup_time,
+ backup_nextcloud_path=settings.backup_nextcloud_path,
+ backup_smb_host=settings.backup_smb_host,
+ backup_smb_share=settings.backup_smb_share,
+ backup_smb_username=settings.backup_smb_username,
+ backup_smb_password_set=bool(settings.backup_smb_password),
+ backup_smb_path=settings.backup_smb_path,
+ backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
+ backup_last_status=settings.backup_last_status,
+ backup_last_error=settings.backup_last_error
+ )
+
+
+# ============ Backup Operations ============
+
+async def _run_backup_task(db: AsyncSession, kitchen_id: int, user_id: int):
+ """Background task to run backup"""
+ backup_service = BackupService(db, kitchen_id)
+ await backup_service.create_backup(user_id=user_id, backup_type="manual")
+
+
+@router.post("/create", response_model=BackupCreateResponse)
+async def create_backup(
+ background_tasks: BackgroundTasks,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Trigger a manual backup"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ # Run backup synchronously for now to get immediate feedback
+ backup_service = BackupService(db, current_user.kitchen_id)
+ success, message, backup = await backup_service.create_backup(
+ user_id=current_user.id,
+ backup_type="manual"
+ )
+
+ if not success:
+ raise HTTPException(status_code=500, detail=message)
+
+ return BackupCreateResponse(
+ message=message,
+ status="success",
+ backup_id=backup.id if backup else None
+ )
+
+
+@router.get("/history", response_model=list[BackupHistoryResponse])
+async def list_backups(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """List backup history"""
+ backup_service = BackupService(db, current_user.kitchen_id)
+ backups = await backup_service.list_backups()
+
+ return [
+ BackupHistoryResponse(
+ id=b.id,
+ backup_type=b.backup_type,
+ destination=b.destination,
+ status=b.status,
+ filename=b.filename,
+ file_size_bytes=b.file_size_bytes,
+ invoice_count=b.invoice_count,
+ file_count=b.file_count,
+ started_at=b.started_at.isoformat(),
+ completed_at=b.completed_at.isoformat() if b.completed_at else None,
+ error_message=b.error_message,
+ triggered_by_username=b.triggered_by_user.name if b.triggered_by_user else None
+ )
+ for b in backups
+ ]
+
+
+@router.post("/{backup_id}/restore", response_model=BackupRestoreResponse)
+async def restore_backup(
+ backup_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Restore from a backup"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ backup_service = BackupService(db, current_user.kitchen_id)
+ success, message = await backup_service.restore_backup(backup_id)
+
+ if not success:
+ raise HTTPException(status_code=400, detail=message)
+
+ return BackupRestoreResponse(status="success", message=message)
+
+
+@router.delete("/{backup_id}")
+async def delete_backup(
+ backup_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Delete a backup"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ backup_service = BackupService(db, current_user.kitchen_id)
+ success, message = await backup_service.delete_backup(backup_id)
+
+ if not success:
+ raise HTTPException(status_code=400, detail=message)
+
+ return {"message": message}
+
+
+@router.get("/{backup_id}/download")
+async def download_backup(
+ backup_id: int,
+ token: str,
+ db: AsyncSession = Depends(get_db)
+):
+ """Download a backup file. Auth via token query param for direct browser downloads."""
+ from auth import get_current_user, require_cap_from_token
+
+ current_user = await get_current_user_from_token(token, db)
+ if not current_user:
+ raise HTTPException(status_code=401, detail="Invalid token")
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ backup_service = BackupService(db, current_user.kitchen_id)
+ backup = await backup_service.get_backup(backup_id)
+
+ if not backup:
+ raise HTTPException(status_code=404, detail="Backup not found")
+
+ if backup.status != "success":
+ raise HTTPException(status_code=400, detail="Cannot download failed backup")
+
+ # Handle local backups
+ if not backup.file_path.startswith("nextcloud:"):
+ if not os.path.exists(backup.file_path):
+ raise HTTPException(status_code=404, detail="Backup file not found on disk")
+
+ return FileResponse(
+ path=backup.file_path,
+ filename=backup.filename,
+ media_type="application/zip"
+ )
+
+ # Handle Nextcloud backups - download to temp file
+ from services.nextcloud_service import NextcloudService
+
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings or not settings.nextcloud_host:
+ raise HTTPException(status_code=400, detail="Nextcloud not configured")
+
+ nc_path = backup.file_path.replace("nextcloud:", "")
+ nc = NextcloudService(
+ settings.nextcloud_host,
+ settings.nextcloud_username,
+ settings.nextcloud_password,
+ ""
+ )
+ success, content = await nc.download_file(nc_path)
+ await nc.close()
+
+ if not success:
+ raise HTTPException(status_code=500, detail=f"Failed to download from Nextcloud: {content}")
+
+ # Write to temp file and return
+ temp_dir = tempfile.mkdtemp()
+ temp_path = os.path.join(temp_dir, backup.filename)
+ with open(temp_path, 'wb') as f:
+ f.write(content)
+
+ return FileResponse(
+ path=temp_path,
+ filename=backup.filename,
+ media_type="application/zip",
+ background=lambda: shutil.rmtree(temp_dir, ignore_errors=True)
+ )
+
+
+@router.post("/upload", response_model=BackupRestoreResponse)
+async def upload_and_restore_backup(
+ file: UploadFile = File(...),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Upload a backup file and restore from it"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin only")
+
+ if not file.filename.endswith('.zip'):
+ raise HTTPException(status_code=400, detail="Only ZIP files are accepted")
+
+ backup_service = BackupService(db, current_user.kitchen_id)
+ success, message = await backup_service.restore_from_upload(file)
+
+ if not success:
+ raise HTTPException(status_code=400, detail=message)
+
+ return BackupRestoreResponse(status="success", message=message)
diff --git a/backend/api/budget.py b/backend/api/budget.py
new file mode 100644
index 0000000..28d9e65
--- /dev/null
+++ b/backend/api/budget.py
@@ -0,0 +1,1121 @@
+"""
+Budget API endpoints for Spend Budget feature.
+
+Calculates spending budgets based on forecasted revenue and target GP%,
+allocated to suppliers based on their historical spending percentage.
+"""
+from datetime import date, timedelta
+from decimal import Decimal
+from typing import Optional
+from collections import defaultdict
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, case, and_, or_, text
+from sqlalchemy.orm import selectinload
+from pydantic import BaseModel, field_serializer
+
+from database import get_db
+from models.user import User
+from models.invoice import Invoice, InvoiceStatus
+from models.supplier import Supplier
+from models.line_item import LineItem
+from models.settings import KitchenSettings
+from models.purchase_order import PurchaseOrder
+from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus
+from auth import get_current_user, require_cap
+from services.forecast_api import ForecastAPIClient, ForecastAPIError
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# Response Models
+
+class BudgetInvoice(BaseModel):
+ """Individual invoice in budget table"""
+ id: int
+ invoice_number: Optional[str]
+ invoice_date: Optional[date]
+ net_stock: Decimal
+ document_type: Optional[str] = None
+
+ @field_serializer('net_stock')
+ def serialize_net_stock(self, v: Decimal) -> float:
+ return float(v)
+
+
+class BudgetPOItem(BaseModel):
+ """Individual PO in budget table"""
+ id: int
+ order_type: str
+ status: str
+ total_amount: Optional[Decimal]
+ order_reference: Optional[str]
+
+ @field_serializer('total_amount')
+ def serialize_total(self, v: Optional[Decimal]) -> Optional[float]:
+ return float(v) if v is not None else None
+
+
+class SupplierBudgetRow(BaseModel):
+ """Supplier row in weekly budget table"""
+ supplier_id: Optional[int]
+ supplier_name: str
+ historical_pct: Decimal # 4-week average percentage
+ allocated_budget: Decimal # total_budget * historical_pct
+ invoices_by_date: dict[str, list[BudgetInvoice]] # date -> invoices
+ purchase_orders_by_date: dict[str, list[BudgetPOItem]] # date -> POs
+ actual_spent: Decimal # Sum of invoices this week (excluding CD)
+ cd_adjustments_by_date: dict[str, float] = {} # date -> CD +/- for this supplier
+ cd_total: float = 0 # Total CD adjustment for this supplier
+ po_ordered: Decimal # Sum of pending PO totals this week
+ remaining: Decimal # allocated - (spent + cd) - po_ordered
+ status: str # "under", "on_track", "over"
+
+ @field_serializer('historical_pct', 'allocated_budget', 'actual_spent', 'po_ordered', 'remaining')
+ def serialize_decimals(self, v: Decimal) -> float:
+ return float(v)
+
+
+class DailyBudgetData(BaseModel):
+ """Daily budget tracking data"""
+ date: date
+ day_name: str
+ forecast_revenue: Decimal
+ budget_split_pct: Decimal # % of weekly budget this day gets (from historical spend)
+ historical_budget: Decimal # Budget allocated based on historical spend patterns
+ revenue_budget: Decimal # Budget allocated based on forecast revenue proportion
+ actual_spent: Optional[Decimal] # Only for past/today
+ cumulative_budget: Decimal
+ cumulative_spent: Optional[Decimal]
+
+ @field_serializer('forecast_revenue', 'budget_split_pct', 'historical_budget', 'revenue_budget', 'cumulative_budget')
+ def serialize_decimals(self, v: Decimal) -> float:
+ return float(v)
+
+ @field_serializer('actual_spent', 'cumulative_spent')
+ def serialize_optional_decimals(self, v: Optional[Decimal]) -> Optional[float]:
+ return float(v) if v is not None else None
+
+
+class CoversSummary(BaseModel):
+ """Covers summary for a meal period"""
+ otb: int
+ pickup: int
+ forecast: int
+
+
+class DailyCoverData(BaseModel):
+ """Daily covers breakdown for a single day"""
+ date: date
+ day_name: str
+ otb_rooms: int = 0
+ pickup_rooms: int = 0
+ otb_guests: int = 0
+ pickup_guests: int = 0
+ breakfast: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
+ lunch: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
+ dinner: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
+
+
+class ForecastSummary(BaseModel):
+ """Weekly forecast summary for rooms and covers"""
+ otb_rooms: int = 0
+ pickup_rooms: int = 0
+ forecast_rooms: int = 0
+ otb_guests: int = 0
+ pickup_guests: int = 0
+ forecast_guests: int = 0
+ breakfast: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
+ lunch: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
+ dinner: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
+ daily_covers: list[DailyCoverData] = []
+
+
+class WeeklyBudgetResponse(BaseModel):
+ """Response for weekly budget endpoint"""
+ week_start: date
+ week_end: date
+ dates: list[date] # All 7 days of the week
+
+ # Forecast data
+ otb_revenue: Decimal # On The Books revenue (current bookings only)
+ forecast_revenue: Decimal # Full forecast revenue (OTB + expected pickup)
+ forecast_source: str # "forecast_api" or "fallback"
+
+ # Forecast summary (rooms + covers)
+ forecast_summary: Optional[ForecastSummary] = None
+
+ # Override info
+ has_overrides: bool = False
+ snapshot_revenue: Optional[Decimal] = None # Original snapshotted revenue
+ adjusted_revenue: Optional[Decimal] = None # Revenue after applying overrides
+
+ # Budget calculation
+ gp_target_pct: Decimal # e.g., 65.00
+ min_budget: Decimal # Minimum budget based on OTB only
+ total_budget: Decimal # Full budget based on forecast
+
+ # Actuals
+ total_spent: Decimal # Actual spend from confirmed invoices
+ total_po_ordered: Decimal # Sum of pending PO totals
+ total_remaining: Decimal # budget - spent - po_ordered (negative = overspend)
+ cd_budget_reservation: Decimal = Decimal("0") # Budget reserved for cost distributions
+
+ # Supplier breakdown
+ suppliers: list[SupplierBudgetRow]
+ all_supplier_names: list[str]
+
+ # Daily breakdown
+ daily_data: list[DailyBudgetData]
+ daily_totals: dict[str, Decimal] # date -> actual spend
+
+ @field_serializer('otb_revenue', 'forecast_revenue', 'gp_target_pct', 'min_budget', 'total_budget', 'total_spent', 'total_po_ordered', 'total_remaining', 'cd_budget_reservation')
+ def serialize_decimals(self, v: Decimal) -> float:
+ return float(v)
+
+ @field_serializer('snapshot_revenue', 'adjusted_revenue')
+ def serialize_optional_revenue(self, v: Optional[Decimal]) -> Optional[float]:
+ return float(v) if v is not None else None
+
+ @field_serializer('daily_totals')
+ def serialize_daily_totals(self, v: dict[str, Decimal]) -> dict[str, float]:
+ return {k: float(val) for k, val in v.items()}
+
+
+class BudgetSettingsResponse(BaseModel):
+ """Budget settings response"""
+ forecast_api_url: Optional[str]
+ forecast_api_configured: bool
+ budget_gp_target: Decimal
+ budget_lookback_weeks: int
+
+ @field_serializer('budget_gp_target')
+ def serialize_gp_target(self, v: Decimal) -> float:
+ return float(v)
+
+
+class BudgetSettingsUpdate(BaseModel):
+ """Budget settings update request"""
+ forecast_api_url: Optional[str] = None
+ forecast_api_key: Optional[str] = None
+ budget_gp_target: Optional[Decimal] = None
+ budget_lookback_weeks: Optional[int] = None
+
+
+class TestConnectionResponse(BaseModel):
+ """Response for forecast API connection test"""
+ success: bool
+ message: str
+
+
+# Helper Functions
+
+async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings:
+ """Get kitchen settings, creating if not exists"""
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ settings = KitchenSettings(kitchen_id=kitchen_id)
+ db.add(settings)
+ await db.commit()
+ await db.refresh(settings)
+
+ return settings
+
+
+async def get_historical_supplier_percentages(
+ db: AsyncSession,
+ kitchen_id: int,
+ lookback_start: date,
+ lookback_end: date
+) -> list[tuple[int | None, str, Decimal]]:
+ """
+ Calculate supplier spend percentages over the lookback period.
+
+ Returns list of (supplier_id, supplier_name, percentage) sorted by percentage desc.
+ """
+ # Build subquery to sum per invoice first (for credit note handling)
+ invoice_stock_subq = (
+ select(
+ Invoice.id.label('inv_id'),
+ Invoice.supplier_id.label('supplier_id'),
+ Invoice.document_type.label('doc_type'),
+ func.sum(LineItem.amount).label('stock_total')
+ )
+ .join(Invoice, LineItem.invoice_id == Invoice.id)
+ .where(
+ Invoice.kitchen_id == kitchen_id,
+ Invoice.invoice_date >= lookback_start,
+ Invoice.invoice_date <= lookback_end,
+ Invoice.status == InvoiceStatus.CONFIRMED,
+ LineItem.amount.isnot(None),
+ or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
+ )
+ .group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type)
+ .subquery()
+ )
+
+ # Get total for period
+ total_result = await db.execute(
+ select(func.sum(
+ case(
+ (and_(invoice_stock_subq.c.doc_type == 'credit_note',
+ invoice_stock_subq.c.stock_total > 0),
+ -invoice_stock_subq.c.stock_total),
+ else_=invoice_stock_subq.c.stock_total
+ )
+ ))
+ .select_from(invoice_stock_subq)
+ )
+ total_spend = total_result.scalar() or Decimal("0")
+
+ if total_spend <= 0:
+ return []
+
+ # Get supplier breakdown
+ supplier_result = await db.execute(
+ select(
+ invoice_stock_subq.c.supplier_id,
+ Supplier.name,
+ func.sum(
+ case(
+ (and_(invoice_stock_subq.c.doc_type == 'credit_note',
+ invoice_stock_subq.c.stock_total > 0),
+ -invoice_stock_subq.c.stock_total),
+ else_=invoice_stock_subq.c.stock_total
+ )
+ ).label('net_total')
+ )
+ .select_from(invoice_stock_subq)
+ .outerjoin(Supplier, invoice_stock_subq.c.supplier_id == Supplier.id)
+ .group_by(invoice_stock_subq.c.supplier_id, Supplier.name)
+ .order_by(func.sum(
+ case(
+ (and_(invoice_stock_subq.c.doc_type == 'credit_note',
+ invoice_stock_subq.c.stock_total > 0),
+ -invoice_stock_subq.c.stock_total),
+ else_=invoice_stock_subq.c.stock_total
+ )
+ ).desc())
+ )
+
+ result = []
+ for supplier_id, supplier_name, net_total in supplier_result.all():
+ if net_total and net_total != 0:
+ pct = (net_total / total_spend * 100)
+ result.append((
+ supplier_id,
+ supplier_name or "Unmatched",
+ round(pct, 2)
+ ))
+
+ return result
+
+
+async def get_historical_daily_distribution(
+ db: AsyncSession,
+ kitchen_id: int,
+ lookback_start: date,
+ lookback_end: date
+) -> dict[int, Decimal]:
+ """
+ Calculate historical spend distribution by day of week.
+
+ Returns dict of weekday (0=Mon, 6=Sun) -> percentage of weekly spend
+ """
+ # Get all confirmed invoices in the lookback period with their totals
+ invoice_subq = (
+ select(
+ Invoice.id.label('inv_id'),
+ Invoice.invoice_date.label('inv_date'),
+ Invoice.document_type.label('doc_type'),
+ func.sum(LineItem.amount).label('stock_total')
+ )
+ .join(Invoice, LineItem.invoice_id == Invoice.id)
+ .where(
+ Invoice.kitchen_id == kitchen_id,
+ Invoice.invoice_date >= lookback_start,
+ Invoice.invoice_date <= lookback_end,
+ Invoice.status == InvoiceStatus.CONFIRMED,
+ LineItem.amount.isnot(None),
+ or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
+ )
+ .group_by(Invoice.id, Invoice.invoice_date, Invoice.document_type)
+ .subquery()
+ )
+
+ # Get spend by day
+ result = await db.execute(
+ select(
+ invoice_subq.c.inv_date,
+ func.sum(
+ case(
+ (and_(invoice_subq.c.doc_type == 'credit_note',
+ invoice_subq.c.stock_total > 0),
+ -invoice_subq.c.stock_total),
+ else_=invoice_subq.c.stock_total
+ )
+ ).label('net_total')
+ )
+ .select_from(invoice_subq)
+ .group_by(invoice_subq.c.inv_date)
+ )
+
+ # Aggregate by day of week
+ weekday_totals: dict[int, Decimal] = {i: Decimal("0") for i in range(7)}
+ total_spend = Decimal("0")
+
+ for inv_date, net_total in result.all():
+ if inv_date and net_total:
+ weekday = inv_date.weekday() # 0=Monday, 6=Sunday
+ weekday_totals[weekday] += Decimal(str(net_total))
+ total_spend += Decimal(str(net_total))
+
+ # Convert to percentages
+ if total_spend > 0:
+ return {day: (amount / total_spend * 100).quantize(Decimal("0.01"))
+ for day, amount in weekday_totals.items()}
+ else:
+ # Default to even distribution if no historical data
+ return {i: Decimal("14.29") for i in range(7)}
+
+
+async def get_weekly_invoices_by_supplier(
+ db: AsyncSession,
+ kitchen_id: int,
+ week_start: date,
+ week_end: date
+) -> dict[tuple[int | None, str], list[dict]]:
+ """
+ Get all confirmed invoices for the week grouped by supplier.
+
+ Returns dict of (supplier_id, supplier_name) -> list of invoice data
+ """
+ # Get all confirmed invoices for the week with line items
+ result = await db.execute(
+ select(Invoice)
+ .where(
+ Invoice.kitchen_id == kitchen_id,
+ Invoice.status == InvoiceStatus.CONFIRMED,
+ Invoice.invoice_date >= week_start,
+ Invoice.invoice_date <= week_end,
+ )
+ .options(selectinload(Invoice.line_items))
+ .order_by(Invoice.invoice_date)
+ )
+ invoices = result.scalars().all()
+
+ # Get supplier names
+ supplier_result = await db.execute(
+ select(Supplier).where(Supplier.kitchen_id == kitchen_id)
+ )
+ suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()}
+
+ # Group invoices by supplier
+ supplier_invoices: dict[tuple[int | None, str], list[dict]] = defaultdict(list)
+
+ for inv in invoices:
+ # Calculate net stock for this invoice
+ net_stock = Decimal("0")
+ if inv.line_items:
+ for item in inv.line_items:
+ if not (item.is_non_stock or False):
+ net_stock += item.amount or Decimal("0")
+
+ # Handle credit notes
+ if inv.document_type == 'credit_note' and net_stock > 0:
+ net_stock = -net_stock
+
+ # Skip invoices with no stock value (non-stock only invoices)
+ if net_stock == 0:
+ continue
+
+ # Get supplier key
+ if inv.supplier_id:
+ supplier_name = suppliers_map.get(inv.supplier_id, "Unknown")
+ key = (inv.supplier_id, supplier_name)
+ else:
+ vendor = inv.vendor_name or "Unknown Supplier"
+ key = (None, vendor)
+
+ supplier_invoices[key].append({
+ "id": inv.id,
+ "invoice_number": inv.invoice_number,
+ "invoice_date": inv.invoice_date,
+ "net_stock": net_stock,
+ "document_type": inv.document_type,
+ })
+
+ return supplier_invoices
+
+
+# API Endpoints
+
+@router.get("/weekly", response_model=WeeklyBudgetResponse)
+async def get_weekly_budget(
+ week_offset: int = 0, # 0 = current week, -1 = last week, etc.
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """
+ Get weekly budget breakdown with supplier allocations.
+
+ week_offset: 0 = current week (Mon-Sun), -1 = previous week, etc.
+ """
+ # Calculate week dates (Mon-Sun)
+ today = date.today()
+ current_monday = today - timedelta(days=today.weekday())
+ week_start = current_monday + timedelta(weeks=week_offset)
+ week_end = week_start + timedelta(days=6)
+ week_dates = [week_start + timedelta(days=i) for i in range(7)]
+
+ # Get settings
+ settings = await get_settings(db, current_user.kitchen_id)
+
+ # Get forecast revenue + rooms + covers
+ otb_revenue = Decimal("0")
+ forecast_revenue = Decimal("0")
+ forecast_source = "fallback"
+ forecast_data = None # Keep the daily forecast data
+ forecast_summary = None
+
+ api_spend_rates = {} # spend rates from API for override recalculation
+ covers_data_raw = [] # raw covers data from API for override recalculation
+
+ if settings.forecast_api_url and settings.forecast_api_key:
+ try:
+ async with ForecastAPIClient(
+ settings.forecast_api_url,
+ settings.forecast_api_key
+ ) as client:
+ forecast_data = await client.get_revenue_forecast(week_start, days=7)
+ otb_revenue, forecast_revenue = client.calculate_food_revenue(forecast_data)
+ forecast_source = "forecast_api"
+ logger.info(f"Fetched revenue - OTB: {otb_revenue}, Forecast: {forecast_revenue}")
+
+ # Fetch rooms and covers for summary banner
+ try:
+ rooms_data = await client.get_rooms_forecast(week_start, days=7)
+ covers_data = await client.get_covers_forecast(week_start, days=7)
+ covers_data_raw = covers_data # Save for override recalculation
+ rooms_agg = client.aggregate_rooms(rooms_data)
+ covers_agg = client.aggregate_covers(covers_data)
+
+ # Index rooms data by date for matching
+ rooms_by_date = {d.get("date", ""): d for d in rooms_data}
+
+ # Build daily covers breakdown
+ daily_covers_list = []
+ for day in covers_data:
+ day_date_str = day.get("date", "")
+ day_name = day.get("day", "")
+ room_day = rooms_by_date.get(day_date_str, {})
+ daily_covers_list.append(DailyCoverData(
+ date=day_date_str,
+ day_name=day_name,
+ otb_rooms=room_day.get("otb_rooms", 0) or 0,
+ pickup_rooms=(room_day.get("forecast_rooms", 0) or 0) - (room_day.get("otb_rooms", 0) or 0),
+ otb_guests=room_day.get("otb_guests", 0) or 0,
+ pickup_guests=(room_day.get("forecast_guests", 0) or 0) - (room_day.get("otb_guests", 0) or 0),
+ breakfast=CoversSummary(
+ otb=day.get("breakfast", {}).get("otb", 0) or 0,
+ pickup=(day.get("breakfast", {}).get("forecast", 0) or 0) - (day.get("breakfast", {}).get("otb", 0) or 0),
+ forecast=day.get("breakfast", {}).get("forecast", 0) or 0,
+ ),
+ lunch=CoversSummary(
+ otb=day.get("lunch", {}).get("otb", 0) or 0,
+ pickup=(day.get("lunch", {}).get("forecast", 0) or 0) - (day.get("lunch", {}).get("otb", 0) or 0),
+ forecast=day.get("lunch", {}).get("forecast", 0) or 0,
+ ),
+ dinner=CoversSummary(
+ otb=day.get("dinner", {}).get("otb", 0) or 0,
+ pickup=(day.get("dinner", {}).get("forecast", 0) or 0) - (day.get("dinner", {}).get("otb", 0) or 0),
+ forecast=day.get("dinner", {}).get("forecast", 0) or 0,
+ ),
+ ))
+
+ forecast_summary = ForecastSummary(
+ otb_rooms=rooms_agg["otb_rooms"],
+ pickup_rooms=rooms_agg["pickup_rooms"],
+ forecast_rooms=rooms_agg["forecast_rooms"],
+ otb_guests=rooms_agg["otb_guests"],
+ pickup_guests=rooms_agg["pickup_guests"],
+ forecast_guests=rooms_agg["forecast_guests"],
+ breakfast=CoversSummary(**covers_agg["breakfast"]),
+ lunch=CoversSummary(**covers_agg["lunch"]),
+ dinner=CoversSummary(**covers_agg["dinner"]),
+ daily_covers=daily_covers_list,
+ )
+ except Exception as e:
+ logger.warning(f"Failed to fetch rooms/covers forecast: {e}")
+
+ # Fetch spend rates for override recalculation
+ try:
+ sr_response = await client.get_spend_rates()
+ api_spend_rates = sr_response.get("periods", {})
+ except Exception as e:
+ logger.warning(f"Failed to fetch spend rates: {e}")
+ except ForecastAPIError as e:
+ logger.warning(f"Failed to fetch forecast: {e.message}")
+ forecast_data = None
+
+ # Calculate budgets
+ gp_target = settings.budget_gp_target or Decimal("65.00")
+ cost_target_pct = (100 - gp_target) / 100
+ min_budget = (otb_revenue * cost_target_pct).quantize(Decimal("0.01"))
+ total_budget = (forecast_revenue * cost_target_pct).quantize(Decimal("0.01"))
+
+ # --- Apply cover/spend rate overrides to adjust forecast revenue ---
+ has_overrides_flag = False
+ snapshot_revenue_val = None
+ adjusted_revenue_val = None
+ adjusted_daily = {} # date_str -> adjusted revenue for daily breakdown
+
+ if forecast_source == "forecast_api":
+ # Check if a forecast snapshot exists for this week
+ week_snap_result = await db.execute(text("""
+ SELECT total_forecast_revenue FROM forecast_week_snapshots
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": current_user.kitchen_id, "ws": week_start})
+ week_snap = week_snap_result.fetchone()
+
+ if week_snap:
+ snapshot_revenue_val = Decimal(str(week_snap.total_forecast_revenue)) if week_snap.total_forecast_revenue else None
+ has_overrides_flag = True
+
+ # Only recalculate if we have live covers data
+ if covers_data_raw:
+ # Load cover overrides for this week
+ ovr_result = await db.execute(text("""
+ SELECT override_date, period, override_covers
+ FROM cover_overrides
+ WHERE kitchen_id = :kid AND override_date >= :ws AND override_date <= :we
+ """), {"kid": current_user.kitchen_id, "ws": week_start, "we": week_end})
+ override_lookup = {}
+ for row in ovr_result.fetchall():
+ override_lookup[(row.override_date.isoformat(), row.period)] = row.override_covers
+
+ # Load snapshot spend rates
+ snap_spend_result = await db.execute(text("""
+ SELECT snapshot_date, period, food_spend, drinks_spend
+ FROM forecast_snapshots
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": current_user.kitchen_id, "ws": week_start})
+ snap_spend_lookup = {}
+ for row in snap_spend_result.fetchall():
+ snap_spend_lookup[(row.snapshot_date.isoformat(), row.period)] = row
+
+ # Load spend rate overrides
+ spend_ovr_result = await db.execute(text("""
+ SELECT period, food_spend, drinks_spend
+ FROM spend_rate_overrides
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": current_user.kitchen_id, "ws": week_start})
+ spend_ovr_lookup = {row.period: row for row in spend_ovr_result.fetchall()}
+
+ # Resolve effective spend rates per period (override > snapshot > API)
+ spend_effective = {}
+ for period in ("breakfast", "lunch", "dinner"):
+ api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0)
+ api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0)
+
+ snap_food = snap_drinks = None
+ for d_check in week_dates:
+ key = (d_check.isoformat(), period)
+ if key in snap_spend_lookup:
+ snap_food = float(snap_spend_lookup[key].food_spend) if snap_spend_lookup[key].food_spend else None
+ snap_drinks = float(snap_spend_lookup[key].drinks_spend) if snap_spend_lookup[key].drinks_spend else None
+ break
+
+ ovr_sp = spend_ovr_lookup.get(period)
+ ovr_food = float(ovr_sp.food_spend) if ovr_sp and ovr_sp.food_spend else None
+ ovr_drinks = float(ovr_sp.drinks_spend) if ovr_sp and ovr_sp.drinks_spend else None
+
+ eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food)
+ eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks)
+ spend_effective[period] = {"food": eff_food, "drinks": eff_drinks}
+
+ # Recalculate revenue per day
+ covers_by_date_ovr = {d.get("date", ""): d for d in covers_data_raw}
+ adjusted_total = Decimal("0")
+
+ for d in week_dates:
+ date_str = d.isoformat()
+ is_past = d < today
+
+ if is_past and forecast_data:
+ # Past: use actual dry revenue from forecast API
+ day_rev = Decimal("0")
+ for fd in forecast_data:
+ if fd.get("date") == date_str:
+ dry = fd.get("dry", {})
+ day_rev = Decimal(str(dry.get("forecast", 0) or 0))
+ break
+ else:
+ # Today/future: recalculate from effective covers × effective spend
+ day_covers = covers_by_date_ovr.get(date_str, {})
+ day_rev = Decimal("0")
+
+ for period in ("breakfast", "lunch", "dinner"):
+ p_covers = day_covers.get(period, {})
+ otb_cvr = p_covers.get("otb", 0) or 0
+ forecast_cvr = p_covers.get("forecast", 0) or 0
+
+ ovr_val = override_lookup.get((date_str, period))
+ if ovr_val is not None:
+ effective = max(otb_cvr, ovr_val) # OTB always supersedes upward
+ else:
+ effective = forecast_cvr
+
+ eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
+ day_rev += Decimal(str(effective)) * eff_food
+
+ adjusted_daily[date_str] = day_rev.quantize(Decimal("0.01"))
+ adjusted_total += day_rev
+
+ adjusted_revenue_val = adjusted_total.quantize(Decimal("0.01"))
+
+ # Replace forecast_revenue and recalculate budget with adjusted values
+ forecast_revenue = adjusted_revenue_val
+ total_budget = (forecast_revenue * cost_target_pct).quantize(Decimal("0.01"))
+
+ # Get historical supplier percentages
+ lookback_weeks = settings.budget_lookback_weeks or 4
+ lookback_start = week_start - timedelta(weeks=lookback_weeks)
+ lookback_end = week_start - timedelta(days=1)
+
+ supplier_pcts = await get_historical_supplier_percentages(
+ db, current_user.kitchen_id, lookback_start, lookback_end
+ )
+
+ # Get historical daily distribution (for budget allocation by day of week)
+ daily_distribution = await get_historical_daily_distribution(
+ db, current_user.kitchen_id, lookback_start, lookback_end
+ )
+
+ # Get this week's invoices by supplier
+ weekly_invoices = await get_weekly_invoices_by_supplier(
+ db, current_user.kitchen_id, week_start, week_end
+ )
+
+ # Get this week's purchase orders (DRAFT + PENDING) grouped by supplier
+ po_result = await db.execute(
+ select(PurchaseOrder)
+ .where(
+ PurchaseOrder.kitchen_id == current_user.kitchen_id,
+ PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
+ PurchaseOrder.order_date >= week_start,
+ PurchaseOrder.order_date <= week_end,
+ )
+ )
+ weekly_pos = po_result.scalars().all()
+
+ # Group POs by supplier_id -> {date_str -> [BudgetPOItem]}
+ po_by_supplier: dict[int, dict[str, list[BudgetPOItem]]] = defaultdict(lambda: defaultdict(list))
+ po_totals_by_supplier: dict[int, Decimal] = defaultdict(lambda: Decimal("0"))
+ po_supplier_names: dict[int, str] = {}
+
+ for po in weekly_pos:
+ sid = po.supplier_id
+ ds = po.order_date.isoformat()
+ amt = po.total_amount or Decimal("0")
+ po_by_supplier[sid][ds].append(BudgetPOItem(
+ id=po.id,
+ order_type=po.order_type,
+ status=po.status,
+ total_amount=amt,
+ order_reference=po.order_reference,
+ ))
+ po_totals_by_supplier[sid] += amt
+
+ # Get supplier names for PO-only suppliers
+ if po_by_supplier:
+ sup_ids = list(po_by_supplier.keys())
+ sup_result = await db.execute(
+ select(Supplier.id, Supplier.name).where(Supplier.id.in_(sup_ids))
+ )
+ for sid, sname in sup_result.all():
+ po_supplier_names[sid] = sname
+
+ # Compute cost distribution adjustments per supplier per date.
+ # CD entries are attributed back to the supplier whose invoice was distributed.
+ cd_supplier_result = await db.execute(
+ select(
+ Invoice.supplier_id,
+ CostDistributionEntry.entry_date,
+ func.sum(CostDistributionEntry.amount)
+ )
+ .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
+ .join(Invoice, CostDistribution.invoice_id == Invoice.id)
+ .where(
+ CostDistributionEntry.kitchen_id == current_user.kitchen_id,
+ CostDistributionEntry.entry_date >= week_start,
+ CostDistributionEntry.entry_date <= week_end,
+ CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
+ )
+ .group_by(Invoice.supplier_id, CostDistributionEntry.entry_date)
+ )
+ # Per-supplier per-date amounts, plus aggregated totals
+ cd_by_supplier_date: dict[Optional[int], dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: Decimal("0")))
+ cd_total_by_supplier: dict[Optional[int], Decimal] = defaultdict(lambda: Decimal("0"))
+ cd_daily_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
+ cd_total_adjustment = Decimal("0")
+ for row in cd_supplier_result.all():
+ sup_id, cd_date, cd_amount = row[0], row[1], row[2] or Decimal("0")
+ date_str = cd_date.isoformat()
+ cd_by_supplier_date[sup_id][date_str] += cd_amount
+ cd_total_by_supplier[sup_id] += cd_amount
+ cd_daily_amounts[date_str] += cd_amount
+ cd_total_adjustment += cd_amount
+
+ # Build supplier rows
+ supplier_rows = []
+ all_supplier_names = []
+ total_spent = Decimal("0")
+ total_po_ordered = Decimal("0")
+
+ # Process historical suppliers first
+ processed_suppliers = set()
+ processed_po_suppliers = set()
+ for supplier_id, supplier_name, hist_pct in supplier_pcts:
+ key = (supplier_id, supplier_name)
+ processed_suppliers.add(key)
+ if supplier_id:
+ processed_po_suppliers.add(supplier_id)
+ all_supplier_names.append(supplier_name)
+
+ # Calculate allocated budget from full total_budget
+ allocated = (total_budget * Decimal(str(hist_pct)) / 100).quantize(Decimal("0.01"))
+
+ # Get invoices for this supplier this week
+ invoices = weekly_invoices.get(key, [])
+
+ # Organize invoices by date
+ invoices_by_date: dict[str, list[BudgetInvoice]] = defaultdict(list)
+ actual_spent = Decimal("0")
+
+ for inv in invoices:
+ date_str = inv["invoice_date"].isoformat() if inv["invoice_date"] else ""
+ if date_str:
+ invoices_by_date[date_str].append(BudgetInvoice(
+ id=inv["id"],
+ invoice_number=inv["invoice_number"],
+ invoice_date=inv["invoice_date"],
+ net_stock=inv["net_stock"],
+ document_type=inv["document_type"],
+ ))
+ actual_spent += inv["net_stock"]
+
+ # Get POs for this supplier
+ supplier_po_dates = dict(po_by_supplier.get(supplier_id, {})) if supplier_id else {}
+ supplier_po_total = po_totals_by_supplier.get(supplier_id, Decimal("0")) if supplier_id else Decimal("0")
+
+ # Get CD adjustments for this supplier
+ supplier_cd_dates = {k: float(v) for k, v in cd_by_supplier_date.get(supplier_id, {}).items()}
+ supplier_cd_total = cd_total_by_supplier.get(supplier_id, Decimal("0"))
+
+ total_spent += actual_spent
+ total_po_ordered += supplier_po_total
+ remaining = allocated - actual_spent - supplier_cd_total - supplier_po_total
+
+ # Determine status
+ if remaining < 0:
+ status = "over"
+ elif remaining < allocated * Decimal("0.1"): # Less than 10% remaining
+ status = "on_track"
+ else:
+ status = "under"
+
+ supplier_rows.append(SupplierBudgetRow(
+ supplier_id=supplier_id,
+ supplier_name=supplier_name,
+ historical_pct=hist_pct,
+ allocated_budget=allocated,
+ invoices_by_date=dict(invoices_by_date),
+ purchase_orders_by_date=supplier_po_dates,
+ actual_spent=actual_spent,
+ cd_adjustments_by_date=supplier_cd_dates,
+ cd_total=float(supplier_cd_total),
+ po_ordered=supplier_po_total,
+ remaining=remaining,
+ status=status,
+ ))
+
+ # Add any suppliers with invoices this week that weren't in historical data
+ for key, invoices in weekly_invoices.items():
+ if key not in processed_suppliers:
+ supplier_id, supplier_name = key
+ if supplier_id:
+ processed_po_suppliers.add(supplier_id)
+ all_supplier_names.append(supplier_name)
+
+ invoices_by_date: dict[str, list[BudgetInvoice]] = defaultdict(list)
+ actual_spent = Decimal("0")
+
+ for inv in invoices:
+ date_str = inv["invoice_date"].isoformat() if inv["invoice_date"] else ""
+ if date_str:
+ invoices_by_date[date_str].append(BudgetInvoice(
+ id=inv["id"],
+ invoice_number=inv["invoice_number"],
+ invoice_date=inv["invoice_date"],
+ net_stock=inv["net_stock"],
+ document_type=inv["document_type"],
+ ))
+ actual_spent += inv["net_stock"]
+
+ supplier_po_dates = dict(po_by_supplier.get(supplier_id, {})) if supplier_id else {}
+ supplier_po_total = po_totals_by_supplier.get(supplier_id, Decimal("0")) if supplier_id else Decimal("0")
+
+ supplier_cd_dates = {k: float(v) for k, v in cd_by_supplier_date.get(supplier_id, {}).items()}
+ supplier_cd_total = cd_total_by_supplier.get(supplier_id, Decimal("0"))
+
+ total_spent += actual_spent
+ total_po_ordered += supplier_po_total
+ combined = actual_spent + supplier_cd_total + supplier_po_total
+
+ supplier_rows.append(SupplierBudgetRow(
+ supplier_id=supplier_id,
+ supplier_name=supplier_name,
+ historical_pct=Decimal("0"), # No historical data
+ allocated_budget=Decimal("0"),
+ invoices_by_date=dict(invoices_by_date),
+ purchase_orders_by_date=supplier_po_dates,
+ actual_spent=actual_spent,
+ cd_adjustments_by_date=supplier_cd_dates,
+ cd_total=float(supplier_cd_total),
+ po_ordered=supplier_po_total,
+ remaining=-combined, # Over by definition
+ status="over" if combined > 0 else "under",
+ ))
+
+ # Add suppliers with POs but no invoices and no historical data
+ for sid, po_dates in po_by_supplier.items():
+ if sid not in processed_po_suppliers:
+ sname = po_supplier_names.get(sid, f"Supplier #{sid}")
+ all_supplier_names.append(sname)
+ supplier_po_total = po_totals_by_supplier.get(sid, Decimal("0"))
+ total_po_ordered += supplier_po_total
+
+ supplier_rows.append(SupplierBudgetRow(
+ supplier_id=sid,
+ supplier_name=sname,
+ historical_pct=Decimal("0"),
+ allocated_budget=Decimal("0"),
+ invoices_by_date={},
+ purchase_orders_by_date=dict(po_dates),
+ actual_spent=Decimal("0"),
+ po_ordered=supplier_po_total,
+ remaining=-supplier_po_total,
+ status="over" if supplier_po_total > 0 else "under",
+ ))
+
+ # Build daily breakdown with both historical and revenue-based budgets
+ daily_data = []
+ daily_totals: dict[str, Decimal] = {}
+ cumulative_budget = Decimal("0")
+ cumulative_spent = Decimal("0")
+
+ # Extract daily revenue from forecast API data
+ daily_forecast_revenue: dict[str, Decimal] = {}
+ total_forecast_rev = Decimal("0")
+
+ if forecast_data:
+ # Use actual daily forecast from API
+ for day in forecast_data:
+ date_str = day.get("date")
+ if date_str:
+ dry = day.get("dry", {})
+ dry_forecast = Decimal(str(dry.get("forecast", 0) or 0))
+ daily_forecast_revenue[date_str] = dry_forecast
+ total_forecast_rev += dry_forecast
+ else:
+ # Fallback: distribute total forecast using historical patterns
+ for d in week_dates:
+ weekday = d.weekday()
+ day_pct = daily_distribution.get(weekday, Decimal("14.29"))
+ day_forecast_rev = (forecast_revenue * day_pct / 100).quantize(Decimal("0.01"))
+ daily_forecast_revenue[d.isoformat()] = day_forecast_rev
+ total_forecast_rev += day_forecast_rev
+
+ # Apply adjusted daily revenue from overrides
+ if adjusted_daily:
+ for adj_date_str, adj_rev in adjusted_daily.items():
+ daily_forecast_revenue[adj_date_str] = adj_rev
+ total_forecast_rev = sum(daily_forecast_revenue.values())
+
+ for d in week_dates:
+ date_str = d.isoformat()
+ day_name = d.strftime("%a")
+ weekday = d.weekday() # 0=Monday, 6=Sunday
+
+ # Historical spend-based budget
+ budget_split_pct = daily_distribution.get(weekday, Decimal("14.29"))
+ historical_budget = (total_budget * budget_split_pct / 100).quantize(Decimal("0.01"))
+
+ # Revenue-based budget (proportional to actual daily forecast revenue)
+ day_forecast_rev = daily_forecast_revenue.get(date_str, Decimal("0"))
+ if total_forecast_rev > 0:
+ revenue_pct = (day_forecast_rev / total_forecast_rev * 100).quantize(Decimal("0.01"))
+ revenue_budget = (total_budget * revenue_pct / 100).quantize(Decimal("0.01"))
+ else:
+ revenue_budget = (total_budget / 7).quantize(Decimal("0.01"))
+
+ # Get actual spend for this day
+ day_spent = Decimal("0")
+ for supplier_row in supplier_rows:
+ for inv in supplier_row.invoices_by_date.get(date_str, []):
+ day_spent += inv.net_stock
+
+ daily_totals[date_str] = day_spent
+ cumulative_budget += historical_budget # Use historical for cumulative
+
+ # Only show cumulative spent for past/today
+ if d <= today:
+ cumulative_spent += day_spent
+ daily_data.append(DailyBudgetData(
+ date=d,
+ day_name=day_name,
+ forecast_revenue=day_forecast_rev,
+ budget_split_pct=budget_split_pct,
+ historical_budget=historical_budget,
+ revenue_budget=revenue_budget,
+ actual_spent=day_spent,
+ cumulative_budget=cumulative_budget,
+ cumulative_spent=cumulative_spent,
+ ))
+ else:
+ daily_data.append(DailyBudgetData(
+ date=d,
+ day_name=day_name,
+ forecast_revenue=day_forecast_rev,
+ budget_split_pct=budget_split_pct,
+ historical_budget=historical_budget,
+ revenue_budget=revenue_budget,
+ actual_spent=None,
+ cumulative_budget=cumulative_budget,
+ cumulative_spent=None,
+ ))
+
+ # Apply pre-computed cost distribution adjustments to daily totals and total_spent
+ for date_str, cd_amount in cd_daily_amounts.items():
+ if date_str in daily_totals:
+ daily_totals[date_str] += cd_amount
+ else:
+ daily_totals[date_str] = cd_amount
+ total_spent += cd_total_adjustment
+
+ total_remaining = total_budget - total_spent - total_po_ordered
+
+ return WeeklyBudgetResponse(
+ week_start=week_start,
+ week_end=week_end,
+ dates=week_dates,
+ otb_revenue=otb_revenue,
+ forecast_revenue=forecast_revenue,
+ forecast_source=forecast_source,
+ forecast_summary=forecast_summary,
+ has_overrides=has_overrides_flag,
+ snapshot_revenue=snapshot_revenue_val,
+ adjusted_revenue=adjusted_revenue_val,
+ gp_target_pct=gp_target,
+ min_budget=min_budget,
+ total_budget=total_budget,
+ total_spent=total_spent,
+ total_po_ordered=total_po_ordered,
+ total_remaining=total_remaining,
+ cd_budget_reservation=Decimal("0"), # Not used; CD is attributed per-supplier
+ suppliers=supplier_rows,
+ all_supplier_names=all_supplier_names,
+ daily_data=daily_data,
+ daily_totals=daily_totals,
+ )
+
+
+@router.get("/settings", response_model=BudgetSettingsResponse)
+async def get_budget_settings(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get current budget settings"""
+ settings = await get_settings(db, current_user.kitchen_id)
+
+ return BudgetSettingsResponse(
+ forecast_api_url=settings.forecast_api_url,
+ forecast_api_configured=bool(settings.forecast_api_url and settings.forecast_api_key),
+ budget_gp_target=settings.budget_gp_target or Decimal("65.00"),
+ budget_lookback_weeks=settings.budget_lookback_weeks or 4,
+ )
+
+
+@router.patch("/settings", response_model=BudgetSettingsResponse)
+async def update_budget_settings(
+ updates: BudgetSettingsUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update budget settings"""
+ settings = await get_settings(db, current_user.kitchen_id)
+
+ if updates.forecast_api_url is not None:
+ settings.forecast_api_url = updates.forecast_api_url or None
+
+ if updates.forecast_api_key is not None:
+ settings.forecast_api_key = updates.forecast_api_key or None
+
+ if updates.budget_gp_target is not None:
+ settings.budget_gp_target = updates.budget_gp_target
+
+ if updates.budget_lookback_weeks is not None:
+ settings.budget_lookback_weeks = updates.budget_lookback_weeks
+
+ await db.commit()
+ await db.refresh(settings)
+
+ return BudgetSettingsResponse(
+ forecast_api_url=settings.forecast_api_url,
+ forecast_api_configured=bool(settings.forecast_api_url and settings.forecast_api_key),
+ budget_gp_target=settings.budget_gp_target or Decimal("65.00"),
+ budget_lookback_weeks=settings.budget_lookback_weeks or 4,
+ )
+
+
+@router.post("/test-forecast-connection", response_model=TestConnectionResponse)
+async def test_forecast_connection(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Test connection to forecast API"""
+ settings = await get_settings(db, current_user.kitchen_id)
+
+ if not settings.forecast_api_url:
+ return TestConnectionResponse(
+ success=False,
+ message="Forecast API URL not configured"
+ )
+
+ if not settings.forecast_api_key:
+ return TestConnectionResponse(
+ success=False,
+ message="Forecast API key not configured"
+ )
+
+ try:
+ async with ForecastAPIClient(
+ settings.forecast_api_url,
+ settings.forecast_api_key
+ ) as client:
+ success, message = await client.test_connection()
+ return TestConnectionResponse(success=success, message=message)
+ except Exception as e:
+ return TestConnectionResponse(
+ success=False,
+ message=f"Connection failed: {str(e)}"
+ )
diff --git a/backend/api/calendar_events.py b/backend/api/calendar_events.py
new file mode 100644
index 0000000..2461420
--- /dev/null
+++ b/backend/api/calendar_events.py
@@ -0,0 +1,236 @@
+from datetime import date, timedelta
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.calendar_events import CalendarEvent
+from auth import get_current_user, require_cap
+
+router = APIRouter()
+
+
+# Pydantic schemas
+class CalendarEventCreate(BaseModel):
+ event_date: date
+ event_type: str # reminder, event, note
+ title: str
+ description: str | None = None
+
+
+class CalendarEventUpdate(BaseModel):
+ event_date: date | None = None
+ event_type: str | None = None
+ title: str | None = None
+ description: str | None = None
+
+
+class CalendarEventResponse(BaseModel):
+ id: int
+ event_date: date
+ event_type: str
+ title: str
+ description: str | None
+ created_at: str
+
+ class Config:
+ from_attributes = True
+
+
+# Endpoints
+@router.get("/")
+async def list_events(
+ from_date: Optional[date] = None,
+ to_date: Optional[date] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> list[CalendarEventResponse]:
+ """List events for date range"""
+ query = select(CalendarEvent).where(
+ CalendarEvent.kitchen_id == current_user.kitchen_id
+ )
+
+ if from_date:
+ query = query.where(CalendarEvent.event_date >= from_date)
+ if to_date:
+ query = query.where(CalendarEvent.event_date <= to_date)
+
+ query = query.order_by(CalendarEvent.event_date, CalendarEvent.created_at)
+ result = await db.execute(query)
+ events = result.scalars().all()
+
+ return [
+ CalendarEventResponse(
+ id=e.id,
+ event_date=e.event_date,
+ event_type=e.event_type,
+ title=e.title,
+ description=e.description,
+ created_at=e.created_at.isoformat()
+ )
+ for e in events
+ ]
+
+
+@router.get("/{date}")
+async def get_events_for_date(
+ date: date,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> list[CalendarEventResponse]:
+ """Get all events for a specific date"""
+ result = await db.execute(
+ select(CalendarEvent).where(
+ and_(
+ CalendarEvent.kitchen_id == current_user.kitchen_id,
+ CalendarEvent.event_date == date
+ )
+ ).order_by(CalendarEvent.created_at)
+ )
+ events = result.scalars().all()
+
+ return [
+ CalendarEventResponse(
+ id=e.id,
+ event_date=e.event_date,
+ event_type=e.event_type,
+ title=e.title,
+ description=e.description,
+ created_at=e.created_at.isoformat()
+ )
+ for e in events
+ ]
+
+
+@router.post("/")
+async def create_event(
+ event: CalendarEventCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> CalendarEventResponse:
+ """Create new event"""
+ new_event = CalendarEvent(
+ kitchen_id=current_user.kitchen_id,
+ event_date=event.event_date,
+ event_type=event.event_type,
+ title=event.title,
+ description=event.description,
+ created_by=current_user.id
+ )
+ db.add(new_event)
+ await db.commit()
+ await db.refresh(new_event)
+
+ return CalendarEventResponse(
+ id=new_event.id,
+ event_date=new_event.event_date,
+ event_type=new_event.event_type,
+ title=new_event.title,
+ description=new_event.description,
+ created_at=new_event.created_at.isoformat()
+ )
+
+
+@router.put("/{id}")
+async def update_event(
+ id: int,
+ update: CalendarEventUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update existing event"""
+ result = await db.execute(
+ select(CalendarEvent).where(
+ and_(
+ CalendarEvent.id == id,
+ CalendarEvent.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ event = result.scalar_one_or_none()
+
+ if not event:
+ raise HTTPException(status_code=404, detail="Event not found")
+
+ if update.event_date is not None:
+ event.event_date = update.event_date
+ if update.event_type is not None:
+ event.event_type = update.event_type
+ if update.title is not None:
+ event.title = update.title
+ if update.description is not None:
+ event.description = update.description
+
+ await db.commit()
+ await db.refresh(event)
+
+ return CalendarEventResponse(
+ id=event.id,
+ event_date=event.event_date,
+ event_type=event.event_type,
+ title=event.title,
+ description=event.description,
+ created_at=event.created_at.isoformat()
+ )
+
+
+@router.delete("/{id}")
+async def delete_event(
+ id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Delete event"""
+ result = await db.execute(
+ select(CalendarEvent).where(
+ and_(
+ CalendarEvent.id == id,
+ CalendarEvent.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ event = result.scalar_one_or_none()
+
+ if not event:
+ raise HTTPException(status_code=404, detail="Event not found")
+
+ await db.delete(event)
+ await db.commit()
+
+ return {"message": "Event deleted"}
+
+
+@router.get("/dashboard/upcoming")
+async def get_upcoming_events(
+ limit: int = 3,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get next N upcoming events for dashboard widget"""
+ today = date.today()
+
+ result = await db.execute(
+ select(CalendarEvent).where(
+ and_(
+ CalendarEvent.kitchen_id == current_user.kitchen_id,
+ CalendarEvent.event_date >= today
+ )
+ ).order_by(CalendarEvent.event_date, CalendarEvent.created_at).limit(limit)
+ )
+ events = result.scalars().all()
+
+ return {
+ "total_count": len(events),
+ "upcoming_events": [
+ {
+ "id": e.id,
+ "event_date": e.event_date.isoformat(),
+ "event_type": e.event_type,
+ "title": e.title
+ }
+ for e in events
+ ]
+ }
diff --git a/backend/api/cost_distributions.py b/backend/api/cost_distributions.py
new file mode 100644
index 0000000..4ff295c
--- /dev/null
+++ b/backend/api/cost_distributions.py
@@ -0,0 +1,863 @@
+"""
+Cost Distribution API endpoints — create, view, settle early, cancel,
+and weekly summaries for budget page integration.
+"""
+import logging
+from datetime import date, timedelta
+from decimal import Decimal, ROUND_HALF_UP
+from typing import Optional
+from collections import defaultdict
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_, delete
+from sqlalchemy.orm import selectinload
+from pydantic import BaseModel, field_serializer
+
+from database import get_db
+from models.user import User
+from models.invoice import Invoice, InvoiceStatus
+from models.line_item import LineItem
+from models.supplier import Supplier
+from models.settings import KitchenSettings
+from models.cost_distribution import (
+ CostDistribution,
+ CostDistributionLineSelection,
+ CostDistributionEntry,
+ DistributionStatus,
+ DistributionMethod,
+)
+from auth import get_current_user, require_cap
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ── Pydantic schemas ──────────────────────────────────────────────────────────
+
+class LineSelectionIn(BaseModel):
+ line_item_id: int
+ selected_quantity: Decimal
+
+
+class CostDistributionCreate(BaseModel):
+ invoice_id: int
+ method: str # "OFFSET" or "DISTRIBUTE"
+ notes: Optional[str] = None
+ line_selections: list[LineSelectionIn]
+ # For OFFSET
+ target_date: Optional[date] = None
+ # For DISTRIBUTE
+ days_of_week: Optional[list[int]] = None # 0=Mon, 6=Sun (Python weekday())
+ num_weeks: Optional[int] = None
+ start_date: Optional[date] = None
+
+
+class CostDistributionUpdate(BaseModel):
+ notes: Optional[str] = None
+
+
+class SettleEarlyRequest(BaseModel):
+ entry_date: date
+ amount: Optional[Decimal] = None # null = settle all
+
+
+# ── Response schemas ──────────────────────────────────────────────────────────
+
+class LineSelectionOut(BaseModel):
+ id: int
+ line_item_id: int
+ description: Optional[str] = None
+ original_quantity: Optional[Decimal] = None
+ selected_quantity: Decimal
+ unit_price: Decimal
+ distributed_value: Decimal
+
+ @field_serializer('selected_quantity', 'unit_price', 'distributed_value', 'original_quantity')
+ def ser(self, v: Optional[Decimal]) -> Optional[float]:
+ return float(v) if v is not None else None
+
+
+class EntryOut(BaseModel):
+ id: int
+ entry_date: date
+ amount: Decimal
+ is_source_offset: bool
+ is_overpay: bool
+
+ @field_serializer('amount')
+ def ser_amount(self, v: Decimal) -> float:
+ return float(v)
+
+
+class CostDistributionOut(BaseModel):
+ id: int
+ invoice_id: int
+ invoice_number: Optional[str] = None
+ invoice_date: Optional[date] = None
+ supplier_name: Optional[str] = None
+ status: str
+ method: str
+ notes: Optional[str]
+ total_distributed_value: Decimal
+ remaining_balance: Decimal
+ source_date: date
+ created_by_name: Optional[str] = None
+ created_at: str
+ line_selections: list[LineSelectionOut] = []
+ entries: list[EntryOut] = []
+
+ @field_serializer('total_distributed_value', 'remaining_balance')
+ def ser_dec(self, v: Decimal) -> float:
+ return float(v)
+
+
+class LineItemAvailability(BaseModel):
+ id: int
+ description: Optional[str]
+ unit: Optional[str]
+ quantity: Optional[Decimal]
+ unit_price: Optional[Decimal]
+ amount: Optional[Decimal]
+ is_non_stock: bool
+ already_distributed_qty: Decimal
+ available_qty: Decimal
+
+ @field_serializer('quantity', 'unit_price', 'amount', 'already_distributed_qty', 'available_qty')
+ def ser(self, v: Optional[Decimal]) -> Optional[float]:
+ return float(v) if v is not None else None
+
+
+class InvoiceAvailabilityOut(BaseModel):
+ invoice_id: int
+ invoice_number: Optional[str]
+ invoice_date: Optional[date]
+ supplier_name: Optional[str]
+ line_items: list[LineItemAvailability] = []
+
+
+class WeeklyDistributionRow(BaseModel):
+ distribution_id: int
+ title: str # Compact: "dd/mm/yy - Supplier"
+ supplier_name: Optional[str] = None
+ invoice_number: Optional[str] = None
+ source_date_str: str = "" # dd/mm/yy
+ summary: str = "" # e.g. "£200 laid away to 28/02/26" or "£200 distributed over 4 weeks"
+ notes: Optional[str] = None
+ invoice_id: int
+ entries_by_date: dict[str, float]
+ total_distributed_value: float
+ remaining_balance: float
+ bf_balance: float # Outstanding at start of this week
+ cf_balance: float # Outstanding at end of this week
+ status: str
+
+
+class WeeklyDistributionsOut(BaseModel):
+ week_start: date
+ week_end: date
+ distributions: list[WeeklyDistributionRow] = []
+ daily_totals: dict[str, float] = {}
+ bf_balance: float
+ cf_balance: float
+ week_total: float
+
+
+# ── Helper functions ──────────────────────────────────────────────────────────
+
+def _generate_target_dates(
+ method: str,
+ target_date: Optional[date],
+ days_of_week: Optional[list[int]],
+ num_weeks: Optional[int],
+ start_date: Optional[date],
+) -> list[date]:
+ """Generate target dates based on distribution method."""
+ if method == DistributionMethod.OFFSET.value:
+ if not target_date:
+ raise HTTPException(400, "target_date required for OFFSET method")
+ return [target_date]
+
+ if method == DistributionMethod.DISTRIBUTE.value:
+ if not days_of_week or not num_weeks or not start_date:
+ raise HTTPException(400, "days_of_week, num_weeks, and start_date required for DISTRIBUTE method")
+ if not days_of_week:
+ raise HTTPException(400, "At least one day of week must be selected")
+ if num_weeks < 1:
+ raise HTTPException(400, "num_weeks must be at least 1")
+
+ dates = []
+ for week in range(num_weeks):
+ week_start = start_date + timedelta(weeks=week)
+ for day_offset in range(7):
+ d = week_start + timedelta(days=day_offset)
+ if d.weekday() in days_of_week:
+ dates.append(d)
+
+ # Deduplicate and sort (in case start_date is mid-week)
+ dates = sorted(set(dates))
+ if not dates:
+ raise HTTPException(400, "No target dates generated from the selected days and weeks")
+ return dates
+
+ raise HTTPException(400, f"Invalid method: {method}")
+
+
+def _distribute_amount(total: Decimal, count: int) -> list[Decimal]:
+ """Distribute a total amount evenly across count entries. Last entry absorbs rounding."""
+ if count == 0:
+ return []
+ per_entry = (total / count).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
+ amounts = [per_entry] * count
+ # Adjust last entry to absorb rounding difference
+ distributed_sum = per_entry * (count - 1)
+ amounts[-1] = total - distributed_sum
+ return amounts
+
+
+async def _get_already_distributed_qty(
+ db: AsyncSession, line_item_id: int, exclude_distribution_id: Optional[int] = None
+) -> Decimal:
+ """Get total quantity already distributed for a line item from ACTIVE distributions."""
+ query = (
+ select(func.coalesce(func.sum(CostDistributionLineSelection.selected_quantity), 0))
+ .join(CostDistribution, CostDistributionLineSelection.distribution_id == CostDistribution.id)
+ .where(
+ CostDistributionLineSelection.line_item_id == line_item_id,
+ CostDistribution.status == DistributionStatus.ACTIVE.value,
+ )
+ )
+ if exclude_distribution_id:
+ query = query.where(CostDistribution.id != exclude_distribution_id)
+ result = await db.execute(query)
+ return Decimal(str(result.scalar() or 0))
+
+
+# ── Endpoints ─────────────────────────────────────────────────────────────────
+
+@router.get("/invoice/{invoice_id}/availability", response_model=InvoiceAvailabilityOut)
+async def get_invoice_availability(
+ invoice_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get line items available for distribution from an invoice."""
+ invoice = await db.execute(
+ select(Invoice)
+ .options(selectinload(Invoice.line_items))
+ .where(
+ Invoice.id == invoice_id,
+ Invoice.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ invoice = invoice.scalar_one_or_none()
+ if not invoice:
+ raise HTTPException(404, "Invoice not found")
+ if invoice.status != InvoiceStatus.CONFIRMED:
+ raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
+ if invoice.document_type == "credit_note":
+ raise HTTPException(400, "Credit notes cannot be distributed")
+
+ supplier_name = None
+ if invoice.supplier_id:
+ supplier = await db.execute(
+ select(Supplier).where(Supplier.id == invoice.supplier_id)
+ )
+ supplier = supplier.scalar_one_or_none()
+ if supplier:
+ supplier_name = supplier.name
+
+ items = []
+ for li in invoice.line_items:
+ already = await _get_already_distributed_qty(db, li.id)
+ original_qty = li.quantity or Decimal("0")
+ available = max(Decimal("0"), original_qty - already)
+ items.append(LineItemAvailability(
+ id=li.id,
+ description=li.description,
+ unit=li.unit,
+ quantity=li.quantity,
+ unit_price=li.unit_price,
+ amount=li.amount,
+ is_non_stock=li.is_non_stock or False,
+ already_distributed_qty=already,
+ available_qty=available,
+ ))
+
+ return InvoiceAvailabilityOut(
+ invoice_id=invoice.id,
+ invoice_number=invoice.invoice_number,
+ invoice_date=invoice.invoice_date,
+ supplier_name=supplier_name or invoice.vendor_name,
+ line_items=items,
+ )
+
+
+@router.post("/", response_model=CostDistributionOut)
+async def create_cost_distribution(
+ data: CostDistributionCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Create a new cost distribution."""
+ # Validate invoice
+ invoice = await db.execute(
+ select(Invoice)
+ .options(selectinload(Invoice.line_items))
+ .where(
+ Invoice.id == data.invoice_id,
+ Invoice.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ invoice = invoice.scalar_one_or_none()
+ if not invoice:
+ raise HTTPException(404, "Invoice not found")
+ if invoice.status != InvoiceStatus.CONFIRMED:
+ raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
+ if invoice.document_type == "credit_note":
+ raise HTTPException(400, "Credit notes cannot be distributed")
+ if not invoice.invoice_date:
+ raise HTTPException(400, "Invoice must have a date to be distributed")
+
+ # Validate method
+ if data.method not in [m.value for m in DistributionMethod]:
+ raise HTTPException(400, f"Invalid method: {data.method}")
+
+ # Get settings for max_days validation
+ settings = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = settings.scalar_one_or_none()
+ max_days = settings.cost_distribution_max_days if settings else 90
+
+ # Generate target dates
+ target_dates = _generate_target_dates(
+ data.method, data.target_date, data.days_of_week, data.num_weeks, data.start_date
+ )
+
+ # Validate max days
+ today = date.today()
+ max_allowed_date = today + timedelta(days=max_days)
+ for td in target_dates:
+ if td > max_allowed_date:
+ raise HTTPException(
+ 400,
+ f"Target date {td} exceeds maximum of {max_days} days into the future ({max_allowed_date})"
+ )
+
+ # Build line item map
+ line_item_map = {li.id: li for li in invoice.line_items}
+
+ # Validate line selections and calculate totals
+ if not data.line_selections:
+ raise HTTPException(400, "At least one line item must be selected")
+
+ total_distributed_value = Decimal("0")
+ selections_data = []
+
+ for sel in data.line_selections:
+ li = line_item_map.get(sel.line_item_id)
+ if not li:
+ raise HTTPException(400, f"Line item {sel.line_item_id} not found on invoice")
+ if li.is_non_stock:
+ raise HTTPException(400, f"Non-stock item '{li.description}' cannot be distributed")
+ if sel.selected_quantity <= 0:
+ raise HTTPException(400, "Selected quantity must be greater than 0")
+
+ # Check available quantity
+ already = await _get_already_distributed_qty(db, li.id)
+ original_qty = li.quantity or Decimal("0")
+ available = original_qty - already
+ if sel.selected_quantity > available:
+ raise HTTPException(
+ 400,
+ f"Requested qty {sel.selected_quantity} exceeds available {available} for '{li.description}'"
+ )
+
+ unit_price = li.unit_price or Decimal("0")
+ distributed_value = (sel.selected_quantity * unit_price).quantize(
+ Decimal("0.01"), rounding=ROUND_HALF_UP
+ )
+ total_distributed_value += distributed_value
+ selections_data.append({
+ "line_item_id": li.id,
+ "selected_quantity": sel.selected_quantity,
+ "unit_price": unit_price,
+ "distributed_value": distributed_value,
+ })
+
+ if total_distributed_value <= 0:
+ raise HTTPException(400, "Total distributed value must be greater than 0")
+
+ # Create the distribution header
+ distribution = CostDistribution(
+ kitchen_id=current_user.kitchen_id,
+ invoice_id=invoice.id,
+ status=DistributionStatus.ACTIVE.value,
+ method=data.method,
+ notes=data.notes,
+ total_distributed_value=total_distributed_value,
+ remaining_balance=total_distributed_value,
+ source_date=invoice.invoice_date,
+ created_by=current_user.id,
+ )
+ db.add(distribution)
+ await db.flush() # Get the distribution ID
+
+ # Create line selections
+ for sel_data in selections_data:
+ selection = CostDistributionLineSelection(
+ distribution_id=distribution.id,
+ **sel_data,
+ )
+ db.add(selection)
+
+ # Create source offset entry (negative on invoice date)
+ source_entry = CostDistributionEntry(
+ distribution_id=distribution.id,
+ kitchen_id=current_user.kitchen_id,
+ entry_date=invoice.invoice_date,
+ amount=-total_distributed_value,
+ is_source_offset=True,
+ is_overpay=False,
+ )
+ db.add(source_entry)
+
+ # Create target entries
+ entry_amounts = _distribute_amount(total_distributed_value, len(target_dates))
+ for td, amt in zip(target_dates, entry_amounts):
+ entry = CostDistributionEntry(
+ distribution_id=distribution.id,
+ kitchen_id=current_user.kitchen_id,
+ entry_date=td,
+ amount=amt,
+ is_source_offset=False,
+ is_overpay=False,
+ )
+ db.add(entry)
+
+ await db.commit()
+ await db.refresh(distribution)
+
+ return await _build_distribution_response(db, distribution)
+
+
+@router.get("/weekly", response_model=WeeklyDistributionsOut)
+async def get_weekly_distributions(
+ week_start: date,
+ week_end: date,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get all cost distributions relevant to a week view for the budget page."""
+ kitchen_id = current_user.kitchen_id
+
+ # Get ALL active distributions (even if no entries this week) to prevent gaming
+ # Also include COMPLETED distributions that have entries in this week period
+ active_dists = await db.execute(
+ select(CostDistribution)
+ .options(
+ selectinload(CostDistribution.entries),
+ selectinload(CostDistribution.invoice),
+ )
+ .where(
+ CostDistribution.kitchen_id == kitchen_id,
+ CostDistribution.status == DistributionStatus.ACTIVE.value,
+ )
+ .order_by(CostDistribution.id)
+ )
+ active_dists = active_dists.scalars().all()
+
+ # Completed distributions that have entries within the viewed week
+ completed_dists = await db.execute(
+ select(CostDistribution)
+ .options(
+ selectinload(CostDistribution.entries),
+ selectinload(CostDistribution.invoice),
+ )
+ .where(
+ CostDistribution.kitchen_id == kitchen_id,
+ CostDistribution.status == DistributionStatus.COMPLETED.value,
+ CostDistribution.id.in_(
+ select(CostDistributionEntry.distribution_id)
+ .where(
+ CostDistributionEntry.kitchen_id == kitchen_id,
+ CostDistributionEntry.entry_date >= week_start,
+ CostDistributionEntry.entry_date <= week_end,
+ )
+ ),
+ )
+ .order_by(CostDistribution.id)
+ )
+ completed_dists = completed_dists.scalars().all()
+
+ # Merge — active first, then completed with entries in period
+ seen_ids = {d.id for d in active_dists}
+ distributions = list(active_dists)
+ for d in completed_dists:
+ if d.id not in seen_ids:
+ distributions.append(d)
+
+ rows = []
+ daily_totals: dict[str, Decimal] = defaultdict(Decimal)
+ bf_balance = Decimal("0")
+ week_total = Decimal("0")
+
+ for dist in distributions:
+ # Get supplier name
+ supplier_name = None
+ if dist.invoice:
+ if dist.invoice.supplier_id:
+ supplier = await db.execute(
+ select(Supplier).where(Supplier.id == dist.invoice.supplier_id)
+ )
+ supplier = supplier.scalar_one_or_none()
+ if supplier:
+ supplier_name = supplier.name
+ if not supplier_name:
+ supplier_name = dist.invoice.vendor_name
+
+ invoice_num = dist.invoice.invoice_number if dist.invoice else None
+ source_date_short = dist.source_date.strftime("%d/%m/%y") if dist.source_date else ""
+ title = f"{source_date_short} - {supplier_name or 'Unknown'}"
+
+ # Build summary text
+ total_val = f"£{float(dist.total_distributed_value):.2f}"
+ target_entries = [e for e in dist.entries if not e.is_source_offset and not e.is_overpay]
+ if dist.method == "OFFSET" and target_entries:
+ target_date = target_entries[0].entry_date.strftime("%d/%m/%y")
+ summary = f"{total_val} laid away to {target_date}"
+ elif target_entries:
+ # DISTRIBUTE: count unique days of week and number of weeks
+ target_dates = sorted(set(e.entry_date for e in target_entries))
+ if len(target_dates) > 1:
+ first, last = target_dates[0], target_dates[-1]
+ num_weeks = max(1, ((last - first).days // 7) + 1)
+ dow_set = set(d.strftime("%a") for d in target_dates)
+ days_str = ",".join(sorted(dow_set, key=lambda x: ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].index(x)))
+ summary = f"{total_val} distributed {days_str} over {num_weeks} weeks"
+ else:
+ summary = f"{total_val} distributed to {target_dates[0].strftime('%d/%m/%y')}"
+ else:
+ summary = f"{total_val} distributed"
+
+ # Get entries for this week (including source offset so user sees the deduction)
+ entries_by_date: dict[str, float] = {}
+ for entry in dist.entries:
+ if week_start <= entry.entry_date <= week_end:
+ date_key = entry.entry_date.isoformat()
+ entries_by_date[date_key] = entries_by_date.get(date_key, 0) + float(entry.amount)
+ daily_totals[date_key] += entry.amount
+ week_total += entry.amount
+
+ # Per-distribution BF/CF as net running balance:
+ # BF = sum of ALL entries (including source offset) before this week
+ # CF = BF + sum of ALL entries within this week
+ # e.g. new distribution: BF=0, source deducts -£200, CF=-£200
+ # next week: BF=-£200, positives bring it toward 0
+ dist_bf = sum(
+ e.amount for e in dist.entries
+ if e.entry_date < week_start
+ )
+ entries_this_week = sum(
+ e.amount for e in dist.entries
+ if week_start <= e.entry_date <= week_end
+ )
+ dist_cf = dist_bf + entries_this_week
+
+ # Only include distributions that are relevant to this period:
+ # has a non-zero BF or CF, or has entries in this week
+ if dist_bf == 0 and dist_cf == 0 and not entries_by_date:
+ continue
+
+ bf_balance += dist_bf
+
+ rows.append(WeeklyDistributionRow(
+ distribution_id=dist.id,
+ title=title,
+ supplier_name=supplier_name,
+ invoice_number=invoice_num,
+ source_date_str=source_date_short,
+ summary=summary,
+ notes=dist.notes,
+ invoice_id=dist.invoice_id,
+ entries_by_date=entries_by_date,
+ total_distributed_value=float(dist.total_distributed_value),
+ remaining_balance=float(dist.remaining_balance),
+ bf_balance=float(dist_bf),
+ cf_balance=float(dist_cf),
+ status=dist.status,
+ ))
+
+ cf_balance = bf_balance + week_total
+
+ return WeeklyDistributionsOut(
+ week_start=week_start,
+ week_end=week_end,
+ distributions=rows,
+ daily_totals={k: float(v) for k, v in daily_totals.items()},
+ bf_balance=float(bf_balance),
+ cf_balance=float(cf_balance),
+ week_total=float(week_total),
+ )
+
+
+@router.get("/{distribution_id}", response_model=CostDistributionOut)
+async def get_cost_distribution(
+ distribution_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get a single cost distribution with line selections and entries."""
+ distribution = await db.execute(
+ select(CostDistribution)
+ .options(
+ selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
+ selectinload(CostDistribution.entries),
+ )
+ .where(
+ CostDistribution.id == distribution_id,
+ CostDistribution.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ distribution = distribution.scalar_one_or_none()
+ if not distribution:
+ raise HTTPException(404, "Cost distribution not found")
+
+ return await _build_distribution_response(db, distribution)
+
+
+@router.put("/{distribution_id}", response_model=CostDistributionOut)
+async def update_cost_distribution(
+ distribution_id: int,
+ data: CostDistributionUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Update notes on an existing distribution."""
+ distribution = await db.execute(
+ select(CostDistribution)
+ .options(
+ selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
+ selectinload(CostDistribution.entries),
+ )
+ .where(
+ CostDistribution.id == distribution_id,
+ CostDistribution.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ distribution = distribution.scalar_one_or_none()
+ if not distribution:
+ raise HTTPException(404, "Cost distribution not found")
+ if distribution.status != DistributionStatus.ACTIVE.value:
+ raise HTTPException(400, "Can only update ACTIVE distributions")
+
+ if data.notes is not None:
+ distribution.notes = data.notes
+
+ await db.commit()
+ await db.refresh(distribution)
+
+ return await _build_distribution_response(db, distribution)
+
+
+@router.delete("/{distribution_id}")
+async def cancel_cost_distribution(
+ distribution_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Cancel a cost distribution. Non-admin restricted to distributions
+ where the source (invoice) date is within 14 days."""
+ distribution = await db.execute(
+ select(CostDistribution).where(
+ CostDistribution.id == distribution_id,
+ CostDistribution.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ distribution = distribution.scalar_one_or_none()
+ if not distribution:
+ raise HTTPException(404, "Cost distribution not found")
+ if distribution.status == DistributionStatus.CANCELLED.value:
+ raise HTTPException(400, "Distribution is already cancelled")
+
+ # Anti-gaming: non-admin cannot cancel distributions where the source
+ # date is more than 14 days in the past (cost would silently revert to
+ # the old invoice date and could be overlooked)
+ if not current_user.is_admin:
+ min_allowed_date = date.today() - timedelta(days=14)
+ if distribution.source_date < min_allowed_date:
+ raise HTTPException(
+ 400,
+ f"Cannot cancel — invoice date {distribution.source_date} is more than 14 days ago. Ask an admin."
+ )
+
+ from datetime import datetime
+ distribution.status = DistributionStatus.CANCELLED.value
+ distribution.cancelled_by = current_user.id
+ distribution.cancelled_at = datetime.utcnow()
+
+ await db.commit()
+ return {"message": "Cost distribution cancelled", "id": distribution_id}
+
+
+@router.post("/{distribution_id}/settle-early", response_model=CostDistributionOut)
+async def settle_early(
+ distribution_id: int,
+ data: SettleEarlyRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Settle a distribution early by moving remaining balance to a chosen date."""
+ distribution = await db.execute(
+ select(CostDistribution)
+ .options(
+ selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
+ selectinload(CostDistribution.entries),
+ )
+ .where(
+ CostDistribution.id == distribution_id,
+ CostDistribution.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ distribution = distribution.scalar_one_or_none()
+ if not distribution:
+ raise HTTPException(404, "Cost distribution not found")
+ if distribution.status != DistributionStatus.ACTIVE.value:
+ raise HTTPException(400, "Can only settle ACTIVE distributions")
+
+ today = date.today()
+
+ # Anti-gaming: non-admin cannot settle more than 14 days in the past
+ if not current_user.is_admin:
+ min_allowed_date = today - timedelta(days=14)
+ if data.entry_date < min_allowed_date:
+ raise HTTPException(
+ 400,
+ f"Cannot settle more than 14 days in the past. Earliest allowed: {min_allowed_date}"
+ )
+
+ # Calculate entries from the settle date onwards — these get replaced by the single
+ # settle entry. Using entry_date (not today) so that settling for yesterday correctly
+ # consolidates yesterday's entry + all future entries into the chosen date.
+ settable_entries = [
+ e for e in distribution.entries
+ if not e.is_source_offset and not e.is_overpay and e.entry_date >= data.entry_date
+ ]
+ settable_total = sum(e.amount for e in settable_entries)
+
+ if settable_total <= 0:
+ raise HTTPException(400, "No entries to settle from the chosen date onwards")
+
+ settle_amount = data.amount if data.amount is not None else settable_total
+ if settle_amount <= 0:
+ raise HTTPException(400, "Settle amount must be greater than 0")
+ if settle_amount > settable_total:
+ raise HTTPException(400, f"Settle amount {settle_amount} exceeds settable entries total {settable_total}")
+
+ # Delete all entries from the settle date onwards
+ for entry in settable_entries:
+ await db.delete(entry)
+
+ # Create settle entry on the chosen date
+ overpay_entry = CostDistributionEntry(
+ distribution_id=distribution.id,
+ kitchen_id=current_user.kitchen_id,
+ entry_date=data.entry_date,
+ amount=settle_amount,
+ is_source_offset=False,
+ is_overpay=True,
+ )
+ db.add(overpay_entry)
+
+ # Update remaining_balance: what's left unaccounted
+ new_remaining = settable_total - settle_amount
+ distribution.remaining_balance = new_remaining
+
+ # If fully settled, mark as completed
+ if new_remaining <= 0:
+ distribution.status = DistributionStatus.COMPLETED.value
+ distribution.remaining_balance = Decimal("0")
+
+ await db.commit()
+ await db.refresh(distribution)
+
+ return await _build_distribution_response(db, distribution)
+
+
+# ── Response builder helper ───────────────────────────────────────────────────
+
+async def _build_distribution_response(
+ db: AsyncSession, distribution: CostDistribution
+) -> CostDistributionOut:
+ """Build the full response object for a cost distribution."""
+ # Get invoice info
+ invoice = await db.execute(
+ select(Invoice).where(Invoice.id == distribution.invoice_id)
+ )
+ invoice = invoice.scalar_one_or_none()
+
+ supplier_name = None
+ if invoice and invoice.supplier_id:
+ supplier = await db.execute(
+ select(Supplier).where(Supplier.id == invoice.supplier_id)
+ )
+ supplier = supplier.scalar_one_or_none()
+ if supplier:
+ supplier_name = supplier.name
+ if not supplier_name and invoice:
+ supplier_name = invoice.vendor_name
+
+ # Get creator name
+ creator = await db.execute(
+ select(User).where(User.id == distribution.created_by)
+ )
+ creator = creator.scalar_one_or_none()
+
+ # Build line selections with line item details
+ line_selections = []
+ for sel in distribution.line_selections:
+ li = sel.line_item if hasattr(sel, 'line_item') and sel.line_item else None
+ if not li:
+ li_result = await db.execute(select(LineItem).where(LineItem.id == sel.line_item_id))
+ li = li_result.scalar_one_or_none()
+
+ line_selections.append(LineSelectionOut(
+ id=sel.id,
+ line_item_id=sel.line_item_id,
+ description=li.description if li else None,
+ original_quantity=li.quantity if li else None,
+ selected_quantity=sel.selected_quantity,
+ unit_price=sel.unit_price,
+ distributed_value=sel.distributed_value,
+ ))
+
+ entries = [
+ EntryOut(
+ id=e.id,
+ entry_date=e.entry_date,
+ amount=e.amount,
+ is_source_offset=e.is_source_offset,
+ is_overpay=e.is_overpay,
+ )
+ for e in distribution.entries
+ ]
+
+ return CostDistributionOut(
+ id=distribution.id,
+ invoice_id=distribution.invoice_id,
+ invoice_number=invoice.invoice_number if invoice else None,
+ invoice_date=invoice.invoice_date if invoice else None,
+ supplier_name=supplier_name,
+ status=distribution.status,
+ method=distribution.method,
+ notes=distribution.notes,
+ total_distributed_value=distribution.total_distributed_value,
+ remaining_balance=distribution.remaining_balance,
+ source_date=distribution.source_date,
+ created_by_name=creator.name if creator else None,
+ created_at=distribution.created_at.isoformat() if distribution.created_at else "",
+ line_selections=line_selections,
+ entries=entries,
+ )
diff --git a/backend/api/cover_overrides.py b/backend/api/cover_overrides.py
new file mode 100644
index 0000000..f123e73
--- /dev/null
+++ b/backend/api/cover_overrides.py
@@ -0,0 +1,648 @@
+"""
+Cover Override API endpoints.
+
+Handles forecast snapshots, cover overrides, and spend rate overrides
+for the Spend Budget feature.
+"""
+from datetime import date, timedelta
+from decimal import Decimal
+from typing import Optional
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import text, select, delete
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.settings import KitchenSettings
+from auth import get_current_user, require_cap
+from services.forecast_api import ForecastAPIClient, ForecastAPIError
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# --- Request/Response Models ---
+
+class SnapshotRequest(BaseModel):
+ week_offset: int = 0
+
+
+class CoverOverrideRequest(BaseModel):
+ override_date: str # YYYY-MM-DD
+ period: str # 'lunch' or 'dinner'
+ override_covers: int # target total covers
+
+
+class SpendRateOverrideRequest(BaseModel):
+ week_offset: int = 0
+ period: str # 'breakfast', 'lunch', or 'dinner'
+ food_spend: Optional[float] = None
+ drinks_spend: Optional[float] = None
+
+
+class SnapshotData(BaseModel):
+ date: str
+ period: str
+ forecast_covers: int
+ otb_covers: int
+ food_spend: Optional[float]
+ drinks_spend: Optional[float]
+ forecast_dry_revenue: Optional[float]
+
+
+class OverrideData(BaseModel):
+ id: int
+ override_date: str
+ period: str
+ override_covers: int
+ original_forecast: Optional[int]
+ original_otb: Optional[int]
+
+
+class SpendRateData(BaseModel):
+ period: str
+ food_spend_api: Optional[float]
+ drinks_spend_api: Optional[float]
+ food_spend_snapshot: Optional[float]
+ drinks_spend_snapshot: Optional[float]
+ food_spend_override: Optional[float]
+ drinks_spend_override: Optional[float]
+ food_spend_effective: float
+ drinks_spend_effective: float
+
+
+class RecalcDay(BaseModel):
+ date: str
+ day_name: str
+ is_past: bool
+ periods: dict # period -> {actual, otb, pickup, effective, override, snapshot, variance}
+ day_revenue: float
+
+
+class WeeklyOverrideResponse(BaseModel):
+ week_start: str
+ week_end: str
+ has_snapshot: bool
+ vat_rate: float = 1.20
+ snapshot_revenue: Optional[float] = None
+ adjusted_revenue: Optional[float] = None
+ snapshots: list[SnapshotData] = []
+ overrides: list[OverrideData] = []
+ spend_rates: list[SpendRateData] = []
+ recalc_days: list[RecalcDay] = []
+
+
+# --- Helpers ---
+
+def get_week_dates(week_offset: int = 0) -> tuple[date, date, list[date]]:
+ today = date.today()
+ current_monday = today - timedelta(days=today.weekday())
+ week_start = current_monday + timedelta(weeks=week_offset)
+ week_end = week_start + timedelta(days=6)
+ week_dates = [week_start + timedelta(days=i) for i in range(7)]
+ return week_start, week_end, week_dates
+
+
+async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings:
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+ if not settings:
+ raise HTTPException(status_code=404, detail="Kitchen settings not found")
+ return settings
+
+
+# --- Endpoints ---
+
+@router.get("/weekly", response_model=WeeklyOverrideResponse)
+async def get_weekly_overrides(
+ week_offset: int = 0,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get snapshot, overrides, spend rates, and recalculated breakdown for a week."""
+ week_start, week_end, week_dates = get_week_dates(week_offset)
+ kitchen_id = current_user.kitchen_id
+ today = date.today()
+
+ # Check if snapshot exists
+ snap_result = await db.execute(text("""
+ SELECT snapshot_date, period, forecast_covers, otb_covers,
+ food_spend, drinks_spend, forecast_dry_revenue
+ FROM forecast_snapshots
+ WHERE kitchen_id = :kid AND week_start = :ws
+ ORDER BY snapshot_date, period
+ """), {"kid": kitchen_id, "ws": week_start})
+ snap_rows = snap_result.fetchall()
+
+ has_snapshot = len(snap_rows) > 0
+ snapshots = []
+ snap_lookup = {} # (date_str, period) -> row
+
+ for row in snap_rows:
+ date_str = row.snapshot_date.isoformat() if hasattr(row.snapshot_date, 'isoformat') else str(row.snapshot_date)
+ snapshots.append(SnapshotData(
+ date=date_str,
+ period=row.period,
+ forecast_covers=row.forecast_covers,
+ otb_covers=row.otb_covers,
+ food_spend=float(row.food_spend) if row.food_spend else None,
+ drinks_spend=float(row.drinks_spend) if row.drinks_spend else None,
+ forecast_dry_revenue=float(row.forecast_dry_revenue) if row.forecast_dry_revenue else None,
+ ))
+ snap_lookup[(date_str, row.period)] = row
+
+ # Get week snapshot for revenue totals
+ week_snap_result = await db.execute(text("""
+ SELECT total_forecast_revenue, total_otb_revenue, gp_target
+ FROM forecast_week_snapshots
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": kitchen_id, "ws": week_start})
+ week_snap = week_snap_result.fetchone()
+ snapshot_revenue = float(week_snap.total_forecast_revenue) if week_snap and week_snap.total_forecast_revenue else None
+
+ # Get overrides
+ override_result = await db.execute(text("""
+ SELECT id, override_date, period, override_covers, original_forecast, original_otb
+ FROM cover_overrides
+ WHERE kitchen_id = :kid
+ AND override_date >= :ws AND override_date <= :we
+ ORDER BY override_date, period
+ """), {"kid": kitchen_id, "ws": week_start, "we": week_end})
+ override_rows = override_result.fetchall()
+
+ overrides = []
+ override_lookup = {} # (date_str, period) -> row
+ for row in override_rows:
+ date_str = row.override_date.isoformat() if hasattr(row.override_date, 'isoformat') else str(row.override_date)
+ overrides.append(OverrideData(
+ id=row.id,
+ override_date=date_str,
+ period=row.period,
+ override_covers=row.override_covers,
+ original_forecast=row.original_forecast,
+ original_otb=row.original_otb,
+ ))
+ override_lookup[(date_str, row.period)] = row
+
+ # Get spend rate overrides
+ spend_override_result = await db.execute(text("""
+ SELECT id, period, food_spend, drinks_spend
+ FROM spend_rate_overrides
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": kitchen_id, "ws": week_start})
+ spend_override_rows = spend_override_result.fetchall()
+ spend_override_lookup = {row.period: row for row in spend_override_rows}
+
+ # Fetch live forecast data + spend rates from API
+ settings = await get_settings(db, kitchen_id)
+ api_spend_rates = {}
+ api_vat_rate = 1.20
+ covers_data = []
+ revenue_data = []
+
+ if settings.forecast_api_url and settings.forecast_api_key:
+ try:
+ async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
+ try:
+ sr = await client.get_spend_rates()
+ api_spend_rates = sr.get("periods", {})
+ api_vat_rate = sr.get("vat_rate", 1.20)
+ except Exception as e:
+ logger.warning(f"Failed to fetch spend rates: {e}")
+
+ try:
+ covers_data = await client.get_covers_forecast(week_start, days=7)
+ except Exception as e:
+ logger.warning(f"Failed to fetch covers: {e}")
+
+ try:
+ revenue_data = await client.get_revenue_forecast(week_start, days=7)
+ except Exception as e:
+ logger.warning(f"Failed to fetch revenue: {e}")
+ except Exception as e:
+ logger.warning(f"Failed to connect to forecast API: {e}")
+
+ # Build spend rates response (per period)
+ spend_rates = []
+ spend_effective = {} # period -> {food, drinks}
+ for period in ("breakfast", "lunch", "dinner"):
+ api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0)
+ api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0)
+
+ # Get snapshot values (from first day of snapshot, same for all days in a period)
+ snap_food = None
+ snap_drinks = None
+ for d in week_dates:
+ key = (d.isoformat(), period)
+ if key in snap_lookup:
+ snap_food = float(snap_lookup[key].food_spend) if snap_lookup[key].food_spend else None
+ snap_drinks = float(snap_lookup[key].drinks_spend) if snap_lookup[key].drinks_spend else None
+ break
+
+ # Get override values
+ ovr = spend_override_lookup.get(period)
+ ovr_food = float(ovr.food_spend) if ovr and ovr.food_spend else None
+ ovr_drinks = float(ovr.drinks_spend) if ovr and ovr.drinks_spend else None
+
+ # Resolve effective: override > snapshot > API
+ eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food)
+ eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks)
+
+ spend_effective[period] = {"food": eff_food, "drinks": eff_drinks}
+ spend_rates.append(SpendRateData(
+ period=period,
+ food_spend_api=api_food,
+ drinks_spend_api=api_drinks,
+ food_spend_snapshot=snap_food,
+ drinks_spend_snapshot=snap_drinks,
+ food_spend_override=ovr_food,
+ drinks_spend_override=ovr_drinks,
+ food_spend_effective=eff_food,
+ drinks_spend_effective=eff_drinks,
+ ))
+
+ # Build recalculated days
+ covers_by_date = {d.get("date", ""): d for d in covers_data}
+ revenue_by_date = {d.get("date", ""): d for d in revenue_data}
+ recalc_days = []
+ total_adjusted_revenue = Decimal("0")
+
+ for d in week_dates:
+ date_str = d.isoformat()
+ is_past = d < today
+ day_covers = covers_by_date.get(date_str, {})
+ day_revenue = revenue_by_date.get(date_str, {})
+ day_name = day_covers.get("day", d.strftime("%a"))
+
+ periods_data = {}
+ day_rev = Decimal("0")
+
+ for period in ("breakfast", "lunch", "dinner"):
+ p_covers = day_covers.get(period, {})
+ otb = p_covers.get("otb", 0) or 0
+ forecast = p_covers.get("forecast", 0) or 0
+ pickup = forecast - otb
+
+ # Get snapshot and override for this day/period
+ snap = snap_lookup.get((date_str, period))
+ ovr = override_lookup.get((date_str, period))
+
+ snap_forecast = snap.forecast_covers if snap else None
+ override_val = ovr.override_covers if ovr else None
+
+ if is_past:
+ # Past day: use actual (forecast value from API is actual for past dates)
+ effective = forecast
+ actual = forecast
+ # Calculate variance vs override or snapshot
+ variance = None
+ if override_val is not None:
+ diff = actual - override_val
+ if diff != 0:
+ variance = diff
+ elif snap_forecast is not None:
+ diff = actual - snap_forecast
+ if diff != 0:
+ variance = diff
+
+ # Past revenue from API (dry revenue)
+ dry = day_revenue.get("dry", {})
+ period_rev = Decimal(str(dry.get("forecast", 0) or 0)) if period == "breakfast" else Decimal("0")
+ # For past days, use actual total revenue from the API (proportioned by period isn't available,
+ # so we'll use effective_covers * spend_rates as approximation, but actual total from revenue API)
+ eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
+ period_rev = Decimal(str(effective)) * eff_food
+
+ periods_data[period] = {
+ "actual": actual,
+ "otb": otb,
+ "pickup": 0,
+ "effective": effective,
+ "override": override_val,
+ "snapshot": snap_forecast,
+ "variance": variance,
+ "is_overridden": False,
+ }
+ else:
+ # Today/future: apply override logic
+ if override_val is not None:
+ if otb >= override_val:
+ effective = otb # OTB supersedes upward
+ adj_pickup = 0
+ else:
+ effective = override_val
+ adj_pickup = override_val - otb
+ is_overridden = True
+ else:
+ effective = forecast
+ adj_pickup = pickup
+ is_overridden = False
+
+ # Calculate revenue for this period (dry/food only - budget tracks food revenue)
+ eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
+ period_rev = Decimal(str(effective)) * eff_food
+
+ periods_data[period] = {
+ "actual": None,
+ "otb": otb,
+ "pickup": adj_pickup,
+ "effective": effective,
+ "override": override_val,
+ "snapshot": snap_forecast,
+ "variance": None,
+ "is_overridden": is_overridden,
+ }
+
+ day_rev += period_rev
+
+ # For past days, use actual revenue from API if available
+ if is_past and day_revenue:
+ dry = day_revenue.get("dry", {})
+ actual_dry_rev = Decimal(str(dry.get("forecast", 0) or 0))
+ if actual_dry_rev > 0:
+ day_rev = actual_dry_rev
+
+ total_adjusted_revenue += day_rev
+
+ recalc_days.append(RecalcDay(
+ date=date_str,
+ day_name=day_name,
+ is_past=is_past,
+ periods=periods_data,
+ day_revenue=float(round(day_rev, 2)),
+ ))
+
+ return WeeklyOverrideResponse(
+ week_start=week_start.isoformat(),
+ week_end=week_end.isoformat(),
+ has_snapshot=has_snapshot,
+ vat_rate=api_vat_rate,
+ snapshot_revenue=snapshot_revenue,
+ adjusted_revenue=float(round(total_adjusted_revenue, 2)),
+ snapshots=snapshots,
+ overrides=overrides,
+ spend_rates=spend_rates,
+ recalc_days=recalc_days,
+ )
+
+
+@router.post("/snapshot")
+async def create_snapshot(
+ req: SnapshotRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Take a snapshot of the current forecast for a week (all periods, all days)."""
+ week_start, week_end, week_dates = get_week_dates(req.week_offset)
+ kitchen_id = current_user.kitchen_id
+ settings = await get_settings(db, kitchen_id)
+
+ if not settings.forecast_api_url or not settings.forecast_api_key:
+ raise HTTPException(status_code=400, detail="Forecast API not configured")
+
+ async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
+ covers_data = await client.get_covers_forecast(week_start, days=7)
+ revenue_data = await client.get_revenue_forecast(week_start, days=7)
+ spend_rates_response = await client.get_spend_rates()
+
+ api_spend = spend_rates_response.get("periods", {})
+ vat_rate = spend_rates_response.get("vat_rate", 1.20)
+
+ covers_by_date = {d.get("date", ""): d for d in covers_data}
+ revenue_by_date = {d.get("date", ""): d for d in revenue_data}
+
+ # Delete existing snapshots for this week (re-snapshot)
+ await db.execute(text("""
+ DELETE FROM forecast_snapshots
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": kitchen_id, "ws": week_start})
+
+ # Insert snapshot for each day/period
+ total_forecast_rev = Decimal("0")
+ total_otb_rev = Decimal("0")
+
+ for d in week_dates:
+ date_str = d.isoformat()
+ day_covers = covers_by_date.get(date_str, {})
+ day_revenue = revenue_by_date.get(date_str, {})
+
+ for period in ("breakfast", "lunch", "dinner"):
+ p = day_covers.get(period, {})
+ otb = p.get("otb", 0) or 0
+ forecast = p.get("forecast", 0) or 0
+
+ # Spend rates (net, ex VAT)
+ food_net = api_spend.get(period, {}).get("food_spend_net", 0)
+ drinks_net = api_spend.get(period, {}).get("drinks_spend_net", 0)
+
+ # Calculate dry revenue for this period/day
+ dry_rev = Decimal(str(forecast)) * Decimal(str(food_net))
+
+ await db.execute(text("""
+ INSERT INTO forecast_snapshots
+ (kitchen_id, snapshot_date, period, forecast_covers, otb_covers,
+ food_spend, drinks_spend, forecast_dry_revenue, week_start)
+ VALUES (:kid, :sd, :period, :fc, :oc, :fs, :ds, :dr, :ws)
+ """), {
+ "kid": kitchen_id, "sd": d, "period": period,
+ "fc": forecast, "oc": otb,
+ "fs": food_net, "ds": drinks_net,
+ "dr": float(round(dry_rev, 2)),
+ "ws": week_start,
+ })
+
+ # Accumulate weekly totals from revenue API
+ dry = day_revenue.get("dry", {})
+ total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
+ total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
+
+ # Divide by 3 since we're iterating 3 periods but revenue data is per-day total
+ # Actually, the revenue API returns per-day totals, not per-period.
+ # We accumulated 3x per day. Let's recalculate from revenue_data directly.
+ total_forecast_rev = Decimal("0")
+ total_otb_rev = Decimal("0")
+ for d in revenue_data:
+ dry = d.get("dry", {})
+ total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
+ total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
+
+ # Upsert week snapshot
+ gp_target = float(settings.budget_gp_target) if settings.budget_gp_target else 65.0
+
+ await db.execute(text("""
+ DELETE FROM forecast_week_snapshots
+ WHERE kitchen_id = :kid AND week_start = :ws
+ """), {"kid": kitchen_id, "ws": week_start})
+
+ await db.execute(text("""
+ INSERT INTO forecast_week_snapshots
+ (kitchen_id, week_start, total_forecast_revenue, total_otb_revenue, gp_target)
+ VALUES (:kid, :ws, :tfr, :tor, :gp)
+ """), {
+ "kid": kitchen_id, "ws": week_start,
+ "tfr": float(round(total_forecast_rev, 2)),
+ "tor": float(round(total_otb_rev, 2)),
+ "gp": gp_target,
+ })
+
+ await db.commit()
+
+ return {
+ "success": True,
+ "message": "Forecast snapshot created",
+ "week_start": week_start.isoformat(),
+ "total_forecast_revenue": float(round(total_forecast_rev, 2)),
+ "total_otb_revenue": float(round(total_otb_rev, 2)),
+ "days_snapshotted": len(week_dates),
+ "periods_per_day": 3,
+ }
+
+
+@router.put("")
+async def upsert_cover_override(
+ req: CoverOverrideRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Set or update a cover override for a specific date+period."""
+ kitchen_id = current_user.kitchen_id
+
+ if req.period not in ("lunch", "dinner"):
+ raise HTTPException(status_code=400, detail="Period must be 'lunch' or 'dinner'")
+
+ if req.override_covers < 0:
+ raise HTTPException(status_code=400, detail="Override covers cannot be negative")
+
+ override_date = date.fromisoformat(req.override_date)
+
+ # Check existing
+ existing = await db.execute(text("""
+ SELECT id FROM cover_overrides
+ WHERE kitchen_id = :kid AND override_date = :od AND period = :p
+ """), {"kid": kitchen_id, "od": override_date, "p": req.period})
+ row = existing.fetchone()
+
+ if row:
+ await db.execute(text("""
+ UPDATE cover_overrides
+ SET override_covers = :oc, updated_by = :uid, updated_at = NOW()
+ WHERE id = :id
+ """), {"oc": req.override_covers, "uid": current_user.id, "id": row.id})
+ else:
+ # Get current forecast for snapshot
+ settings = await get_settings(db, kitchen_id)
+ original_forecast = None
+ original_otb = None
+
+ if settings.forecast_api_url and settings.forecast_api_key:
+ try:
+ async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
+ covers = await client.get_covers_forecast(override_date, days=1)
+ if covers:
+ p = covers[0].get(req.period, {})
+ original_forecast = p.get("forecast", 0) or 0
+ original_otb = p.get("otb", 0) or 0
+ except Exception as e:
+ logger.warning(f"Failed to get forecast for snapshot: {e}")
+
+ await db.execute(text("""
+ INSERT INTO cover_overrides
+ (kitchen_id, override_date, period, override_covers,
+ original_forecast, original_otb, created_by, updated_by)
+ VALUES (:kid, :od, :p, :oc, :of, :oo, :uid, :uid)
+ """), {
+ "kid": kitchen_id, "od": override_date, "p": req.period,
+ "oc": req.override_covers, "of": original_forecast, "oo": original_otb,
+ "uid": current_user.id,
+ })
+
+ await db.commit()
+ return {"success": True, "date": req.override_date, "period": req.period, "covers": req.override_covers}
+
+
+@router.delete("/{override_id}")
+async def delete_cover_override(
+ override_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Remove a cover override (revert to forecast)."""
+ result = await db.execute(text("""
+ DELETE FROM cover_overrides
+ WHERE id = :id AND kitchen_id = :kid
+ RETURNING id
+ """), {"id": override_id, "kid": current_user.kitchen_id})
+ deleted = result.fetchone()
+
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Override not found")
+
+ await db.commit()
+ return {"success": True, "deleted_id": override_id}
+
+
+@router.put("/spend-rates")
+async def upsert_spend_rate_override(
+ req: SpendRateOverrideRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Set or update a spend rate override for a week+period."""
+ kitchen_id = current_user.kitchen_id
+ week_start, _, _ = get_week_dates(req.week_offset)
+
+ if req.period not in ("breakfast", "lunch", "dinner"):
+ raise HTTPException(status_code=400, detail="Period must be 'breakfast', 'lunch', or 'dinner'")
+
+ # Check existing
+ existing = await db.execute(text("""
+ SELECT id FROM spend_rate_overrides
+ WHERE kitchen_id = :kid AND week_start = :ws AND period = :p
+ """), {"kid": kitchen_id, "ws": week_start, "p": req.period})
+ row = existing.fetchone()
+
+ if row:
+ await db.execute(text("""
+ UPDATE spend_rate_overrides
+ SET food_spend = :fs, drinks_spend = :ds, updated_by = :uid, updated_at = NOW()
+ WHERE id = :id
+ """), {"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id, "id": row.id})
+ else:
+ await db.execute(text("""
+ INSERT INTO spend_rate_overrides
+ (kitchen_id, week_start, period, food_spend, drinks_spend, created_by, updated_by)
+ VALUES (:kid, :ws, :p, :fs, :ds, :uid, :uid)
+ """), {
+ "kid": kitchen_id, "ws": week_start, "p": req.period,
+ "fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id,
+ })
+
+ await db.commit()
+ return {"success": True, "week_start": week_start.isoformat(), "period": req.period}
+
+
+@router.delete("/spend-rates/{override_id}")
+async def delete_spend_rate_override(
+ override_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Remove a spend rate override (revert to snapshot/API value)."""
+ result = await db.execute(text("""
+ DELETE FROM spend_rate_overrides
+ WHERE id = :id AND kitchen_id = :kid
+ RETURNING id
+ """), {"id": override_id, "kid": current_user.kitchen_id})
+ deleted = result.fetchone()
+
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Spend rate override not found")
+
+ await db.commit()
+ return {"success": True, "deleted_id": override_id}
diff --git a/backend/api/credit_notes.py b/backend/api/credit_notes.py
new file mode 100644
index 0000000..0cadc2a
--- /dev/null
+++ b/backend/api/credit_notes.py
@@ -0,0 +1,327 @@
+"""
+API endpoints for credit note management.
+
+Handles:
+- Credit note upload
+- Credit note download
+- Credit note CRUD operations
+"""
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from fastapi.responses import Response
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_
+from sqlalchemy.orm import selectinload
+from datetime import date as date_type
+from typing import Optional
+from pydantic import BaseModel
+from decimal import Decimal
+
+from auth import get_current_user, require_cap
+from database import get_db
+from models.user import User
+from models.dispute import CreditNote, InvoiceDispute, DisputeStatus, DisputeActivity
+from models.invoice import Invoice
+from models.supplier import Supplier
+from services.dispute_archival_service import DisputeArchivalService
+
+router = APIRouter()
+
+
+# Pydantic Schemas
+
+class CreditNoteResponse(BaseModel):
+ id: int
+ invoice_id: int
+ supplier_id: int
+ supplier_name: str
+ credit_note_number: str
+ credit_date: str
+ credit_amount: float
+ reason: Optional[str]
+ notes: Optional[str]
+ file_storage_location: str
+ created_at: str
+ created_by: str
+
+ class Config:
+ from_attributes = True
+
+
+# Endpoints
+
+@router.post("/upload")
+async def upload_credit_note(
+ file: UploadFile = File(...),
+ invoice_id: int = Form(...),
+ credit_note_number: str = Form(...),
+ credit_date: str = Form(...),
+ credit_amount: float = Form(...),
+ dispute_id: Optional[int] = Form(None),
+ reason: Optional[str] = Form(None),
+ notes: Optional[str] = Form(None),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Upload credit note PDF"""
+
+ # Verify invoice exists and belongs to kitchen
+ result = await db.execute(
+ select(Invoice).where(
+ and_(
+ 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")
+
+ # Parse credit date
+ try:
+ credit_date_obj = date_type.fromisoformat(credit_date)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
+
+ # Read file content
+ file_content = await file.read()
+
+ # Validate file type (should be PDF)
+ if file.content_type and "pdf" not in file.content_type.lower():
+ raise HTTPException(status_code=400, detail="Only PDF files are supported for credit notes")
+
+ # Save file
+ archival_service = DisputeArchivalService(db, current_user.kitchen_id)
+ success, file_path = await archival_service.save_credit_note(
+ invoice,
+ file_content,
+ file.filename or "credit_note.pdf"
+ )
+
+ if not success:
+ raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}")
+
+ # Create credit note record
+ credit_note = CreditNote(
+ kitchen_id=current_user.kitchen_id,
+ invoice_id=invoice_id,
+ supplier_id=invoice.supplier_id or 0, # Use 0 if no supplier (will need to handle)
+ credit_note_number=credit_note_number,
+ credit_date=credit_date_obj,
+ credit_amount=Decimal(str(credit_amount)),
+ reason=reason,
+ notes=notes,
+ file_path=file_path,
+ file_storage_location="local",
+ created_by=current_user.id
+ )
+
+ db.add(credit_note)
+ await db.flush() # Get credit_note.id
+
+ # If linked to dispute, update dispute
+ if dispute_id:
+ result = await db.execute(
+ select(InvoiceDispute).where(
+ and_(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ dispute = result.scalar_one_or_none()
+
+ if dispute:
+ # Link credit note to dispute
+ dispute.credit_note_id = credit_note.id
+ dispute.resolved_amount = credit_note.credit_amount
+
+ # Update dispute status if still open/in_progress
+ if dispute.status in [DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS]:
+ dispute.status = DisputeStatus.AWAITING_CREDIT
+
+ # Log activity
+ activity = DisputeActivity(
+ dispute_id=dispute_id,
+ activity_type="credit_note_added",
+ description=f"Credit note {credit_note_number} (£{credit_amount:.2f}) linked to dispute",
+ created_by=current_user.id
+ )
+ db.add(activity)
+
+ await db.commit()
+ await db.refresh(credit_note)
+
+ # Archive to Nextcloud if enabled
+ success, result = await archival_service.archive_credit_note(credit_note)
+ if success:
+ await db.commit() # Update archived status
+
+ return {
+ "id": credit_note.id,
+ "credit_note_number": credit_note_number,
+ "credit_amount": credit_amount,
+ "archived": success
+ }
+
+
+@router.get("/{credit_note_id}")
+async def get_credit_note(
+ credit_note_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> CreditNoteResponse:
+ """Get credit note details"""
+
+ result = await db.execute(
+ select(CreditNote).options(
+ selectinload(CreditNote.supplier),
+ selectinload(CreditNote.created_by_user)
+ ).where(
+ and_(
+ CreditNote.id == credit_note_id,
+ CreditNote.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ credit_note = result.scalar_one_or_none()
+
+ if not credit_note:
+ raise HTTPException(status_code=404, detail="Credit note not found")
+
+ return CreditNoteResponse(
+ id=credit_note.id,
+ invoice_id=credit_note.invoice_id,
+ supplier_id=credit_note.supplier_id,
+ supplier_name=credit_note.supplier.name if credit_note.supplier else "Unknown",
+ credit_note_number=credit_note.credit_note_number,
+ credit_date=credit_note.credit_date.isoformat(),
+ credit_amount=float(credit_note.credit_amount),
+ reason=credit_note.reason,
+ notes=credit_note.notes,
+ file_storage_location=credit_note.file_storage_location,
+ created_at=credit_note.created_at.isoformat(),
+ created_by=credit_note.created_by_user.name if credit_note.created_by_user else "Unknown"
+ )
+
+
+@router.get("/{credit_note_id}/download")
+async def download_credit_note(
+ credit_note_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Download credit note PDF"""
+
+ result = await db.execute(
+ select(CreditNote).where(
+ and_(
+ CreditNote.id == credit_note_id,
+ CreditNote.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ credit_note = result.scalar_one_or_none()
+
+ if not credit_note:
+ raise HTTPException(status_code=404, detail="Credit note not found")
+
+ # Get file content
+ archival_service = DisputeArchivalService(db, current_user.kitchen_id)
+ success, content = await archival_service.get_credit_note_content(credit_note)
+
+ if not success:
+ raise HTTPException(status_code=404, detail="File not found")
+
+ return Response(
+ content=content,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'inline; filename="{credit_note.credit_note_number}.pdf"'}
+ )
+
+
+@router.patch("/{credit_note_id}")
+async def update_credit_note(
+ credit_note_id: int,
+ credit_note_number: Optional[str] = None,
+ credit_date: Optional[str] = None,
+ credit_amount: Optional[float] = None,
+ reason: Optional[str] = None,
+ notes: Optional[str] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update credit note details"""
+
+ result = await db.execute(
+ select(CreditNote).where(
+ and_(
+ CreditNote.id == credit_note_id,
+ CreditNote.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ credit_note = result.scalar_one_or_none()
+
+ if not credit_note:
+ raise HTTPException(status_code=404, detail="Credit note not found")
+
+ if credit_note_number:
+ credit_note.credit_note_number = credit_note_number
+
+ if credit_date:
+ try:
+ credit_note.credit_date = date_type.fromisoformat(credit_date)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
+
+ if credit_amount is not None:
+ credit_note.credit_amount = Decimal(str(credit_amount))
+
+ if reason is not None:
+ credit_note.reason = reason
+
+ if notes is not None:
+ credit_note.notes = notes
+
+ await db.commit()
+
+ return {"status": "updated"}
+
+
+@router.delete("/{credit_note_id}")
+async def delete_credit_note(
+ credit_note_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Delete credit note"""
+
+ result = await db.execute(
+ select(CreditNote).where(
+ and_(
+ CreditNote.id == credit_note_id,
+ CreditNote.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ credit_note = result.scalar_one_or_none()
+
+ if not credit_note:
+ raise HTTPException(status_code=404, detail="Credit note not found")
+
+ # Check if linked to dispute
+ result = await db.execute(
+ select(InvoiceDispute).where(InvoiceDispute.credit_note_id == credit_note_id)
+ )
+ dispute = result.scalar_one_or_none()
+
+ if dispute:
+ # Unlink from dispute
+ dispute.credit_note_id = None
+ dispute.resolved_amount = None
+
+ await db.delete(credit_note)
+ await db.commit()
+
+ return {"status": "deleted"}
diff --git a/backend/api/disputes.py b/backend/api/disputes.py
new file mode 100644
index 0000000..012f0a1
--- /dev/null
+++ b/backend/api/disputes.py
@@ -0,0 +1,1125 @@
+"""
+API endpoints for invoice dispute tracking.
+
+Handles:
+- Dispute CRUD operations
+- Dispute attachments upload/download
+- Dispute activity logging
+- Dashboard statistics
+"""
+import secrets
+import hashlib
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_, func
+from sqlalchemy.orm import selectinload
+from datetime import datetime, date, timedelta
+from typing import Optional, List
+from pydantic import BaseModel
+from decimal import Decimal
+
+from auth import get_current_user, require_cap
+from database import get_db
+from models.user import User
+from models.dispute import (
+ InvoiceDispute, DisputeLineItem, DisputeAttachment, DisputeActivity,
+ DisputeType, DisputeStatus, DisputePriority
+)
+from models.invoice import Invoice
+from models.supplier import Supplier
+from services.dispute_archival_service import DisputeArchivalService
+
+
+def generate_public_hash() -> str:
+ """Generate a secure random hash for public attachment links"""
+ return secrets.token_urlsafe(32) # 43 character URL-safe string
+
+router = APIRouter()
+
+
+# Pydantic Schemas
+
+class DisputeLineItemInput(BaseModel):
+ invoice_line_item_id: Optional[int] = None
+ product_name: str
+ product_code: Optional[str] = None
+ quantity_ordered: Optional[float] = None
+ quantity_received: Optional[float] = None
+ unit_price_quoted: Optional[float] = None
+ unit_price_charged: Optional[float] = None
+ total_charged: float
+ total_expected: Optional[float] = None
+ notes: Optional[str] = None
+
+
+class CreateDisputeInput(BaseModel):
+ invoice_id: int
+ dispute_type: DisputeType
+ priority: DisputePriority = DisputePriority.MEDIUM
+ title: str
+ description: Optional[str] = ""
+ disputed_amount: float
+ expected_amount: Optional[float] = None
+ line_items: List[DisputeLineItemInput] = []
+ tags: Optional[List[str]] = None
+
+
+class UpdateDisputeInput(BaseModel):
+ status: Optional[DisputeStatus] = None
+ priority: Optional[DisputePriority] = None
+ title: Optional[str] = None
+ description: Optional[str] = None
+ resolution_notes: Optional[str] = None
+ supplier_response: Optional[str] = None
+ supplier_contact_name: Optional[str] = None
+ resolved_amount: Optional[float] = None
+
+
+class DisputeLineItemResponse(BaseModel):
+ id: int
+ product_name: str
+ product_code: Optional[str]
+ quantity_ordered: Optional[float]
+ quantity_received: Optional[float]
+ quantity_difference: Optional[float]
+ unit_price_quoted: Optional[float]
+ unit_price_charged: Optional[float]
+ price_difference: Optional[float]
+ total_charged: float
+ total_expected: Optional[float]
+ notes: Optional[str]
+
+ class Config:
+ from_attributes = True
+
+
+class DisputeAttachmentResponse(BaseModel):
+ id: int
+ file_name: str
+ file_type: str
+ file_size_bytes: int
+ attachment_type: str
+ description: Optional[str]
+ uploaded_at: str
+ uploaded_by_username: str
+ public_hash: Optional[str] = None
+ public_url: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class DisputeActivityResponse(BaseModel):
+ id: int
+ activity_type: str
+ description: str
+ old_value: Optional[str] = None
+ new_value: Optional[str] = None
+ created_at: str
+ created_by_username: str
+
+ class Config:
+ from_attributes = True
+
+
+class DisputeResponse(BaseModel):
+ id: int
+ invoice_id: int
+ invoice_number: Optional[str]
+ supplier_name: str
+ dispute_type: str
+ status: str
+ priority: str
+ title: str
+ description: str
+ disputed_amount: float
+ expected_amount: Optional[float]
+ difference_amount: float
+ supplier_contacted_at: Optional[str]
+ supplier_response: Optional[str]
+ supplier_contact_name: Optional[str]
+ resolved_amount: Optional[float]
+ opened_at: str
+ opened_by: str
+ updated_at: str
+ resolved_at: Optional[str]
+ closed_at: Optional[str]
+ tags: Optional[List[str]]
+ line_items: List[DisputeLineItemResponse]
+ attachments: List[DisputeAttachmentResponse]
+ activity_log: List[DisputeActivityResponse]
+
+ class Config:
+ from_attributes = True
+
+
+# Endpoints
+
+@router.get("")
+async def get_disputes(
+ status: Optional[str] = None,
+ priority: Optional[str] = None,
+ invoice_id: Optional[int] = None,
+ supplier_id: Optional[int] = None,
+ opened_date: Optional[date] = None,
+ limit: int = 50,
+ offset: int = 0,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get disputes with filters"""
+
+ # Build base query
+ query = select(InvoiceDispute).options(
+ selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier),
+ selectinload(InvoiceDispute.opened_by_user),
+ selectinload(InvoiceDispute.resolved_by_user),
+ selectinload(InvoiceDispute.closed_by_user),
+ selectinload(InvoiceDispute.line_items),
+ selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user),
+ selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user)
+ ).where(InvoiceDispute.kitchen_id == current_user.kitchen_id)
+
+ # Join with Invoice for supplier filtering
+ if supplier_id:
+ query = query.join(Invoice, InvoiceDispute.invoice_id == Invoice.id).where(Invoice.supplier_id == supplier_id)
+
+ if status:
+ try:
+ status_enum = DisputeStatus(status)
+ query = query.where(InvoiceDispute.status == status_enum)
+ except ValueError:
+ pass
+
+ if priority:
+ try:
+ priority_enum = DisputePriority(priority)
+ query = query.where(InvoiceDispute.priority == priority_enum)
+ except ValueError:
+ pass
+
+ if invoice_id:
+ query = query.where(InvoiceDispute.invoice_id == invoice_id)
+
+ if opened_date:
+ day_start = datetime.combine(opened_date, datetime.min.time())
+ day_end = datetime.combine(opened_date + timedelta(days=1), datetime.min.time())
+ query = query.where(InvoiceDispute.opened_at >= day_start, InvoiceDispute.opened_at < day_end)
+
+ # Count total (before pagination)
+ count_query = select(func.count()).select_from(InvoiceDispute).where(InvoiceDispute.kitchen_id == current_user.kitchen_id)
+ if supplier_id:
+ count_query = count_query.join(Invoice, InvoiceDispute.invoice_id == Invoice.id).where(Invoice.supplier_id == supplier_id)
+ if status:
+ try:
+ status_enum = DisputeStatus(status)
+ count_query = count_query.where(InvoiceDispute.status == status_enum)
+ except ValueError:
+ pass
+ if priority:
+ try:
+ priority_enum = DisputePriority(priority)
+ count_query = count_query.where(InvoiceDispute.priority == priority_enum)
+ except ValueError:
+ pass
+ if invoice_id:
+ count_query = count_query.where(InvoiceDispute.invoice_id == invoice_id)
+ if opened_date:
+ day_start = datetime.combine(opened_date, datetime.min.time())
+ day_end = datetime.combine(opened_date + timedelta(days=1), datetime.min.time())
+ count_query = count_query.where(InvoiceDispute.opened_at >= day_start, InvoiceDispute.opened_at < day_end)
+
+ total_result = await db.execute(count_query)
+ total = total_result.scalar()
+
+ # Apply pagination
+ query = query.order_by(InvoiceDispute.opened_at.desc())
+ query = query.limit(limit).offset(offset)
+
+ result = await db.execute(query)
+ disputes = result.scalars().all()
+
+ return {
+ "disputes": [_format_dispute(dispute) for dispute in disputes],
+ "total": total
+ }
+
+
+@router.get("/{dispute_id}")
+async def get_dispute(
+ dispute_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> DisputeResponse:
+ """Get single dispute by ID"""
+
+ result = await db.execute(
+ select(InvoiceDispute).options(
+ selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier),
+ selectinload(InvoiceDispute.opened_by_user),
+ selectinload(InvoiceDispute.resolved_by_user),
+ selectinload(InvoiceDispute.closed_by_user),
+ selectinload(InvoiceDispute.line_items),
+ selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user),
+ selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user)
+ ).where(
+ and_(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ dispute = result.scalar_one_or_none()
+
+ if not dispute:
+ raise HTTPException(status_code=404, detail="Dispute not found")
+
+ return _format_dispute(dispute)
+
+
+@router.post("")
+async def create_dispute(
+ dispute_input: CreateDisputeInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> DisputeResponse:
+ """Create new invoice dispute"""
+
+ # Verify invoice belongs to kitchen
+ result = await db.execute(
+ select(Invoice).where(
+ and_(
+ Invoice.id == dispute_input.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")
+
+ # Calculate difference
+ expected = Decimal(str(dispute_input.expected_amount)) if dispute_input.expected_amount else Decimal(0)
+ disputed = Decimal(str(dispute_input.disputed_amount))
+ difference = disputed - expected
+
+ # Create dispute
+ dispute = InvoiceDispute(
+ kitchen_id=current_user.kitchen_id,
+ invoice_id=dispute_input.invoice_id,
+ dispute_type=dispute_input.dispute_type,
+ priority=dispute_input.priority,
+ status=DisputeStatus.NEW,
+ title=dispute_input.title,
+ description=dispute_input.description,
+ disputed_amount=disputed,
+ expected_amount=expected if dispute_input.expected_amount else None,
+ difference_amount=difference,
+ opened_by=current_user.id,
+ tags=dispute_input.tags
+ )
+
+ db.add(dispute)
+ await db.flush() # Get dispute.id
+
+ # Add line items
+ for item_input in dispute_input.line_items:
+ qty_diff = None
+ if item_input.quantity_ordered is not None and item_input.quantity_received is not None:
+ qty_diff = Decimal(str(item_input.quantity_ordered)) - Decimal(str(item_input.quantity_received))
+
+ price_diff = None
+ if item_input.unit_price_quoted is not None and item_input.unit_price_charged is not None:
+ price_diff = Decimal(str(item_input.unit_price_charged)) - Decimal(str(item_input.unit_price_quoted))
+
+ line_item = DisputeLineItem(
+ dispute_id=dispute.id,
+ invoice_line_item_id=item_input.invoice_line_item_id,
+ product_name=item_input.product_name,
+ product_code=item_input.product_code,
+ quantity_ordered=Decimal(str(item_input.quantity_ordered)) if item_input.quantity_ordered else None,
+ quantity_received=Decimal(str(item_input.quantity_received)) if item_input.quantity_received else None,
+ quantity_difference=qty_diff,
+ unit_price_quoted=Decimal(str(item_input.unit_price_quoted)) if item_input.unit_price_quoted else None,
+ unit_price_charged=Decimal(str(item_input.unit_price_charged)) if item_input.unit_price_charged else None,
+ price_difference=price_diff,
+ total_charged=Decimal(str(item_input.total_charged)),
+ total_expected=Decimal(str(item_input.total_expected)) if item_input.total_expected else None,
+ notes=item_input.notes
+ )
+ db.add(line_item)
+
+ # Log activity
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="created",
+ description=f"Dispute created: {dispute.title}",
+ created_by=current_user.id
+ )
+ db.add(activity)
+
+ await db.commit()
+ await db.refresh(dispute)
+
+ # Re-fetch with all relationships
+ result = await db.execute(
+ select(InvoiceDispute).options(
+ selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier),
+ selectinload(InvoiceDispute.opened_by_user),
+ selectinload(InvoiceDispute.line_items),
+ selectinload(InvoiceDispute.attachments).selectinload(DisputeAttachment.uploaded_by_user),
+ selectinload(InvoiceDispute.activity_log).selectinload(DisputeActivity.created_by_user)
+ ).where(InvoiceDispute.id == dispute.id)
+ )
+ dispute = result.scalar_one()
+
+ return _format_dispute(dispute)
+
+
+@router.patch("/{dispute_id}")
+async def update_dispute(
+ dispute_id: int,
+ update_input: UpdateDisputeInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update dispute status or details"""
+
+ result = await db.execute(
+ select(InvoiceDispute).where(
+ and_(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ dispute = result.scalar_one_or_none()
+
+ if not dispute:
+ raise HTTPException(status_code=404, detail="Dispute not found")
+
+ # Track changes for activity log
+ changes = []
+
+ if update_input.status and update_input.status != dispute.status:
+ old_status = dispute.status.value.upper()
+ new_status = update_input.status.value.upper()
+ dispute.status = update_input.status
+
+ # Log status change with specific activity_type
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="status_change",
+ description=f"Status changed from {old_status} to {new_status}",
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append(f"Status changed from {old_status} to {new_status}")
+
+ # Auto-set resolved_at when status changes to resolved
+ if update_input.status == DisputeStatus.RESOLVED and not dispute.resolved_at:
+ dispute.resolved_at = datetime.utcnow()
+ dispute.resolved_by = current_user.id
+
+ # Auto-set closed_at when status changes to closed
+ if update_input.status == DisputeStatus.CLOSED and not dispute.closed_at:
+ dispute.closed_at = datetime.utcnow()
+ dispute.closed_by = current_user.id
+
+ if update_input.priority and update_input.priority != dispute.priority:
+ old_priority = dispute.priority.value.upper()
+ new_priority = update_input.priority.value.upper()
+ dispute.priority = update_input.priority
+
+ # Log priority change with specific activity_type
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="priority_change",
+ description=f"Priority changed from {old_priority} to {new_priority}",
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append(f"Priority changed from {old_priority} to {new_priority}")
+
+ if update_input.title and update_input.title != dispute.title:
+ old_title = dispute.title
+ dispute.title = update_input.title
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="updated",
+ description=f"Title updated from '{old_title}' to '{update_input.title}'",
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append("Title updated")
+
+ if update_input.description is not None and update_input.description != dispute.description:
+ dispute.description = update_input.description
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="updated",
+ description="Description updated",
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append("Description updated")
+
+ if update_input.resolution_notes:
+ dispute.resolution_notes = update_input.resolution_notes
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="note",
+ description=update_input.resolution_notes,
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append("Resolution notes updated")
+
+ if update_input.supplier_response:
+ dispute.supplier_response = update_input.supplier_response
+ if not dispute.supplier_contacted_at:
+ dispute.supplier_contacted_at = datetime.utcnow()
+
+ # Log note with specific activity_type
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="note",
+ description=update_input.supplier_response,
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append(update_input.supplier_response)
+
+ if update_input.supplier_contact_name:
+ dispute.supplier_contact_name = update_input.supplier_contact_name
+
+ if update_input.resolved_amount is not None:
+ dispute.resolved_amount = Decimal(str(update_input.resolved_amount))
+ activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="updated",
+ description=f"Resolved amount set to £{update_input.resolved_amount:.2f}",
+ created_by=current_user.id
+ )
+ db.add(activity)
+ changes.append(f"Resolved amount set to £{update_input.resolved_amount:.2f}")
+
+ dispute.updated_at = datetime.utcnow()
+
+ await db.commit()
+
+ return {"status": "updated", "changes": changes}
+
+
+@router.delete("/{dispute_id}")
+async def delete_dispute(
+ dispute_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Delete a dispute (admin only, use carefully)"""
+
+ result = await db.execute(
+ select(InvoiceDispute).where(
+ and_(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ dispute = result.scalar_one_or_none()
+
+ if not dispute:
+ raise HTTPException(status_code=404, detail="Dispute not found")
+
+ # Cascade delete handles attachments, line items, activity
+ await db.delete(dispute)
+ await db.commit()
+
+ return {"status": "deleted"}
+
+
+@router.post("/{dispute_id}/attachments")
+async def upload_dispute_attachment(
+ dispute_id: int,
+ file: UploadFile = File(...),
+ attachment_type: str = "other",
+ description: Optional[str] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Upload supporting document (photo, email, delivery note)"""
+
+ # Verify dispute exists
+ result = await db.execute(
+ select(InvoiceDispute).where(
+ and_(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ dispute = result.scalar_one_or_none()
+ if not dispute:
+ raise HTTPException(status_code=404, detail="Dispute not found")
+
+ # Read file content
+ file_content = await file.read()
+ file_size = len(file_content)
+
+ # Save file
+ archival_service = DisputeArchivalService(db, current_user.kitchen_id)
+ success, file_path = await archival_service.save_dispute_attachment(
+ dispute,
+ file_content,
+ file.filename or "attachment",
+ file.content_type or "application/octet-stream"
+ )
+
+ if not success:
+ raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}")
+
+ # Generate public hash for shareable link
+ public_hash = generate_public_hash()
+
+ # Create attachment record
+ attachment = DisputeAttachment(
+ dispute_id=dispute_id,
+ kitchen_id=current_user.kitchen_id,
+ file_name=file.filename or "attachment",
+ file_path=file_path,
+ file_type=file.content_type or "application/octet-stream",
+ file_size_bytes=file_size,
+ attachment_type=attachment_type,
+ description=description,
+ uploaded_by=current_user.id,
+ public_hash=public_hash
+ )
+
+ db.add(attachment)
+
+ # Log activity
+ activity = DisputeActivity(
+ dispute_id=dispute_id,
+ activity_type="attachment_added",
+ description=f"Attachment added: {file.filename} ({attachment_type})",
+ created_by=current_user.id
+ )
+ db.add(activity)
+
+ await db.commit()
+ await db.refresh(attachment)
+
+ # Archive to Nextcloud if enabled
+ success, result = await archival_service.archive_dispute_attachment(attachment)
+ if success:
+ await db.commit() # Update archived status
+
+ return {
+ "id": attachment.id,
+ "file_name": file.filename,
+ "file_size": file_size,
+ "archived": success,
+ "public_hash": public_hash,
+ "public_url": f"/api/public/attachments/{public_hash}"
+ }
+
+
+@router.get("/{dispute_id}/attachments/{attachment_id}")
+async def download_dispute_attachment(
+ dispute_id: int,
+ attachment_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Download dispute attachment"""
+ from fastapi.responses import Response
+
+ result = await db.execute(
+ select(DisputeAttachment).where(
+ and_(
+ DisputeAttachment.id == attachment_id,
+ DisputeAttachment.dispute_id == dispute_id,
+ DisputeAttachment.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ attachment = result.scalar_one_or_none()
+ if not attachment:
+ raise HTTPException(status_code=404, detail="Attachment not found")
+
+ # Get file content
+ archival_service = DisputeArchivalService(db, current_user.kitchen_id)
+ success, content = await archival_service.get_attachment_content(attachment)
+
+ if not success:
+ raise HTTPException(status_code=404, detail="File not found")
+
+ return Response(
+ content=content,
+ media_type=attachment.file_type,
+ headers={"Content-Disposition": f'inline; filename="{attachment.file_name}"'}
+ )
+
+
+@router.get("/stats/summary")
+async def get_dispute_stats(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get dispute statistics for dashboard widget"""
+
+ # Count by status
+ result = await db.execute(
+ select(
+ InvoiceDispute.status,
+ func.count(InvoiceDispute.id).label("count"),
+ func.sum(InvoiceDispute.difference_amount).label("total_amount")
+ ).where(
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ ).group_by(InvoiceDispute.status)
+ )
+ status_counts = {
+ row.status.value: {
+ "count": row.count,
+ "amount": float(row.total_amount or 0)
+ }
+ for row in result
+ }
+
+ # Recent disputes
+ result = await db.execute(
+ select(InvoiceDispute).options(
+ selectinload(InvoiceDispute.invoice).selectinload(Invoice.supplier)
+ ).where(
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ ).order_by(InvoiceDispute.opened_at.desc()).limit(5)
+ )
+ recent_disputes = result.scalars().all()
+
+ # Count open disputes (all non-resolved statuses)
+ open_count = sum(
+ status_counts.get(status, {}).get("count", 0)
+ for status in ["NEW", "CONTACTED", "AWAITING_CREDIT", "AWAITING_REPLACEMENT"]
+ )
+
+ # Total disputed amount
+ total_disputed_amount = sum(s.get("amount", 0) for s in status_counts.values())
+
+ return {
+ "status_counts": status_counts,
+ "total_disputes": sum(s.get("count", 0) for s in status_counts.values()),
+ "open_disputes": open_count,
+ "total_disputed_amount": total_disputed_amount,
+ "recent_disputes": [
+ {
+ "id": d.id,
+ "invoice_id": d.invoice_id,
+ "title": d.title,
+ "status": d.status.value,
+ "disputed_amount": float(d.difference_amount),
+ "opened_at": d.opened_at.isoformat(),
+ "invoice_number": d.invoice.invoice_number if d.invoice else None,
+ "supplier_name": d.invoice.supplier.name if d.invoice and d.invoice.supplier else "Unknown"
+ }
+ for d in recent_disputes
+ ]
+ }
+
+
+@router.get("/stats/daily")
+async def get_daily_dispute_stats(
+ from_date: Optional[date] = None,
+ to_date: Optional[date] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get daily dispute statistics for a date range (for purchases chart integration).
+ Returns totals split by resolved/unresolved status for color-coding."""
+ from sqlalchemy import cast, Date, case
+
+ resolved_statuses = [DisputeStatus.RESOLVED, DisputeStatus.CLOSED]
+
+ query = select(
+ cast(InvoiceDispute.opened_at, Date).label("date"),
+ func.count(InvoiceDispute.id).label("count"),
+ func.sum(InvoiceDispute.disputed_amount).label("total_disputed"),
+ # Unresolved breakdown
+ func.sum(case(
+ (InvoiceDispute.status.notin_(resolved_statuses), InvoiceDispute.disputed_amount),
+ else_=0
+ )).label("unresolved_total"),
+ func.count(case(
+ (InvoiceDispute.status.notin_(resolved_statuses), InvoiceDispute.id),
+ )).label("unresolved_count"),
+ # Resolved breakdown
+ func.sum(case(
+ (InvoiceDispute.status.in_(resolved_statuses), InvoiceDispute.disputed_amount),
+ else_=0
+ )).label("resolved_total"),
+ func.count(case(
+ (InvoiceDispute.status.in_(resolved_statuses), InvoiceDispute.id),
+ )).label("resolved_count"),
+ ).where(
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+
+ if from_date:
+ query = query.where(cast(InvoiceDispute.opened_at, Date) >= from_date)
+ if to_date:
+ query = query.where(cast(InvoiceDispute.opened_at, Date) <= to_date)
+
+ query = query.group_by(cast(InvoiceDispute.opened_at, Date))
+ query = query.order_by(cast(InvoiceDispute.opened_at, Date))
+
+ result = await db.execute(query)
+ rows = result.all()
+
+ return {
+ "daily_stats": {
+ row.date.isoformat(): {
+ "count": row.count,
+ "total_disputed": float(row.total_disputed or 0),
+ "unresolved_count": row.unresolved_count,
+ "unresolved_total": float(row.unresolved_total or 0),
+ "resolved_count": row.resolved_count,
+ "resolved_total": float(row.resolved_total or 0),
+ }
+ for row in rows
+ }
+ }
+
+
+def _format_dispute(dispute: InvoiceDispute) -> DisputeResponse:
+ """Format dispute for API response"""
+
+ supplier_name = "Unknown"
+ if dispute.invoice and dispute.invoice.supplier:
+ supplier_name = dispute.invoice.supplier.name
+ elif dispute.invoice and dispute.invoice.vendor_name:
+ supplier_name = dispute.invoice.vendor_name
+
+ return DisputeResponse(
+ id=dispute.id,
+ invoice_id=dispute.invoice_id,
+ invoice_number=dispute.invoice.invoice_number if dispute.invoice else None,
+ supplier_name=supplier_name,
+ dispute_type=dispute.dispute_type.value,
+ status=dispute.status.value,
+ priority=dispute.priority.value,
+ title=dispute.title,
+ description=dispute.description,
+ disputed_amount=float(dispute.disputed_amount),
+ expected_amount=float(dispute.expected_amount) if dispute.expected_amount else None,
+ difference_amount=float(dispute.difference_amount),
+ supplier_contacted_at=dispute.supplier_contacted_at.isoformat() if dispute.supplier_contacted_at else None,
+ supplier_response=dispute.supplier_response,
+ supplier_contact_name=dispute.supplier_contact_name,
+ resolved_amount=float(dispute.resolved_amount) if dispute.resolved_amount else None,
+ opened_at=dispute.opened_at.isoformat(),
+ opened_by=dispute.opened_by_user.name if dispute.opened_by_user else "Unknown",
+ updated_at=dispute.updated_at.isoformat(),
+ resolved_at=dispute.resolved_at.isoformat() if dispute.resolved_at else None,
+ closed_at=dispute.closed_at.isoformat() if dispute.closed_at else None,
+ tags=dispute.tags,
+ line_items=[
+ DisputeLineItemResponse(
+ id=item.id,
+ product_name=item.product_name,
+ product_code=item.product_code,
+ quantity_ordered=float(item.quantity_ordered) if item.quantity_ordered else None,
+ quantity_received=float(item.quantity_received) if item.quantity_received else None,
+ quantity_difference=float(item.quantity_difference) if item.quantity_difference else None,
+ unit_price_quoted=float(item.unit_price_quoted) if item.unit_price_quoted else None,
+ unit_price_charged=float(item.unit_price_charged) if item.unit_price_charged else None,
+ price_difference=float(item.price_difference) if item.price_difference else None,
+ total_charged=float(item.total_charged),
+ total_expected=float(item.total_expected) if item.total_expected else None,
+ notes=item.notes
+ )
+ for item in dispute.line_items
+ ],
+ attachments=[
+ DisputeAttachmentResponse(
+ id=att.id,
+ file_name=att.file_name,
+ file_type=att.file_type,
+ file_size_bytes=att.file_size_bytes,
+ attachment_type=att.attachment_type,
+ description=att.description,
+ uploaded_at=att.uploaded_at.isoformat(),
+ uploaded_by_username=att.uploaded_by_user.name if att.uploaded_by_user else "Unknown",
+ public_hash=att.public_hash,
+ public_url=f"/api/public/attachments/{att.public_hash}" if att.public_hash else None
+ )
+ for att in dispute.attachments
+ ],
+ activity_log=[
+ DisputeActivityResponse(
+ id=act.id,
+ activity_type=act.activity_type,
+ description=act.description,
+ old_value=act.old_value,
+ new_value=act.new_value,
+ created_at=act.created_at.isoformat(),
+ created_by_username=act.created_by_user.name if act.created_by_user else "Unknown"
+ )
+ for act in sorted(dispute.activity_log, key=lambda x: x.created_at)
+ ]
+ )
+
+
+# ===== Credit Note Linking Endpoints =====
+
+class OpenDisputeResponse(BaseModel):
+ """Simplified dispute info for linking modal"""
+ id: int
+ title: str
+ dispute_type: str
+ status: str
+ disputed_amount: float
+ opened_at: str
+ invoice_number: Optional[str] = None
+
+
+class LinkCreditNoteInput(BaseModel):
+ """Input for linking a credit note to a dispute"""
+ credit_note_invoice_id: int # The invoice ID with document_type='credit_note'
+ resolved_amount: Optional[float] = None # Optional: override the credit note amount
+ resolution_notes: Optional[str] = None # Optional: additional notes
+
+
+@router.get("/supplier/{supplier_id}/open", response_model=List[OpenDisputeResponse])
+async def get_open_disputes_for_supplier(
+ supplier_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get all open/unresolved disputes for a specific supplier"""
+ # Open statuses (not resolved or closed)
+ open_statuses = [
+ DisputeStatus.NEW,
+ DisputeStatus.OPEN,
+ DisputeStatus.CONTACTED,
+ DisputeStatus.IN_PROGRESS,
+ DisputeStatus.AWAITING_CREDIT,
+ DisputeStatus.AWAITING_REPLACEMENT,
+ DisputeStatus.ESCALATED
+ ]
+
+ result = await db.execute(
+ select(InvoiceDispute, Invoice.invoice_number)
+ .join(Invoice, InvoiceDispute.invoice_id == Invoice.id)
+ .where(
+ and_(
+ InvoiceDispute.kitchen_id == current_user.kitchen_id,
+ Invoice.supplier_id == supplier_id,
+ InvoiceDispute.status.in_(open_statuses)
+ )
+ )
+ .order_by(InvoiceDispute.opened_at.desc())
+ )
+ rows = result.all()
+
+ return [
+ OpenDisputeResponse(
+ id=dispute.id,
+ title=dispute.title,
+ dispute_type=dispute.dispute_type.value,
+ status=dispute.status.value,
+ disputed_amount=float(dispute.disputed_amount),
+ opened_at=dispute.opened_at.isoformat(),
+ invoice_number=invoice_number
+ )
+ for dispute, invoice_number in rows
+ ]
+
+
+@router.post("/{dispute_id}/link-credit-note")
+async def link_credit_note_to_dispute(
+ dispute_id: int,
+ link_input: LinkCreditNoteInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Link a credit note invoice to a dispute and mark it as resolved"""
+ # Get the dispute
+ result = await db.execute(
+ select(InvoiceDispute).where(
+ and_(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ dispute = result.scalar_one_or_none()
+ if not dispute:
+ raise HTTPException(status_code=404, detail="Dispute not found")
+
+ # Get the credit note invoice
+ result = await db.execute(
+ select(Invoice).where(
+ and_(
+ Invoice.id == link_input.credit_note_invoice_id,
+ Invoice.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ credit_note = result.scalar_one_or_none()
+ if not credit_note:
+ raise HTTPException(status_code=404, detail="Credit note not found")
+
+ # Verify it's a credit note
+ if credit_note.document_type != 'credit_note':
+ raise HTTPException(status_code=400, detail="Invoice is not a credit note")
+
+ # Determine resolved amount
+ resolved_amount = link_input.resolved_amount
+ if resolved_amount is None and credit_note.total:
+ # Use the credit note total (as positive value)
+ resolved_amount = abs(float(credit_note.total))
+
+ # Update the dispute
+ old_status = dispute.status.value
+ dispute.status = DisputeStatus.RESOLVED
+ dispute.resolved_amount = Decimal(str(resolved_amount)) if resolved_amount else None
+ dispute.resolved_by = current_user.id
+ dispute.resolved_at = datetime.utcnow()
+
+ if link_input.resolution_notes:
+ dispute.resolution_notes = link_input.resolution_notes
+
+ # Build credit note reference
+ credit_note_ref = credit_note.invoice_number or f"ID#{credit_note.id}"
+ credit_note_date = credit_note.invoice_date.strftime('%d %b %Y') if credit_note.invoice_date else 'unknown date'
+
+ # Add activity for status change
+ status_activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="status_change",
+ description=f"Status changed from {old_status} to RESOLVED",
+ old_value=old_status,
+ new_value="RESOLVED",
+ created_by=current_user.id
+ )
+ db.add(status_activity)
+
+ # Add activity for credit note link
+ link_activity = DisputeActivity(
+ dispute_id=dispute.id,
+ activity_type="credit_note_linked",
+ description=f"Dispute resolved with credit note #{credit_note_ref} dated {credit_note_date}",
+ new_value=str(credit_note.id), # Store invoice ID for linking
+ created_by=current_user.id
+ )
+ db.add(link_activity)
+
+ # Update the credit note to track the linked dispute
+ credit_note.linked_dispute_id = dispute.id
+
+ await db.commit()
+
+ return {
+ "success": True,
+ "message": f"Dispute linked to credit note #{credit_note_ref}",
+ "dispute_id": dispute.id,
+ "credit_note_id": credit_note.id,
+ "credit_note_number": credit_note_ref,
+ "resolved_amount": resolved_amount
+ }
+
+
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+@router.post("/{dispute_id}/draft-email")
+async def draft_email(
+ dispute_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Draft a supplier dispute email using AI."""
+ from services.llm_service import draft_dispute_email
+ from models.settings import KitchenSettings
+
+ # Load dispute with line items
+ result = await db.execute(
+ select(InvoiceDispute)
+ .options(selectinload(InvoiceDispute.line_items))
+ .where(
+ InvoiceDispute.id == dispute_id,
+ InvoiceDispute.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ dispute = result.scalar_one_or_none()
+ if not dispute:
+ raise HTTPException(status_code=404, detail="Dispute not found")
+
+ # Load supplier name via invoice
+ from models.invoice import Invoice
+ from models.supplier import Supplier
+ inv_result = await db.execute(
+ select(Invoice).where(Invoice.id == dispute.invoice_id)
+ )
+ invoice = inv_result.scalar_one_or_none()
+ supplier_name = "Unknown Supplier"
+ if invoice and invoice.supplier_id:
+ sup_result = await db.execute(
+ select(Supplier.name).where(Supplier.id == invoice.supplier_id)
+ )
+ sup_row = sup_result.scalar_one_or_none()
+ if sup_row:
+ supplier_name = sup_row
+
+ # Load kitchen details
+ settings_result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = settings_result.scalar_one_or_none()
+
+ kitchen_details = {
+ "name": getattr(settings, "kitchen_display_name", "") or "",
+ "address": " ".join(filter(None, [
+ getattr(settings, "kitchen_address_line1", ""),
+ getattr(settings, "kitchen_address_line2", ""),
+ getattr(settings, "kitchen_city", ""),
+ getattr(settings, "kitchen_postcode", ""),
+ ])),
+ "email": getattr(settings, "kitchen_email", "") or "",
+ "phone": getattr(settings, "kitchen_phone", "") or "",
+ }
+
+ dispute_data = {
+ "supplier_name": supplier_name,
+ "invoice_number": invoice.invoice_number if invoice else None,
+ "invoice_date": str(invoice.invoice_date) if invoice and invoice.invoice_date else None,
+ "dispute_type": dispute.dispute_type.value if dispute.dispute_type else "price_discrepancy",
+ "title": dispute.title,
+ "description": dispute.description,
+ "disputed_amount": float(dispute.disputed_amount) if dispute.disputed_amount else 0,
+ "line_items": [
+ {
+ "product_name": li.product_name,
+ "product_code": li.product_code,
+ "quantity_ordered": float(li.quantity_ordered) if li.quantity_ordered else None,
+ "quantity_received": float(li.quantity_received) if li.quantity_received else None,
+ "unit_price_quoted": float(li.unit_price_quoted) if li.unit_price_quoted else None,
+ "unit_price_charged": float(li.unit_price_charged) if li.unit_price_charged else None,
+ "total_charged": float(li.total_charged) if li.total_charged else 0,
+ "total_expected": float(li.total_expected) if li.total_expected else None,
+ }
+ for li in (dispute.line_items or [])
+ ],
+ }
+
+ llm_result = await draft_dispute_email(
+ db=db,
+ kitchen_id=current_user.kitchen_id,
+ dispute_data=dispute_data,
+ kitchen_details=kitchen_details,
+ )
+
+ return {
+ "llm_status": llm_result["status"],
+ "email_subject": llm_result.get("email_subject"),
+ "email_body": llm_result.get("email_body"),
+ "error": llm_result.get("error"),
+ }
diff --git a/backend/api/event_orders.py b/backend/api/event_orders.py
new file mode 100644
index 0000000..fb9920f
--- /dev/null
+++ b/backend/api/event_orders.py
@@ -0,0 +1,694 @@
+"""
+Event Order API — create event orders, add recipes × quantities,
+generate aggregated shopping lists, and optionally create purchase orders.
+"""
+import logging
+from datetime import date
+from decimal import Decimal
+from typing import Optional
+from collections import defaultdict
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, delete
+from sqlalchemy.orm import selectinload
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.event_order import EventOrder, EventOrderItem
+from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe
+from models.ingredient import Ingredient, IngredientSource, IngredientCategory
+from models.supplier import Supplier
+from auth import get_current_user, require_cap
+from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ── Pydantic schemas ─────────────────────────────────────────────────────────
+
+class EventOrderCreate(BaseModel):
+ name: str
+ event_date: Optional[date] = None
+ notes: Optional[str] = None
+
+class EventOrderUpdate(BaseModel):
+ name: Optional[str] = None
+ event_date: Optional[date] = None
+ notes: Optional[str] = None
+ status: Optional[str] = None
+
+class EventOrderItemAdd(BaseModel):
+ recipe_id: int
+ quantity: int
+ notes: Optional[str] = None
+ sort_order: int = 0
+
+class EventOrderItemUpdate(BaseModel):
+ quantity: Optional[int] = None
+ notes: Optional[str] = None
+ sort_order: Optional[int] = None
+
+class BulkItemEntry(BaseModel):
+ recipe_id: int
+ quantity: int
+ notes: Optional[str] = None
+
+class EventOrderBulkAdd(BaseModel):
+ items: list[BulkItemEntry]
+
+class EventOrderResponse(BaseModel):
+ id: int
+ name: str
+ event_date: Optional[str] = None
+ notes: Optional[str] = None
+ status: str
+ item_count: int = 0
+ estimated_cost: Optional[float] = None
+ created_at: str = ""
+ updated_at: str = ""
+
+class EventOrderItemResponse(BaseModel):
+ id: int
+ recipe_id: int
+ recipe_name: str = ""
+ recipe_type: str = ""
+ batch_portions: int = 1
+ quantity: int
+ cost_per_portion: Optional[float] = None
+ subtotal: Optional[float] = None
+ notes: Optional[str] = None
+ sort_order: int = 0
+
+
+# ── Event Order CRUD ─────────────────────────────────────────────────────────
+
+@router.get("")
+async def list_event_orders(
+ status: Optional[str] = Query(None),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ query = (
+ select(EventOrder)
+ .options(selectinload(EventOrder.items))
+ .where(EventOrder.kitchen_id == user.kitchen_id)
+ )
+ if status:
+ query = query.where(EventOrder.status == status)
+
+ result = await db.execute(query.order_by(EventOrder.event_date.desc().nullslast(), EventOrder.created_at.desc()))
+ orders = result.scalars().all()
+
+ responses = []
+ for o in orders:
+ responses.append(EventOrderResponse(
+ id=o.id,
+ name=o.name,
+ event_date=str(o.event_date) if o.event_date else None,
+ notes=o.notes,
+ status=o.status,
+ item_count=len(o.items) if o.items else 0,
+ created_at=str(o.created_at) if o.created_at else "",
+ updated_at=str(o.updated_at) if o.updated_at else "",
+ ))
+
+ return responses
+
+
+@router.post("")
+async def create_event_order(
+ data: EventOrderCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ order = EventOrder(
+ kitchen_id=user.kitchen_id,
+ name=data.name,
+ event_date=data.event_date,
+ notes=data.notes,
+ created_by=user.id,
+ )
+ db.add(order)
+ await db.commit()
+ await db.refresh(order)
+ return {"id": order.id, "name": order.name}
+
+
+@router.get("/{order_id}")
+async def get_event_order(
+ order_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(EventOrder)
+ .options(selectinload(EventOrder.items).selectinload(EventOrderItem.recipe))
+ .where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ raise HTTPException(404, "Event order not found")
+
+ items = []
+ for item in sorted(order.items, key=lambda x: x.sort_order):
+ recipe = item.recipe
+ # Get cost per portion from latest snapshot
+ from models.recipe import RecipeCostSnapshot
+ snap_result = await db.execute(
+ select(RecipeCostSnapshot)
+ .where(RecipeCostSnapshot.recipe_id == item.recipe_id)
+ .order_by(RecipeCostSnapshot.snapshot_date.desc())
+ .limit(1)
+ )
+ snap = snap_result.scalar_one_or_none()
+ cpp = float(snap.cost_per_portion) if snap else None
+ subtotal = cpp * item.quantity if cpp else None
+
+ items.append(EventOrderItemResponse(
+ id=item.id,
+ recipe_id=item.recipe_id,
+ recipe_name=recipe.name if recipe else "",
+ recipe_type=recipe.recipe_type if recipe else "",
+ batch_portions=recipe.batch_portions if recipe else 1,
+ quantity=item.quantity,
+ cost_per_portion=cpp,
+ subtotal=round(subtotal, 2) if subtotal else None,
+ notes=item.notes,
+ sort_order=item.sort_order,
+ ))
+
+ return {
+ "id": order.id,
+ "name": order.name,
+ "event_date": str(order.event_date) if order.event_date else None,
+ "notes": order.notes,
+ "status": order.status,
+ "items": [i.model_dump() for i in items],
+ "created_at": str(order.created_at) if order.created_at else "",
+ "updated_at": str(order.updated_at) if order.updated_at else "",
+ }
+
+
+@router.patch("/{order_id}")
+async def update_event_order(
+ order_id: int,
+ data: EventOrderUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ raise HTTPException(404, "Event order not found")
+ if data.name is not None:
+ order.name = data.name
+ if data.event_date is not None:
+ order.event_date = data.event_date
+ if data.notes is not None:
+ order.notes = data.notes
+ if data.status is not None:
+ order.status = data.status
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/{order_id}")
+async def delete_event_order(
+ order_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ raise HTTPException(404, "Event order not found")
+ if order.status != "DRAFT":
+ raise HTTPException(400, "Only DRAFT orders can be deleted")
+ await db.delete(order)
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Event Order Items ────────────────────────────────────────────────────────
+
+@router.post("/{order_id}/items")
+async def add_item(
+ order_id: int,
+ data: EventOrderItemAdd,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ order = await _get_order(order_id, user.kitchen_id, db)
+ recipe = await db.execute(
+ select(Recipe).where(Recipe.id == data.recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not recipe.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ item = EventOrderItem(
+ event_order_id=order_id,
+ recipe_id=data.recipe_id,
+ quantity=data.quantity,
+ notes=data.notes,
+ sort_order=data.sort_order,
+ )
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+ return {"id": item.id}
+
+
+@router.post("/{order_id}/items/bulk")
+async def add_items_bulk(
+ order_id: int,
+ data: EventOrderBulkAdd,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Add multiple recipes to an event order at once (e.g. from a menu)."""
+ order = await _get_order(order_id, user.kitchen_id, db)
+
+ # Get existing recipe_ids on this order to detect duplicates
+ existing_result = await db.execute(
+ select(EventOrderItem.recipe_id).where(EventOrderItem.event_order_id == order_id)
+ )
+ existing_ids = {r[0] for r in existing_result.all()}
+
+ # Get max sort_order
+ max_sort_result = await db.execute(
+ select(func.coalesce(func.max(EventOrderItem.sort_order), -1))
+ .where(EventOrderItem.event_order_id == order_id)
+ )
+ next_sort = (max_sort_result.scalar() or 0) + 1
+
+ # Validate all recipe_ids belong to this kitchen
+ recipe_ids = [entry.recipe_id for entry in data.items]
+ valid_result = await db.execute(
+ select(Recipe.id).where(Recipe.id.in_(recipe_ids), Recipe.kitchen_id == user.kitchen_id)
+ )
+ valid_ids = {r[0] for r in valid_result.all()}
+
+ added = 0
+ skipped = 0
+ for entry in data.items:
+ if entry.recipe_id not in valid_ids:
+ skipped += 1
+ continue
+ if entry.recipe_id in existing_ids:
+ skipped += 1
+ continue
+ item = EventOrderItem(
+ event_order_id=order_id,
+ recipe_id=entry.recipe_id,
+ quantity=entry.quantity,
+ notes=entry.notes,
+ sort_order=next_sort,
+ )
+ db.add(item)
+ existing_ids.add(entry.recipe_id)
+ next_sort += 1
+ added += 1
+
+ await db.commit()
+ return {"added": added, "skipped": skipped}
+
+
+@router.patch("/items/{item_id}")
+async def update_item(
+ item_id: int,
+ data: EventOrderItemUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Item not found")
+ if data.quantity is not None:
+ item.quantity = data.quantity
+ if data.notes is not None:
+ item.notes = data.notes
+ if data.sort_order is not None:
+ item.sort_order = data.sort_order
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/items/{item_id}")
+async def delete_item(
+ item_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Item not found")
+ await db.delete(item)
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Shopping List ────────────────────────────────────────────────────────────
+
+async def _collect_ingredients_for_recipe(
+ recipe_id: int,
+ multiplier: float,
+ db: AsyncSession,
+ depth: int = 0,
+) -> dict[int, float]:
+ """Recursively collect ingredient quantities for a recipe × multiplier.
+ Returns {ingredient_id: total_quantity_in_standard_unit}."""
+ if depth > 5:
+ return {}
+
+ result: dict[int, float] = defaultdict(float)
+
+ # Direct ingredients (yield-adjusted: divide by yield to get required purchase qty)
+ ri_result = await db.execute(
+ select(RecipeIngredient)
+ .options(selectinload(RecipeIngredient.ingredient))
+ .where(RecipeIngredient.recipe_id == recipe_id)
+ )
+ _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
+ for ri in ri_result.scalars().all():
+ yld = float(ri.yield_percent) if ri.yield_percent else 100.0
+ raw_qty = float(ri.quantity) * multiplier
+ # Convert from display unit to standard unit if needed
+ if ri.unit and ri.ingredient and ri.unit != ri.ingredient.standard_unit:
+ from_base = _bases.get(ri.unit)
+ to_base = _bases.get(ri.ingredient.standard_unit)
+ if from_base and to_base:
+ raw_qty = raw_qty * from_base / to_base
+ adjusted_qty = raw_qty / (yld / 100) if yld > 0 else raw_qty
+ result[ri.ingredient_id] += adjusted_qty
+
+ # Sub-recipe ingredients
+ sr_result = await db.execute(
+ select(RecipeSubRecipe).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
+ )
+ for sr in sr_result.scalars().all():
+ child_result = await db.execute(
+ select(Recipe).where(Recipe.id == sr.child_recipe_id)
+ )
+ child_recipe = child_result.scalar_one_or_none()
+ if not child_recipe:
+ continue
+ # Use unified output qty (handles both portioned and bulk)
+ child_output_qty = float(child_recipe.batch_yield_qty) if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_qty else (child_recipe.batch_portions or 1)
+ child_output_unit = child_recipe.batch_yield_unit if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_unit else "portion"
+ # Convert portions_needed to child output unit if different unit was used
+ needed = float(sr.portions_needed)
+ needed_unit = sr.portions_needed_unit or child_output_unit
+ if needed_unit != child_output_unit:
+ _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
+ if needed_unit in _bases and child_output_unit in _bases:
+ needed = needed * _bases[needed_unit] / _bases[child_output_unit]
+ child_multiplier = multiplier * (needed / child_output_qty)
+ child_ings = await _collect_ingredients_for_recipe(sr.child_recipe_id, child_multiplier, db, depth + 1)
+ for ing_id, qty in child_ings.items():
+ result[ing_id] += qty
+
+ return dict(result)
+
+
+@router.get("/{order_id}/shopping-list")
+async def get_shopping_list(
+ order_id: int,
+ group_by_supplier: bool = Query(False),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Aggregated ingredient shopping list across all event order items."""
+ order = await _get_order(order_id, user.kitchen_id, db)
+
+ # Get all items
+ items_result = await db.execute(
+ select(EventOrderItem)
+ .options(selectinload(EventOrderItem.recipe))
+ .where(EventOrderItem.event_order_id == order_id)
+ )
+ items = items_result.scalars().all()
+
+ # Aggregate ingredient quantities across all recipes
+ total_ingredients: dict[int, float] = defaultdict(float)
+ recipe_breakdown: dict[int, list] = defaultdict(list) # ingredient_id -> [{recipe, qty}]
+
+ for item in items:
+ recipe = item.recipe
+ if not recipe:
+ continue
+
+ # For dish: multiplier = quantity (servings) / output_qty
+ # For component: multiplier = quantity (batches)
+ if recipe.recipe_type == "component":
+ multiplier = float(item.quantity) # each item.quantity = number of batches
+ else:
+ # Dishes are always portioned with batch_portions=1
+ multiplier = float(item.quantity) / (recipe.batch_portions or 1)
+
+ ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
+ for ing_id, qty in ings.items():
+ total_ingredients[ing_id] += qty
+ recipe_breakdown[ing_id].append({
+ "recipe_name": recipe.name,
+ "quantity": round(qty, 3),
+ })
+
+ if not total_ingredients:
+ return {"items": [], "by_supplier": {}}
+
+ # Load ingredient details and sources
+ ing_ids = list(total_ingredients.keys())
+ ing_result = await db.execute(
+ select(Ingredient)
+ .options(
+ selectinload(Ingredient.category),
+ selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
+ )
+ .where(Ingredient.id.in_(ing_ids))
+ )
+ ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
+
+ shopping_items = []
+ by_supplier: dict[str, list] = defaultdict(list)
+
+ for ing_id, total_qty in sorted(total_ingredients.items(), key=lambda x: x[0]):
+ ing = ingredients.get(ing_id)
+ if not ing:
+ continue
+
+ # Quantity already yield-adjusted during collection
+ adjusted_qty = total_qty
+
+ # Source info
+ sources = []
+ for src in (ing.sources or []):
+ pack_total = None
+ suggested_packs = None
+ cost_per_pack = None
+
+ if src.pack_quantity and src.unit_size and src.unit_size_type:
+ pack_in_std = convert_to_standard(
+ Decimal(str(src.pack_quantity)) * src.unit_size,
+ src.unit_size_type,
+ ing.standard_unit,
+ )
+ if pack_in_std and float(pack_in_std) > 0:
+ pack_total = float(pack_in_std)
+ suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
+ if src.latest_unit_price:
+ cost_per_pack = float(src.latest_unit_price)
+
+ source_info = {
+ "supplier_id": src.supplier_id,
+ "supplier_name": src.supplier.name if src.supplier else "",
+ "product_code": src.product_code,
+ "pack_description": f"{src.pack_quantity}×{src.unit_size}{src.unit_size_type}" if src.pack_quantity and src.unit_size else None,
+ "pack_total_std_unit": pack_total,
+ "suggested_packs": suggested_packs,
+ "cost_per_pack": cost_per_pack,
+ "subtotal": round(cost_per_pack * suggested_packs, 2) if cost_per_pack and suggested_packs else None,
+ }
+ sources.append(source_info)
+
+ if group_by_supplier:
+ supplier_name = src.supplier.name if src.supplier else "Unknown"
+ by_supplier[supplier_name].append({
+ "ingredient_name": ing.name,
+ "quantity_needed": round(adjusted_qty, 3),
+ "unit": ing.standard_unit,
+ **source_info,
+ })
+
+ item_data = {
+ "ingredient_id": ing.id,
+ "ingredient_name": ing.name,
+ "category": ing.category.name if ing.category else "Other",
+ "total_quantity": round(total_qty, 3),
+ "adjusted_quantity": round(adjusted_qty, 3),
+ "unit": ing.standard_unit,
+ "sources": sources,
+ "recipe_breakdown": recipe_breakdown.get(ing_id, []),
+ }
+ shopping_items.append(item_data)
+
+ # Sort by category
+ shopping_items.sort(key=lambda x: (x["category"], x["ingredient_name"]))
+
+ return {"items": shopping_items, "by_supplier": dict(by_supplier) if group_by_supplier else {}}
+
+
+# ── Generate Purchase Orders ─────────────────────────────────────────────────
+
+@router.post("/{order_id}/generate-po")
+async def generate_purchase_orders(
+ order_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Generate purchase orders from shopping list, grouped by supplier."""
+ from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
+
+ order = await _get_order(order_id, user.kitchen_id, db)
+
+ # Get shopping list grouped by supplier
+ items_result = await db.execute(
+ select(EventOrderItem)
+ .options(selectinload(EventOrderItem.recipe))
+ .where(EventOrderItem.event_order_id == order_id)
+ )
+ items = items_result.scalars().all()
+
+ # Aggregate ingredient quantities
+ total_ingredients: dict[int, float] = defaultdict(float)
+ for item in items:
+ recipe = item.recipe
+ if not recipe:
+ continue
+ if recipe.recipe_type == "component":
+ multiplier = float(item.quantity)
+ else:
+ multiplier = float(item.quantity) / (recipe.batch_portions or 1)
+ ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
+ for ing_id, qty in ings.items():
+ total_ingredients[ing_id] += qty
+
+ if not total_ingredients:
+ raise HTTPException(400, "No ingredients to order")
+
+ # Load ingredients with sources
+ from sqlalchemy.orm import selectinload as si
+ ing_result = await db.execute(
+ select(Ingredient)
+ .options(
+ selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
+ )
+ .where(Ingredient.id.in_(list(total_ingredients.keys())))
+ )
+ ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
+
+ # Group by supplier: {supplier_id: [{ingredient, qty, source}]}
+ supplier_lines: dict[int, list] = defaultdict(list)
+ unmapped = []
+
+ for ing_id, total_qty in total_ingredients.items():
+ ing = ingredients.get(ing_id)
+ if not ing:
+ continue
+
+ # Quantity already yield-adjusted during collection
+ adjusted_qty = total_qty
+
+ # Pick the most recent source (by latest_invoice_date)
+ best_source = None
+ for src in (ing.sources or []):
+ if best_source is None or (src.latest_invoice_date and (
+ not best_source.latest_invoice_date or src.latest_invoice_date > best_source.latest_invoice_date
+ )):
+ best_source = src
+
+ if best_source and best_source.supplier_id:
+ pack_total = None
+ suggested_packs = 1
+ if best_source.pack_quantity and best_source.unit_size and best_source.unit_size_type:
+ pack_in_std = convert_to_standard(
+ Decimal(str(best_source.pack_quantity)) * best_source.unit_size,
+ best_source.unit_size_type,
+ ing.standard_unit,
+ )
+ if pack_in_std and float(pack_in_std) > 0:
+ pack_total = float(pack_in_std)
+ suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
+
+ supplier_lines[best_source.supplier_id].append({
+ "ingredient": ing,
+ "source": best_source,
+ "quantity": max(suggested_packs, 1),
+ "unit_price": float(best_source.latest_unit_price) if best_source.latest_unit_price else 0,
+ })
+ else:
+ unmapped.append(ing.name)
+
+ # Create one PO per supplier
+ created_pos = []
+ for supplier_id, lines in supplier_lines.items():
+ total = sum(l["quantity"] * l["unit_price"] for l in lines)
+ po = PurchaseOrder(
+ kitchen_id=user.kitchen_id,
+ supplier_id=supplier_id,
+ order_date=order.event_date or date.today(),
+ order_type="itemised",
+ status="DRAFT",
+ total_amount=Decimal(str(round(total, 2))),
+ notes=f"Auto-generated from event order: {order.name}",
+ created_by=user.id,
+ )
+ db.add(po)
+ await db.flush()
+
+ for idx, line in enumerate(lines):
+ line_total = round(line["quantity"] * line["unit_price"], 2)
+ po_line = PurchaseOrderLineItem(
+ purchase_order_id=po.id,
+ kitchen_id=user.kitchen_id,
+ product_code=line["source"].product_code,
+ description=line["ingredient"].name,
+ unit=line["ingredient"].standard_unit,
+ unit_price=Decimal(str(line["unit_price"])),
+ quantity=Decimal(str(line["quantity"])),
+ total=Decimal(str(line_total)),
+ line_number=idx + 1,
+ source="event_order",
+ )
+ db.add(po_line)
+
+ created_pos.append({"id": po.id, "supplier_id": supplier_id})
+
+ await db.commit()
+
+ return {
+ "created": len(created_pos),
+ "purchase_orders": created_pos,
+ "unmapped_ingredients": unmapped,
+ }
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+async def _get_order(order_id: int, kitchen_id: int, db: AsyncSession) -> EventOrder:
+ result = await db.execute(
+ select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == kitchen_id)
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ raise HTTPException(404, "Event order not found")
+ return order
diff --git a/backend/api/external.py b/backend/api/external.py
new file mode 100644
index 0000000..860fe5a
--- /dev/null
+++ b/backend/api/external.py
@@ -0,0 +1,497 @@
+"""
+Internal API for in-house apps — API key authentication, dish recipe data,
+food flag listings, menus. Prefix: /api/external/
+"""
+import os
+import logging
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Header, Query
+from fastapi.responses import FileResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from database import get_db
+from models.settings import KitchenSettings
+from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeImage
+from models.menu import Menu, MenuDivision, MenuItem
+from models.ingredient import Ingredient
+from models.food_flag import FoodFlagCategory, FoodFlag
+from api.food_flags import compute_recipe_flags
+from api.menus import _compute_staleness
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+async def get_kitchen_from_api_key(
+ x_api_key: str = Header(..., alias="X-API-Key"),
+ db: AsyncSession = Depends(get_db),
+) -> KitchenSettings:
+ """Authenticate via API key and return the kitchen settings."""
+ if not x_api_key:
+ raise HTTPException(401, "Missing X-API-Key header")
+
+ result = await db.execute(
+ select(KitchenSettings).where(
+ KitchenSettings.api_key == x_api_key,
+ KitchenSettings.api_key_enabled == True,
+ )
+ )
+ settings = result.scalar_one_or_none()
+ if not settings:
+ raise HTTPException(401, "Invalid or disabled API key")
+ return settings
+
+
+@router.get("/recipes/dishes")
+async def list_dish_recipes(
+ include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
+ include_costs: bool = Query(False),
+ exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"),
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """List non-archived dish recipes for external consumption."""
+ query = (
+ select(Recipe)
+ .options(
+ selectinload(Recipe.menu_section),
+ selectinload(Recipe.images),
+ )
+ .where(
+ Recipe.kitchen_id == kitchen.kitchen_id,
+ Recipe.recipe_type == "dish",
+ Recipe.is_archived == False,
+ )
+ .order_by(Recipe.name)
+ )
+ result = await db.execute(query)
+ recipes = result.scalars().all()
+
+ exclude_flag_ids = set()
+ if exclude_flags:
+ exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
+
+ items = []
+ for r in recipes:
+ # Get flags
+ flags = await compute_recipe_flags(r.id, kitchen.kitchen_id, db)
+ active_flags = [f for f in flags if f.is_active]
+
+ # Check exclude filter
+ if exclude_flag_ids:
+ recipe_flag_ids = {f.food_flag_id for f in active_flags}
+ if recipe_flag_ids & exclude_flag_ids:
+ continue
+
+ flag_data = [
+ {
+ "id": f.food_flag_id,
+ "name": f.flag_name,
+ "code": f.flag_code,
+ "icon": f.flag_icon,
+ "category": f.category_name,
+ "propagation": f.propagation_type,
+ "excludable": f.excludable_on_request,
+ }
+ for f in active_flags
+ ]
+
+ item = {
+ "id": r.id,
+ "name": r.name,
+ "description": r.description,
+ "menu_section": r.menu_section.name if r.menu_section else None,
+ "prep_time_minutes": r.prep_time_minutes,
+ "cook_time_minutes": r.cook_time_minutes,
+ "flags": flag_data,
+ "images": [
+ {"id": img.id, "caption": img.caption, "image_type": img.image_type}
+ for img in (r.images or [])
+ ],
+ }
+
+ # Include costs if requested
+ if include_costs:
+ from api.recipes import _calc_recipe_cost
+ cost_data = await _calc_recipe_cost(r.id, db)
+ item["cost_per_portion"] = cost_data.get("cost_per_portion")
+ item["total_cost"] = cost_data.get("total_cost_recent")
+
+ # Include ingredients if requested
+ if include_ingredients != "none":
+ item["ingredients"] = await _get_recipe_ingredients(r.id, kitchen.kitchen_id, db, include_ingredients)
+
+ items.append(item)
+
+ return items
+
+
+@router.get("/recipes/plated")
+async def list_plated_recipes_compat(
+ include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
+ include_costs: bool = Query(False),
+ exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"),
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Backward-compatible alias for /recipes/dishes."""
+ return await list_dish_recipes(include_ingredients, include_costs, exclude_flags, kitchen, db)
+
+
+@router.get("/recipes/{recipe_id}")
+async def get_dish_recipe(
+ recipe_id: int,
+ include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
+ include_costs: bool = Query(False),
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get a single dish recipe for external consumption."""
+ result = await db.execute(
+ select(Recipe)
+ .options(selectinload(Recipe.menu_section), selectinload(Recipe.images))
+ .where(
+ Recipe.id == recipe_id,
+ Recipe.kitchen_id == kitchen.kitchen_id,
+ Recipe.recipe_type == "dish",
+ Recipe.is_archived == False,
+ )
+ )
+ recipe = result.scalar_one_or_none()
+ if not recipe:
+ raise HTTPException(404, "Recipe not found")
+
+ flags = await compute_recipe_flags(recipe.id, kitchen.kitchen_id, db)
+ active_flags = [f for f in flags if f.is_active]
+
+ item = {
+ "id": recipe.id,
+ "name": recipe.name,
+ "description": recipe.description,
+ "menu_section": recipe.menu_section.name if recipe.menu_section else None,
+ "prep_time_minutes": recipe.prep_time_minutes,
+ "cook_time_minutes": recipe.cook_time_minutes,
+ "flags": [
+ {
+ "id": f.food_flag_id,
+ "name": f.flag_name,
+ "code": f.flag_code,
+ "icon": f.flag_icon,
+ "category": f.category_name,
+ "propagation": f.propagation_type,
+ "excludable": f.excludable_on_request,
+ }
+ for f in active_flags
+ ],
+ "images": [
+ {"id": img.id, "caption": img.caption, "image_type": img.image_type}
+ for img in (recipe.images or [])
+ ],
+ }
+
+ if include_costs:
+ from api.recipes import _calc_recipe_cost
+ cost_data = await _calc_recipe_cost(recipe.id, db)
+ item["cost_per_portion"] = cost_data.get("cost_per_portion")
+ item["total_cost"] = cost_data.get("total_cost_recent")
+
+ if include_ingredients != "none":
+ item["ingredients"] = await _get_recipe_ingredients(recipe.id, kitchen.kitchen_id, db, include_ingredients)
+
+ return item
+
+
+@router.get("/food-flags")
+async def list_food_flags(
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """List all flag categories and flags for external apps."""
+ result = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == kitchen.kitchen_id)
+ .order_by(FoodFlagCategory.sort_order)
+ )
+ categories = result.scalars().all()
+ return [
+ {
+ "id": cat.id,
+ "name": cat.name,
+ "propagation_type": cat.propagation_type,
+ "flags": [
+ {"id": f.id, "name": f.name, "code": f.code, "icon": f.icon}
+ for f in sorted(cat.flags, key=lambda x: x.sort_order)
+ ],
+ }
+ for cat in categories
+ ]
+
+
+async def _get_recipe_ingredients(recipe_id: int, kitchen_id: int, db: AsyncSession, mode: str) -> list:
+ """Get ingredient list for external API — flat (consolidated) or nested (sub-recipe breakdown)."""
+ if mode == "flat":
+ # Consolidated list
+ from api.event_orders import _collect_ingredients_for_recipe
+ ing_qtys = await _collect_ingredients_for_recipe(recipe_id, 1.0, db)
+ if not ing_qtys:
+ return []
+
+ result = await db.execute(
+ select(Ingredient).where(Ingredient.id.in_(list(ing_qtys.keys())))
+ )
+ ingredients = {ing.id: ing for ing in result.scalars().all()}
+
+ return [
+ {
+ "ingredient_id": ing_id,
+ "name": ingredients[ing_id].name if ing_id in ingredients else "?",
+ "quantity": round(qty, 3),
+ "unit": ingredients[ing_id].standard_unit if ing_id in ingredients else "",
+ }
+ for ing_id, qty in sorted(ing_qtys.items())
+ ]
+
+ elif mode == "nested":
+ # Show sub-recipe breakdown
+ ri_result = await db.execute(
+ select(RecipeIngredient)
+ .options(selectinload(RecipeIngredient.ingredient))
+ .where(RecipeIngredient.recipe_id == recipe_id)
+ .order_by(RecipeIngredient.sort_order)
+ )
+ direct = [
+ {
+ "ingredient_id": ri.ingredient_id,
+ "name": ri.ingredient.name if ri.ingredient else "?",
+ "quantity": float(ri.quantity),
+ "unit": ri.ingredient.standard_unit if ri.ingredient else "",
+ "source": "direct",
+ }
+ for ri in ri_result.scalars().all()
+ ]
+
+ sr_result = await db.execute(
+ select(RecipeSubRecipe)
+ .options(selectinload(RecipeSubRecipe.child_recipe))
+ .where(RecipeSubRecipe.parent_recipe_id == recipe_id)
+ )
+ sub_recipe_ings = []
+ for sr in sr_result.scalars().all():
+ child = sr.child_recipe
+ if not child:
+ continue
+ cri_result = await db.execute(
+ select(RecipeIngredient)
+ .options(selectinload(RecipeIngredient.ingredient))
+ .where(RecipeIngredient.recipe_id == child.id)
+ )
+ # Use unified output qty for bulk/portioned child recipes
+ child_output_qty = float(child.batch_yield_qty) if child.batch_output_type == "bulk" and child.batch_yield_qty else (child.batch_portions or 1)
+ child_output_unit = child.batch_yield_unit if child.batch_output_type == "bulk" and child.batch_yield_unit else "portion"
+ # Convert portions_needed to child output unit if different unit was used
+ needed = float(sr.portions_needed)
+ needed_unit = sr.portions_needed_unit or child_output_unit
+ if needed_unit != child_output_unit:
+ _bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
+ if needed_unit in _bases and child_output_unit in _bases:
+ needed = needed * _bases[needed_unit] / _bases[child_output_unit]
+ scale = needed / child_output_qty
+ for cri in cri_result.scalars().all():
+ sub_recipe_ings.append({
+ "ingredient_id": cri.ingredient_id,
+ "name": cri.ingredient.name if cri.ingredient else "?",
+ "quantity": round(float(cri.quantity) * scale, 3),
+ "unit": cri.ingredient.standard_unit if cri.ingredient else "",
+ "source": f"sub-recipe: {child.name}",
+ })
+
+ return direct + sub_recipe_ings
+
+ return []
+
+
+# ── Menu Endpoints ───────────────────────────────────────────────────────────
+
+@router.get("/menus")
+async def list_menus_external(
+ exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude items containing those allergens"),
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """List active menus with divisions and items served from snapshots."""
+ result = await db.execute(
+ select(Menu)
+ .options(
+ selectinload(Menu.divisions).selectinload(MenuDivision.items),
+ selectinload(Menu.items),
+ )
+ .where(Menu.kitchen_id == kitchen.kitchen_id, Menu.is_active == True)
+ .order_by(Menu.sort_order)
+ )
+ menus = result.scalars().all()
+
+ exclude_flag_ids = set()
+ if exclude_flags:
+ exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
+
+ menus_data = []
+ for menu in menus:
+ all_items = [i for i in (menu.items or []) if i.recipe_id is not None]
+ staleness = await _compute_staleness(all_items, db)
+
+ divisions_data = []
+ for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
+ div_items = sorted(
+ [i for i in all_items if i.division_id == div.id],
+ key=lambda i: i.sort_order,
+ )
+ items_data = []
+ for item in div_items:
+ snapshot = item.snapshot_json or {}
+ confirmed_flags = snapshot.get("confirmed_flags", [])
+
+ # Apply exclude filter
+ if exclude_flag_ids:
+ item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")}
+ if item_flag_ids & exclude_flag_ids:
+ continue
+
+ stale_info = staleness.get(item.id, {"is_stale": False})
+
+ items_data.append({
+ "id": item.id,
+ "display_name": snapshot.get("display_name", item.display_name),
+ "description": snapshot.get("description", item.description),
+ "price": snapshot.get("price", str(item.price) if item.price else None),
+ "flags": [
+ {"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"),
+ "category": f.get("category"), "excludable": f.get("excludable", False)}
+ for f in confirmed_flags
+ ],
+ "is_stale": stale_info.get("is_stale", False),
+ "has_image": bool(item.image_path),
+ })
+
+ if items_data:
+ divisions_data.append({
+ "name": div.name,
+ "items": items_data,
+ })
+
+ menus_data.append({
+ "id": menu.id,
+ "name": menu.name,
+ "description": menu.description,
+ "divisions": divisions_data,
+ })
+
+ return menus_data
+
+
+@router.get("/menus/{menu_id}")
+async def get_menu_external(
+ menu_id: int,
+ exclude_flags: Optional[str] = Query(None),
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Single menu detail for external consumption."""
+ result = await db.execute(
+ select(Menu)
+ .options(
+ selectinload(Menu.divisions).selectinload(MenuDivision.items),
+ selectinload(Menu.items),
+ )
+ .where(
+ Menu.id == menu_id,
+ Menu.kitchen_id == kitchen.kitchen_id,
+ Menu.is_active == True,
+ )
+ )
+ menu = result.scalar_one_or_none()
+ if not menu:
+ raise HTTPException(404, "Menu not found")
+
+ exclude_flag_ids = set()
+ if exclude_flags:
+ exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
+
+ all_items = [i for i in (menu.items or []) if i.recipe_id is not None]
+ staleness = await _compute_staleness(all_items, db)
+
+ divisions_data = []
+ for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
+ div_items = sorted(
+ [i for i in all_items if i.division_id == div.id],
+ key=lambda i: i.sort_order,
+ )
+ items_data = []
+ for item in div_items:
+ snapshot = item.snapshot_json or {}
+ confirmed_flags = snapshot.get("confirmed_flags", [])
+
+ if exclude_flag_ids:
+ item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")}
+ if item_flag_ids & exclude_flag_ids:
+ continue
+
+ stale_info = staleness.get(item.id, {"is_stale": False})
+
+ items_data.append({
+ "id": item.id,
+ "display_name": snapshot.get("display_name", item.display_name),
+ "description": snapshot.get("description", item.description),
+ "price": snapshot.get("price", str(item.price) if item.price else None),
+ "flags": [
+ {"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"),
+ "category": f.get("category"), "excludable": f.get("excludable", False)}
+ for f in confirmed_flags
+ ],
+ "is_stale": stale_info.get("is_stale", False),
+ "has_image": bool(item.image_path),
+ })
+
+ if items_data:
+ divisions_data.append({
+ "name": div.name,
+ "items": items_data,
+ })
+
+ return {
+ "id": menu.id,
+ "name": menu.name,
+ "description": menu.description,
+ "divisions": divisions_data,
+ }
+
+
+@router.get("/menus/{menu_id}/items/{item_id}/image")
+async def serve_menu_item_image_external(
+ menu_id: int,
+ item_id: int,
+ kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Serve a menu item image via API key authentication."""
+ result = await db.execute(
+ select(MenuItem)
+ .join(Menu, MenuItem.menu_id == Menu.id)
+ .where(
+ MenuItem.id == item_id,
+ MenuItem.menu_id == menu_id,
+ Menu.kitchen_id == kitchen.kitchen_id,
+ )
+ )
+ item = result.scalar_one_or_none()
+ if not item or not item.image_path:
+ raise HTTPException(404, "Image not found")
+
+ if not os.path.exists(item.image_path):
+ raise HTTPException(404, "Image file not found")
+
+ return FileResponse(item.image_path)
diff --git a/backend/api/field_mappings.py b/backend/api/field_mappings.py
new file mode 100644
index 0000000..2ecabdc
--- /dev/null
+++ b/backend/api/field_mappings.py
@@ -0,0 +1,205 @@
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.field_mapping import (
+ FieldMapping,
+ AZURE_INVOICE_FIELDS,
+ AZURE_LINE_ITEM_FIELDS,
+ TARGET_INVOICE_FIELDS,
+ TARGET_LINE_ITEM_FIELDS
+)
+from auth import get_current_user, require_cap
+
+router = APIRouter()
+
+
+class FieldMappingResponse(BaseModel):
+ id: int
+ supplier_id: int | None
+ source_field: str
+ target_field: str
+ field_type: str
+ transform: str
+ priority: int
+
+ class Config:
+ from_attributes = True
+
+
+class FieldMappingCreate(BaseModel):
+ supplier_id: Optional[int] = None
+ source_field: str
+ target_field: str
+ field_type: str = "invoice"
+ transform: str = "direct"
+ priority: int = 0
+
+
+class FieldMappingUpdate(BaseModel):
+ source_field: Optional[str] = None
+ target_field: Optional[str] = None
+ field_type: Optional[str] = None
+ transform: Optional[str] = None
+ priority: Optional[int] = None
+
+
+class FieldOptionsResponse(BaseModel):
+ azure_invoice_fields: list[str]
+ azure_line_item_fields: list[str]
+ target_invoice_fields: list[str]
+ target_line_item_fields: list[str]
+
+
+@router.get("/options", response_model=FieldOptionsResponse)
+async def get_field_options():
+ """Get available field names for creating mappings"""
+ return FieldOptionsResponse(
+ azure_invoice_fields=AZURE_INVOICE_FIELDS,
+ azure_line_item_fields=AZURE_LINE_ITEM_FIELDS,
+ target_invoice_fields=TARGET_INVOICE_FIELDS,
+ target_line_item_fields=TARGET_LINE_ITEM_FIELDS
+ )
+
+
+@router.get("/", response_model=list[FieldMappingResponse])
+async def list_field_mappings(
+ supplier_id: Optional[int] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """List field mappings for the current kitchen, optionally filtered by supplier"""
+ query = select(FieldMapping).where(
+ FieldMapping.kitchen_id == current_user.kitchen_id
+ )
+
+ if supplier_id is not None:
+ # Get mappings for specific supplier OR global (supplier_id=null)
+ query = query.where(
+ (FieldMapping.supplier_id == supplier_id) |
+ (FieldMapping.supplier_id.is_(None))
+ )
+
+ query = query.order_by(FieldMapping.priority.desc(), FieldMapping.id)
+
+ result = await db.execute(query)
+ mappings = result.scalars().all()
+
+ return [
+ FieldMappingResponse(
+ id=m.id,
+ supplier_id=m.supplier_id,
+ source_field=m.source_field,
+ target_field=m.target_field,
+ field_type=m.field_type,
+ transform=m.transform,
+ priority=m.priority
+ )
+ for m in mappings
+ ]
+
+
+@router.post("/", response_model=FieldMappingResponse)
+async def create_field_mapping(
+ mapping: FieldMappingCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Create a new field mapping"""
+ # Validate target field
+ valid_targets = (
+ TARGET_INVOICE_FIELDS if mapping.field_type == "invoice"
+ else TARGET_LINE_ITEM_FIELDS
+ )
+ if mapping.target_field not in valid_targets:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid target field. Valid options: {valid_targets}"
+ )
+
+ new_mapping = FieldMapping(
+ kitchen_id=current_user.kitchen_id,
+ supplier_id=mapping.supplier_id,
+ source_field=mapping.source_field,
+ target_field=mapping.target_field,
+ field_type=mapping.field_type,
+ transform=mapping.transform,
+ priority=mapping.priority
+ )
+
+ db.add(new_mapping)
+ await db.commit()
+ await db.refresh(new_mapping)
+
+ return FieldMappingResponse(
+ id=new_mapping.id,
+ supplier_id=new_mapping.supplier_id,
+ source_field=new_mapping.source_field,
+ target_field=new_mapping.target_field,
+ field_type=new_mapping.field_type,
+ transform=new_mapping.transform,
+ priority=new_mapping.priority
+ )
+
+
+@router.patch("/{mapping_id}", response_model=FieldMappingResponse)
+async def update_field_mapping(
+ mapping_id: int,
+ update: FieldMappingUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update a field mapping"""
+ result = await db.execute(
+ select(FieldMapping).where(
+ FieldMapping.id == mapping_id,
+ FieldMapping.kitchen_id == current_user.kitchen_id
+ )
+ )
+ mapping = result.scalar_one_or_none()
+ if not mapping:
+ raise HTTPException(status_code=404, detail="Field mapping not found")
+
+ update_data = update.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(mapping, field, value)
+
+ await db.commit()
+ await db.refresh(mapping)
+
+ return FieldMappingResponse(
+ id=mapping.id,
+ supplier_id=mapping.supplier_id,
+ source_field=mapping.source_field,
+ target_field=mapping.target_field,
+ field_type=mapping.field_type,
+ transform=mapping.transform,
+ priority=mapping.priority
+ )
+
+
+@router.delete("/{mapping_id}")
+async def delete_field_mapping(
+ mapping_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Delete a field mapping"""
+ result = await db.execute(
+ select(FieldMapping).where(
+ FieldMapping.id == mapping_id,
+ FieldMapping.kitchen_id == current_user.kitchen_id
+ )
+ )
+ mapping = result.scalar_one_or_none()
+ if not mapping:
+ raise HTTPException(status_code=404, detail="Field mapping not found")
+
+ await db.delete(mapping)
+ await db.commit()
+
+ return {"message": "Field mapping deleted"}
diff --git a/backend/api/food_flags.py b/backend/api/food_flags.py
new file mode 100644
index 0000000..3a9c594
--- /dev/null
+++ b/backend/api/food_flags.py
@@ -0,0 +1,2309 @@
+"""
+Food Flag API — categories, flags, line item flagging + latching, recipe flag propagation,
+allergen keyword suggestions, and label OCR scanning.
+"""
+import logging
+import os
+import re
+import uuid
+from datetime import datetime
+from typing import Optional
+
+import aiofiles
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, delete, func
+from sqlalchemy.orm import selectinload
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.food_flag import FoodFlagCategory, FoodFlag, LineItemFlag, RecipeFlag, RecipeFlagOverride, AllergenKeyword, BrakesProductCache
+from models.ingredient import Ingredient, IngredientFlag, IngredientFlagNone, IngredientFlagDismissal
+from models.line_item import LineItem
+from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeTextFlagDismissal
+from models.settings import KitchenSettings
+from auth import get_current_user, require_cap
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+# ── Pydantic schemas ─────────────────────────────────────────────────────────
+
+class CategoryCreate(BaseModel):
+ name: str
+ propagation_type: str = "contains" # "contains" | "suitable_for"
+ required: bool = False
+ sort_order: int = 0
+
+class CategoryUpdate(BaseModel):
+ name: Optional[str] = None
+ propagation_type: Optional[str] = None
+ required: Optional[bool] = None
+ sort_order: Optional[int] = None
+
+class FlagCreate(BaseModel):
+ category_id: int
+ name: str
+ code: Optional[str] = None
+ icon: Optional[str] = None
+ sort_order: int = 0
+
+class FlagUpdate(BaseModel):
+ name: Optional[str] = None
+ code: Optional[str] = None
+ icon: Optional[str] = None
+ sort_order: Optional[int] = None
+
+class FlagResponse(BaseModel):
+ id: int
+ name: str
+ code: Optional[str] = None
+ icon: Optional[str] = None
+ sort_order: int = 0
+ category_id: int
+ category_name: str = ""
+ propagation_type: str = "contains"
+
+class CategoryResponse(BaseModel):
+ id: int
+ name: str
+ propagation_type: str
+ required: bool = False
+ sort_order: int
+ flags: list[FlagResponse] = []
+
+class LineItemFlagSet(BaseModel):
+ food_flag_ids: list[int]
+
+class LineItemFlagResponse(BaseModel):
+ id: int
+ food_flag_id: int
+ flag_name: str = ""
+ flag_code: Optional[str] = None
+ category_name: str = ""
+
+class RecipeFlagState(BaseModel):
+ food_flag_id: int
+ flag_name: str = ""
+ flag_code: Optional[str] = None
+ flag_icon: Optional[str] = None
+ category_id: int
+ category_name: str = ""
+ propagation_type: str = "contains"
+ source_type: str = "auto" # "auto" | "manual"
+ is_active: bool = True
+ excludable_on_request: bool = False
+ source_ingredients: list[str] = [] # ingredient names that contribute this flag
+
+class RecipeFlagOverrideLog(BaseModel):
+ id: int
+ food_flag_id: int
+ flag_name: str = ""
+ action: str
+ note: str
+ username: str = ""
+ created_at: str = ""
+
+class OverrideRequest(BaseModel):
+ note: str
+
+class ManualFlagAdd(BaseModel):
+ food_flag_id: int
+
+class ExcludableToggle(BaseModel):
+ note: str
+
+class MatrixCell(BaseModel):
+ has_flag: bool = False
+ is_unassessed: bool = False
+ is_none: bool = False # "None apply" set for this flag's category
+ has_open_suggestion: bool = False # unreviewed allergen suggestion exists
+
+class MatrixIngredient(BaseModel):
+ ingredient_id: int
+ ingredient_name: str
+ is_sub_recipe: bool = False
+ sub_recipe_name: Optional[str] = None
+ flags: dict[int, MatrixCell] = {} # food_flag_id -> cell state
+
+
+# ── Seed defaults ────────────────────────────────────────────────────────────
+
+DEFAULT_ALLERGY_FLAGS = [
+ ("Celery", "Ce"), ("Gluten", "Gl"), ("Crustaceans", "Cr"), ("Eggs", "Eg"),
+ ("Fish", "Fi"), ("Lupin", "Lu"), ("Milk", "Mi"), ("Molluscs", "Mo"),
+ ("Mustard", "Mu"), ("Tree Nuts", "TN"), ("Peanuts", "Pn"), ("Sesame", "Se"),
+ ("Soya", "So"), ("Sulphites", "Su"),
+]
+
+DEFAULT_DIETARY_FLAGS = [
+ ("Vegetarian", "V"), ("Vegan", "Ve"), ("Pescatarian", "Pe"), ("Gluten-Free", "GF"),
+]
+
+
+@router.post("/seed-defaults")
+async def seed_default_flags(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Seed the standard 14 UK allergens + 4 dietary flags. Skips any that already exist."""
+ import traceback
+ try:
+ from migrations.add_allergen_keywords import ALLERGEN_KEYWORDS
+
+ kid = user.kitchen_id
+ created_cats = 0
+ created_flags = 0
+ seeded_keywords = 0
+
+ # --- Allergy category ---
+ result = await db.execute(
+ select(FoodFlagCategory).where(
+ FoodFlagCategory.kitchen_id == kid,
+ FoodFlagCategory.name == "Allergy",
+ )
+ )
+ allergy_cat = result.scalar_one_or_none()
+ if not allergy_cat:
+ allergy_cat = FoodFlagCategory(
+ kitchen_id=kid, name="Allergy", propagation_type="contains",
+ required=True, sort_order=0,
+ )
+ db.add(allergy_cat)
+ await db.flush()
+ created_cats += 1
+
+ for i, (name, code) in enumerate(DEFAULT_ALLERGY_FLAGS):
+ exists = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.kitchen_id == kid, FoodFlag.name == name,
+ )
+ )
+ if exists.scalar_one_or_none():
+ continue
+ db.add(FoodFlag(
+ category_id=allergy_cat.id, kitchen_id=kid,
+ name=name, code=code, sort_order=i,
+ ))
+ created_flags += 1
+
+ # --- Dietary category ---
+ result = await db.execute(
+ select(FoodFlagCategory).where(
+ FoodFlagCategory.kitchen_id == kid,
+ FoodFlagCategory.name == "Dietary",
+ )
+ )
+ dietary_cat = result.scalar_one_or_none()
+ if not dietary_cat:
+ dietary_cat = FoodFlagCategory(
+ kitchen_id=kid, name="Dietary", propagation_type="suitable_for",
+ required=False, sort_order=1,
+ )
+ db.add(dietary_cat)
+ await db.flush()
+ created_cats += 1
+
+ for i, (name, code) in enumerate(DEFAULT_DIETARY_FLAGS):
+ exists = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.kitchen_id == kid, FoodFlag.name == name,
+ )
+ )
+ if exists.scalar_one_or_none():
+ continue
+ db.add(FoodFlag(
+ category_id=dietary_cat.id, kitchen_id=kid,
+ name=name, code=code, sort_order=i,
+ ))
+ created_flags += 1
+
+ await db.flush()
+
+ # --- Seed allergen keywords ---
+ # Flush new flags first, then seed keywords separately to avoid autoflush conflicts
+ await db.commit()
+
+ # Re-fetch all flags for keyword seeding (clean session, no pending objects)
+ for flag_name, keywords in ALLERGEN_KEYWORDS.items():
+ flag_result = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.kitchen_id == kid, FoodFlag.name == flag_name,
+ )
+ )
+ flag = flag_result.scalar_one_or_none()
+ if not flag:
+ continue
+ # Get all existing keywords for this flag
+ existing_result = await db.execute(
+ select(AllergenKeyword.keyword).where(
+ AllergenKeyword.kitchen_id == kid,
+ AllergenKeyword.food_flag_id == flag.id,
+ )
+ )
+ existing_keywords = {row[0] for row in existing_result.all()}
+
+ # Add only keywords that don't already exist (preserves manual entries)
+ seen: set[str] = set()
+ for kw_str in keywords:
+ kw = kw_str.lower()
+ if kw in seen or kw in existing_keywords:
+ continue
+ seen.add(kw)
+ db.add(AllergenKeyword(
+ kitchen_id=kid, food_flag_id=flag.id,
+ keyword=kw, is_default=True,
+ ))
+ seeded_keywords += 1
+ # Flush after each flag to avoid cross-flag autoflush issues
+ await db.flush()
+
+ await db.commit()
+ logger.info(f"Seeded defaults for kitchen {kid}: {created_cats} categories, {created_flags} flags, {seeded_keywords} keywords")
+ return {
+ "ok": True,
+ "created_categories": created_cats,
+ "created_flags": created_flags,
+ "seeded_keywords": seeded_keywords,
+ }
+ except Exception as e:
+ logger.error(f"seed-defaults failed: {e}\n{traceback.format_exc()}")
+ raise HTTPException(500, detail=f"Seed failed: {str(e)}")
+
+
+# ── Category endpoints ───────────────────────────────────────────────────────
+
+@router.get("/categories")
+async def list_categories(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == user.kitchen_id)
+ .order_by(FoodFlagCategory.sort_order, FoodFlagCategory.name)
+ )
+ cats = result.scalars().all()
+ return [
+ CategoryResponse(
+ id=c.id,
+ name=c.name,
+ propagation_type=c.propagation_type,
+ required=c.required,
+ sort_order=c.sort_order,
+ flags=[
+ FlagResponse(
+ id=f.id, name=f.name, code=f.code, icon=f.icon,
+ sort_order=f.sort_order, category_id=c.id,
+ category_name=c.name, propagation_type=c.propagation_type,
+ )
+ for f in sorted(c.flags, key=lambda x: (x.sort_order, x.name))
+ ],
+ )
+ for c in cats
+ ]
+
+
+@router.post("/categories")
+async def create_category(
+ data: CategoryCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ if data.propagation_type not in ("contains", "suitable_for"):
+ raise HTTPException(400, "propagation_type must be 'contains' or 'suitable_for'")
+ cat = FoodFlagCategory(
+ kitchen_id=user.kitchen_id,
+ name=data.name,
+ propagation_type=data.propagation_type,
+ required=data.required,
+ sort_order=data.sort_order,
+ )
+ db.add(cat)
+ await db.commit()
+ await db.refresh(cat)
+ return CategoryResponse(id=cat.id, name=cat.name, propagation_type=cat.propagation_type, required=cat.required, sort_order=cat.sort_order)
+
+
+@router.patch("/categories/{cat_id}")
+async def update_category(
+ cat_id: int,
+ data: CategoryUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(FoodFlagCategory).where(
+ FoodFlagCategory.id == cat_id,
+ FoodFlagCategory.kitchen_id == user.kitchen_id,
+ )
+ )
+ cat = result.scalar_one_or_none()
+ if not cat:
+ raise HTTPException(404, "Category not found")
+ if data.name is not None:
+ cat.name = data.name
+ if data.propagation_type is not None:
+ if data.propagation_type not in ("contains", "suitable_for"):
+ raise HTTPException(400, "propagation_type must be 'contains' or 'suitable_for'")
+ cat.propagation_type = data.propagation_type
+ if data.required is not None:
+ cat.required = data.required
+ if data.sort_order is not None:
+ cat.sort_order = data.sort_order
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/categories/{cat_id}")
+async def delete_category(
+ cat_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(FoodFlagCategory).where(
+ FoodFlagCategory.id == cat_id,
+ FoodFlagCategory.kitchen_id == user.kitchen_id,
+ )
+ )
+ cat = result.scalar_one_or_none()
+ if not cat:
+ raise HTTPException(404, "Category not found")
+ # Clean up IngredientFlagNone records for this category
+ await db.execute(
+ delete(IngredientFlagNone).where(IngredientFlagNone.category_id == cat_id)
+ )
+ try:
+ await db.delete(cat)
+ await db.commit()
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Failed to delete category {cat_id}: {e}")
+ raise HTTPException(500, f"Failed to delete category: {str(e)}")
+ return {"ok": True}
+
+
+# ── Flag CRUD ────────────────────────────────────────────────────────────────
+
+@router.post("/flags")
+async def create_flag(
+ data: FlagCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ # Verify category belongs to kitchen
+ cat = await db.execute(
+ select(FoodFlagCategory).where(
+ FoodFlagCategory.id == data.category_id,
+ FoodFlagCategory.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not cat.scalar_one_or_none():
+ raise HTTPException(404, "Category not found")
+
+ flag = FoodFlag(
+ category_id=data.category_id,
+ kitchen_id=user.kitchen_id,
+ name=data.name,
+ code=data.code,
+ icon=data.icon,
+ sort_order=data.sort_order,
+ )
+ db.add(flag)
+ await db.commit()
+ await db.refresh(flag)
+ return FlagResponse(
+ id=flag.id, name=flag.name, code=flag.code, icon=flag.icon,
+ sort_order=flag.sort_order, category_id=data.category_id,
+ )
+
+
+@router.patch("/flags/{flag_id}")
+async def update_flag(
+ flag_id: int,
+ data: FlagUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.id == flag_id,
+ FoodFlag.kitchen_id == user.kitchen_id,
+ )
+ )
+ flag = result.scalar_one_or_none()
+ if not flag:
+ raise HTTPException(404, "Flag not found")
+ if data.name is not None:
+ flag.name = data.name
+ if data.code is not None:
+ flag.code = data.code
+ if data.icon is not None:
+ flag.icon = data.icon
+ if data.sort_order is not None:
+ flag.sort_order = data.sort_order
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/flags/{flag_id}")
+async def delete_flag(
+ flag_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.id == flag_id,
+ FoodFlag.kitchen_id == user.kitchen_id,
+ )
+ )
+ flag = result.scalar_one_or_none()
+ if not flag:
+ raise HTTPException(404, "Flag not found")
+ # Explicitly clean up related records (belt + suspenders alongside CASCADE)
+ await db.execute(delete(IngredientFlag).where(IngredientFlag.food_flag_id == flag_id))
+ await db.execute(delete(RecipeFlag).where(RecipeFlag.food_flag_id == flag_id))
+ await db.execute(delete(RecipeFlagOverride).where(RecipeFlagOverride.food_flag_id == flag_id))
+ await db.execute(delete(LineItemFlag).where(LineItemFlag.food_flag_id == flag_id))
+ try:
+ await db.delete(flag)
+ await db.commit()
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Failed to delete flag {flag_id}: {e}")
+ raise HTTPException(500, f"Failed to delete flag: {str(e)}")
+ return {"ok": True}
+
+
+# ── Line Item Flags + Latching ───────────────────────────────────────────────
+
+@router.get("/line-items/{line_item_id}/flags")
+async def get_line_item_flags(
+ line_item_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(LineItemFlag)
+ .options(selectinload(LineItemFlag.food_flag).selectinload(FoodFlag.category))
+ .where(LineItemFlag.line_item_id == line_item_id)
+ )
+ flags = result.scalars().all()
+ return [
+ LineItemFlagResponse(
+ id=f.id,
+ food_flag_id=f.food_flag_id,
+ flag_name=f.food_flag.name if f.food_flag else "",
+ flag_code=f.food_flag.code if f.food_flag else None,
+ category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "",
+ )
+ for f in flags
+ ]
+
+
+@router.put("/line-items/{line_item_id}/flags")
+async def set_line_item_flags(
+ line_item_id: int,
+ data: LineItemFlagSet,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Set flags on a line item (full replacement). Triggers latching to mapped ingredient."""
+ # Get the line item to check ingredient_id
+ li_result = await db.execute(select(LineItem).where(LineItem.id == line_item_id))
+ li = li_result.scalar_one_or_none()
+ if not li:
+ raise HTTPException(404, "Line item not found")
+
+ # Delete existing line item flags
+ await db.execute(delete(LineItemFlag).where(LineItemFlag.line_item_id == line_item_id))
+
+ # Add new flags
+ for flag_id in data.food_flag_ids:
+ db.add(LineItemFlag(
+ line_item_id=line_item_id,
+ food_flag_id=flag_id,
+ flagged_by=user.id,
+ ))
+
+ # Latching: if line item is mapped to an ingredient, auto-create ingredient_flag
+ if li.ingredient_id:
+ existing = await db.execute(
+ select(IngredientFlag).where(
+ IngredientFlag.ingredient_id == li.ingredient_id,
+ IngredientFlag.food_flag_id == flag_id,
+ )
+ )
+ if not existing.scalar_one_or_none():
+ db.add(IngredientFlag(
+ ingredient_id=li.ingredient_id,
+ food_flag_id=flag_id,
+ flagged_by=user.id,
+ source="latched",
+ ))
+
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Recipe Flag Propagation ──────────────────────────────────────────────────
+
+async def _collect_recipe_ingredient_ids(recipe_id: int, db: AsyncSession, depth: int = 0) -> list[int]:
+ """Recursively collect all ingredient IDs used in a recipe (including sub-recipes)."""
+ if depth > 5:
+ return []
+
+ ingredient_ids = []
+
+ # Direct ingredients
+ ri_result = await db.execute(
+ select(RecipeIngredient.ingredient_id).where(RecipeIngredient.recipe_id == recipe_id)
+ )
+ ingredient_ids.extend([r[0] for r in ri_result.fetchall()])
+
+ # Sub-recipe ingredients (recursive)
+ sr_result = await db.execute(
+ select(RecipeSubRecipe.child_recipe_id).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
+ )
+ for (child_id,) in sr_result.fetchall():
+ child_ids = await _collect_recipe_ingredient_ids(child_id, db, depth + 1)
+ ingredient_ids.extend(child_ids)
+
+ return ingredient_ids
+
+
+async def compute_recipe_flags(recipe_id: int, kitchen_id: int, db: AsyncSession) -> list[RecipeFlagState]:
+ """Compute the full flag state for a recipe using ingredient_flags as canonical source."""
+ # Get all ingredient IDs (including sub-recipes)
+ all_ingredient_ids = await _collect_recipe_ingredient_ids(recipe_id, db)
+ if not all_ingredient_ids:
+ # Check for manual recipe flags only
+ manual_result = await db.execute(
+ select(RecipeFlag)
+ .options(selectinload(RecipeFlag.food_flag).selectinload(FoodFlag.category))
+ .where(RecipeFlag.recipe_id == recipe_id)
+ )
+ manual_flags = manual_result.scalars().all()
+ return [
+ RecipeFlagState(
+ food_flag_id=rf.food_flag_id,
+ flag_name=rf.food_flag.name if rf.food_flag else "",
+ flag_code=rf.food_flag.code if rf.food_flag else None,
+ flag_icon=rf.food_flag.icon if rf.food_flag else None,
+ category_id=rf.food_flag.category_id if rf.food_flag else 0,
+ category_name=rf.food_flag.category.name if rf.food_flag and rf.food_flag.category else "",
+ propagation_type=rf.food_flag.category.propagation_type if rf.food_flag and rf.food_flag.category else "contains",
+ source_type=rf.source_type,
+ is_active=rf.is_active,
+ excludable_on_request=rf.excludable_on_request,
+ )
+ for rf in manual_flags
+ ]
+
+ unique_ingredient_ids = list(set(all_ingredient_ids))
+
+ # Get all ingredient flags for these ingredients
+ if_result = await db.execute(
+ select(IngredientFlag)
+ .options(selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category))
+ .where(IngredientFlag.ingredient_id.in_(unique_ingredient_ids))
+ )
+ ingredient_flags = if_result.scalars().all()
+
+ # Get all food flag categories for this kitchen
+ cat_result = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == kitchen_id)
+ )
+ categories = cat_result.scalars().all()
+
+ # Build ingredient -> flags mapping
+ ing_flag_map: dict[int, set[int]] = {}
+ for ifl in ingredient_flags:
+ ing_flag_map.setdefault(ifl.ingredient_id, set()).add(ifl.food_flag_id)
+
+ # Build flag_id -> ingredient_names mapping (for source tracing)
+ flag_source_names: dict[int, list[str]] = {}
+ # Load ingredient names
+ ing_names_result = await db.execute(
+ select(Ingredient.id, Ingredient.name).where(Ingredient.id.in_(unique_ingredient_ids))
+ )
+ ing_names = {r[0]: r[1] for r in ing_names_result.fetchall()}
+
+ for ifl in ingredient_flags:
+ flag_source_names.setdefault(ifl.food_flag_id, []).append(ing_names.get(ifl.ingredient_id, "?"))
+
+ # Compute propagated flags
+ computed_flags: dict[int, RecipeFlagState] = {}
+
+ for cat in categories:
+ if cat.propagation_type == "contains":
+ # Union: recipe has flag if ANY ingredient has it
+ for flag in cat.flags:
+ for ing_id in unique_ingredient_ids:
+ if flag.id in ing_flag_map.get(ing_id, set()):
+ computed_flags[flag.id] = RecipeFlagState(
+ food_flag_id=flag.id,
+ flag_name=flag.name,
+ flag_code=flag.code,
+ flag_icon=flag.icon,
+ category_id=cat.id,
+ category_name=cat.name,
+ propagation_type="contains",
+ source_type="auto",
+ is_active=True,
+ source_ingredients=flag_source_names.get(flag.id, []),
+ )
+ break
+
+ elif cat.propagation_type == "suitable_for":
+ # Intersection: recipe has flag only if ALL ingredients have it
+ for flag in cat.flags:
+ all_have = True
+ for ing_id in unique_ingredient_ids:
+ ing_flags = ing_flag_map.get(ing_id, set())
+ # Check if ingredient has ANY flags in this category (if not, it's unassessed)
+ cat_flag_ids = {f.id for f in cat.flags}
+ has_any_in_cat = bool(ing_flags & cat_flag_ids)
+ if not has_any_in_cat or flag.id not in ing_flags:
+ all_have = False
+ break
+ if all_have:
+ computed_flags[flag.id] = RecipeFlagState(
+ food_flag_id=flag.id,
+ flag_name=flag.name,
+ flag_code=flag.code,
+ flag_icon=flag.icon,
+ category_id=cat.id,
+ category_name=cat.name,
+ propagation_type="suitable_for",
+ source_type="auto",
+ is_active=True,
+ source_ingredients=[ing_names.get(i, "?") for i in unique_ingredient_ids],
+ )
+
+ # Merge with manual recipe flags and apply overrides
+ rf_result = await db.execute(
+ select(RecipeFlag)
+ .options(selectinload(RecipeFlag.food_flag).selectinload(FoodFlag.category))
+ .where(RecipeFlag.recipe_id == recipe_id)
+ )
+ recipe_flags = rf_result.scalars().all()
+
+ for rf in recipe_flags:
+ if rf.source_type == "manual" and rf.food_flag_id not in computed_flags:
+ computed_flags[rf.food_flag_id] = RecipeFlagState(
+ food_flag_id=rf.food_flag_id,
+ flag_name=rf.food_flag.name if rf.food_flag else "",
+ flag_code=rf.food_flag.code if rf.food_flag else None,
+ flag_icon=rf.food_flag.icon if rf.food_flag else None,
+ category_id=rf.food_flag.category_id if rf.food_flag else 0,
+ category_name=rf.food_flag.category.name if rf.food_flag and rf.food_flag.category else "",
+ propagation_type=rf.food_flag.category.propagation_type if rf.food_flag and rf.food_flag.category else "contains",
+ source_type="manual",
+ is_active=rf.is_active,
+ excludable_on_request=rf.excludable_on_request,
+ )
+ elif rf.food_flag_id in computed_flags:
+ # Apply overrides from recipe_flags to computed flags
+ computed_flags[rf.food_flag_id].is_active = rf.is_active
+ computed_flags[rf.food_flag_id].excludable_on_request = rf.excludable_on_request
+ if rf.source_type == "manual":
+ computed_flags[rf.food_flag_id].source_type = "manual"
+
+ return list(computed_flags.values())
+
+
+# ── Recipe Flag endpoints ────────────────────────────────────────────────────
+
+@router.get("/recipes/{recipe_id}/flags")
+async def get_recipe_flags(
+ recipe_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Full flag state with source tracing and unassessed ingredient list."""
+ # Verify recipe
+ r = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not r.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db)
+
+ # Find unassessed ingredients — per required category evaluation
+ all_ing_ids = await _collect_recipe_ingredient_ids(recipe_id, db)
+ unique_ids = list(set(all_ing_ids))
+
+ # Get required categories with their flag IDs
+ req_cat_result = await db.execute(
+ select(FoodFlagCategory.id, FoodFlagCategory.name).where(
+ FoodFlagCategory.kitchen_id == user.kitchen_id,
+ FoodFlagCategory.required == True,
+ )
+ )
+ required_cats = req_cat_result.all()
+
+ # Build map: category_id → set of flag_ids
+ cat_flag_map: dict[int, set[int]] = {}
+ for cat_id, _ in required_cats:
+ rf_result = await db.execute(
+ select(FoodFlag.id).where(FoodFlag.category_id == cat_id)
+ )
+ cat_flag_map[cat_id] = set(rf_result.scalars().all())
+
+ unassessed = []
+ if unique_ids and required_cats:
+ for ing_id in unique_ids:
+ ing_result = await db.execute(
+ select(Ingredient.name).where(Ingredient.id == ing_id)
+ )
+ name = ing_result.scalar()
+ if not name:
+ continue
+
+ # Get "None" entries for this ingredient
+ none_result = await db.execute(
+ select(IngredientFlagNone.category_id).where(
+ IngredientFlagNone.ingredient_id == ing_id,
+ )
+ )
+ none_cat_ids = set(none_result.scalars().all())
+
+ # Check each required category separately
+ for cat_id, cat_name in required_cats:
+ if cat_id in none_cat_ids:
+ continue # "None" selected for this category
+ flag_ids = cat_flag_map.get(cat_id, set())
+ if not flag_ids:
+ continue
+ flag_count = await db.execute(
+ select(func.count(IngredientFlag.id)).where(
+ IngredientFlag.ingredient_id == ing_id,
+ IngredientFlag.food_flag_id.in_(flag_ids),
+ )
+ )
+ if flag_count.scalar() == 0:
+ unassessed.append({"id": ing_id, "name": name, "category": cat_name})
+ break # One missing category is enough to flag the ingredient
+
+ # Find ingredients with open (undismissed, unapplied) allergen suggestions
+ open_suggestion_ings = []
+ if unique_ids:
+ # Load allergen keywords once
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ all_keywords = kw_result.scalars().all()
+
+ if all_keywords:
+ for ing_id in unique_ids:
+ # Get ingredient name + product_ingredients
+ ing_result = await db.execute(
+ select(Ingredient.name, Ingredient.product_ingredients).where(Ingredient.id == ing_id)
+ )
+ ing_row = ing_result.first()
+ if not ing_row:
+ continue
+
+ # Match keywords against name + product ingredients
+ texts_to_check = [ing_row.name or ""]
+ if ing_row.product_ingredients:
+ texts_to_check.append(ing_row.product_ingredients)
+ combined_text = " ".join(texts_to_check)
+
+ keyword_matches = match_allergen_keywords(combined_text, all_keywords)
+ if not keyword_matches:
+ continue
+
+ matched_flag_ids = set(m["flag_id"] for m in keyword_matches)
+
+ # Subtract active flags
+ active_result = await db.execute(
+ select(IngredientFlag.food_flag_id).where(
+ IngredientFlag.ingredient_id == ing_id,
+ )
+ )
+ active_ids = set(active_result.scalars().all())
+ matched_flag_ids -= active_ids
+
+ # Subtract dismissed flags
+ dismissed_result = await db.execute(
+ select(IngredientFlagDismissal.food_flag_id).where(
+ IngredientFlagDismissal.ingredient_id == ing_id,
+ )
+ )
+ dismissed_ids = set(dismissed_result.scalars().all())
+ matched_flag_ids -= dismissed_ids
+
+ if matched_flag_ids:
+ open_suggestion_ings.append({
+ "ingredient_id": ing_id,
+ "ingredient_name": ing_row.name,
+ "suggestion_count": len(matched_flag_ids),
+ })
+
+ # ── Recipe text keyword scanning ──────────────────────────────────
+ recipe_text_suggestions = []
+ # Load recipe details for text scanning
+ recipe_result = await db.execute(
+ select(Recipe.name, Recipe.description, Recipe.notes).where(Recipe.id == recipe_id)
+ )
+ recipe_row = recipe_result.first()
+
+ # Reuse all_keywords if already loaded above, otherwise load now
+ if not unique_ids or not all_keywords:
+ kw_result2 = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ all_keywords = kw_result2.scalars().all()
+
+ if recipe_row and all_keywords:
+ # Collect text sources: (text_value, source_label)
+ text_sources: list[tuple[str, str]] = []
+ if recipe_row.name:
+ text_sources.append((recipe_row.name, "recipe name"))
+ if recipe_row.description:
+ text_sources.append((recipe_row.description, "description"))
+ if recipe_row.notes:
+ text_sources.append((recipe_row.notes, "notes"))
+
+ # Also scan ingredient notes
+ ing_notes_result = await db.execute(
+ select(RecipeIngredient.notes, Ingredient.name)
+ .join(Ingredient, Ingredient.id == RecipeIngredient.ingredient_id)
+ .where(
+ RecipeIngredient.recipe_id == recipe_id,
+ RecipeIngredient.notes.isnot(None),
+ RecipeIngredient.notes != "",
+ )
+ )
+ for note_text, ing_name in ing_notes_result.all():
+ text_sources.append((note_text, f"ingredient note: {ing_name}"))
+
+ # Match keywords against each text source
+ text_flag_matches: dict[int, dict] = {}
+ for src_text, src_label in text_sources:
+ matches = match_allergen_keywords(src_text, all_keywords)
+ for m in matches:
+ fid = m["flag_id"]
+ if fid not in text_flag_matches:
+ text_flag_matches[fid] = {
+ "flag_id": fid,
+ "flag_name": m["flag_name"],
+ "flag_code": m.get("flag_code"),
+ "category_name": m["category_name"],
+ "matched_keywords": [],
+ "sources": [],
+ }
+ for kw in m["matched_keywords"]:
+ entry = f"{kw} ({src_label})"
+ if entry not in text_flag_matches[fid]["matched_keywords"]:
+ text_flag_matches[fid]["matched_keywords"].append(entry)
+ if src_label not in text_flag_matches[fid]["sources"]:
+ text_flag_matches[fid]["sources"].append(src_label)
+
+ if text_flag_matches:
+ # Subtract flags already in computed recipe flags
+ computed_flag_ids = set(f.food_flag_id for f in flags)
+ for fid in list(text_flag_matches.keys()):
+ if fid in computed_flag_ids:
+ del text_flag_matches[fid]
+
+ # Subtract dismissed flags
+ dismissed_result = await db.execute(
+ select(RecipeTextFlagDismissal.food_flag_id).where(
+ RecipeTextFlagDismissal.recipe_id == recipe_id,
+ )
+ )
+ dismissed_ids = set(dismissed_result.scalars().all())
+ for fid in list(text_flag_matches.keys()):
+ if fid in dismissed_ids:
+ del text_flag_matches[fid]
+
+ recipe_text_suggestions = list(text_flag_matches.values())
+
+ # LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+ # Optionally run LLM analysis on recipe text for contextual allergen detection
+ llm_recipe_suggestions = []
+ try:
+ from services.llm_service import analyse_product_label
+
+ # Concatenate all text sources for LLM
+ all_text_parts = []
+ if recipe_row:
+ if recipe_row.name:
+ all_text_parts.append(f"Recipe: {recipe_row.name}")
+ if recipe_row.description:
+ all_text_parts.append(f"Description: {recipe_row.description}")
+ if recipe_row.notes:
+ all_text_parts.append(f"Notes: {recipe_row.notes}")
+ combined_text = "\n".join(all_text_parts)
+
+ if combined_text and len(combined_text.strip()) >= 10:
+ # Build flag categories for LLM
+ cat_result_llm = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == user.kitchen_id)
+ )
+ llm_categories = cat_result_llm.scalars().all()
+ flag_categories_for_llm = [
+ {
+ "category_name": c.name,
+ "propagation_type": c.propagation_type,
+ "flags": [{"id": f.id, "name": f.name, "code": f.code} for f in c.flags],
+ }
+ for c in llm_categories
+ ]
+
+ llm_result = await analyse_product_label(db, user.kitchen_id, combined_text, flag_categories_for_llm)
+
+ if llm_result["status"] in ("success", "cached") and llm_result.get("suggestions"):
+ # Merge LLM suggestions — only add flags not already in keyword results or computed flags
+ computed_flag_ids = set(f.food_flag_id for f in flags)
+ keyword_flag_ids = set(s["flag_id"] for s in recipe_text_suggestions)
+ dismissed_result2 = await db.execute(
+ select(RecipeTextFlagDismissal.food_flag_id).where(
+ RecipeTextFlagDismissal.recipe_id == recipe_id,
+ )
+ )
+ dismissed_ids2 = set(dismissed_result2.scalars().all())
+
+ for s in llm_result["suggestions"]:
+ fid = s["flag_id"]
+ if fid not in computed_flag_ids and fid not in keyword_flag_ids and fid not in dismissed_ids2:
+ llm_recipe_suggestions.append(s)
+ except Exception as e:
+ logger.warning(f"LLM recipe text scanning failed (non-fatal): {e}")
+
+ return {
+ "flags": [f.model_dump() for f in flags],
+ "unassessed_ingredients": unassessed,
+ "open_suggestion_ingredients": open_suggestion_ings,
+ "recipe_text_suggestions": recipe_text_suggestions,
+ "llm_recipe_suggestions": llm_recipe_suggestions, # LLM FEATURE
+ }
+
+
+@router.post("/recipes/{recipe_id}/flags/{flag_id}/deactivate")
+async def deactivate_recipe_flag(
+ recipe_id: int,
+ flag_id: int,
+ data: OverrideRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Override a flag off (requires note, creates audit log)."""
+ # Upsert recipe_flag
+ rf_result = await db.execute(
+ select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id)
+ )
+ rf = rf_result.scalar_one_or_none()
+ if rf:
+ rf.is_active = False
+ else:
+ rf = RecipeFlag(recipe_id=recipe_id, food_flag_id=flag_id, source_type="auto", is_active=False)
+ db.add(rf)
+
+ # Audit log
+ db.add(RecipeFlagOverride(
+ recipe_id=recipe_id, food_flag_id=flag_id,
+ action="deactivated", note=data.note, user_id=user.id,
+ ))
+ await db.commit()
+ return {"ok": True}
+
+
+@router.post("/recipes/{recipe_id}/flags/{flag_id}/reactivate")
+async def reactivate_recipe_flag(
+ recipe_id: int,
+ flag_id: int,
+ data: OverrideRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ rf_result = await db.execute(
+ select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id)
+ )
+ rf = rf_result.scalar_one_or_none()
+ if rf:
+ rf.is_active = True
+
+ db.add(RecipeFlagOverride(
+ recipe_id=recipe_id, food_flag_id=flag_id,
+ action="reactivated", note=data.note, user_id=user.id,
+ ))
+ await db.commit()
+ return {"ok": True}
+
+
+@router.patch("/recipes/{recipe_id}/flags/{flag_id}")
+async def toggle_excludable(
+ recipe_id: int,
+ flag_id: int,
+ data: ExcludableToggle,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Toggle excludable_on_request for a dish recipe flag."""
+ rf_result = await db.execute(
+ select(RecipeFlag).where(RecipeFlag.recipe_id == recipe_id, RecipeFlag.food_flag_id == flag_id)
+ )
+ rf = rf_result.scalar_one_or_none()
+ if rf:
+ new_state = not rf.excludable_on_request
+ rf.excludable_on_request = new_state
+ action = "set_excludable" if new_state else "unset_excludable"
+ else:
+ rf = RecipeFlag(
+ recipe_id=recipe_id, food_flag_id=flag_id,
+ source_type="auto", is_active=True, excludable_on_request=True,
+ )
+ db.add(rf)
+ action = "set_excludable"
+
+ db.add(RecipeFlagOverride(
+ recipe_id=recipe_id, food_flag_id=flag_id,
+ action=action, note=data.note, user_id=user.id,
+ ))
+ await db.commit()
+ return {"ok": True}
+
+
+@router.post("/recipes/{recipe_id}/flags/manual")
+async def add_manual_flag(
+ recipe_id: int,
+ data: ManualFlagAdd,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Manually add a flag to a recipe."""
+ existing = await db.execute(
+ select(RecipeFlag).where(
+ RecipeFlag.recipe_id == recipe_id,
+ RecipeFlag.food_flag_id == data.food_flag_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(409, "Flag already exists on this recipe")
+
+ db.add(RecipeFlag(
+ recipe_id=recipe_id, food_flag_id=data.food_flag_id,
+ source_type="manual", is_active=True,
+ ))
+ await db.commit()
+ return {"ok": True}
+
+
+@router.get("/recipes/{recipe_id}/flags/audit-log")
+async def get_flag_audit_log(
+ recipe_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(RecipeFlagOverride)
+ .options(
+ selectinload(RecipeFlagOverride.food_flag),
+ selectinload(RecipeFlagOverride.user),
+ )
+ .where(RecipeFlagOverride.recipe_id == recipe_id)
+ .order_by(RecipeFlagOverride.created_at.desc())
+ )
+ overrides = result.scalars().all()
+ return [
+ RecipeFlagOverrideLog(
+ id=o.id,
+ food_flag_id=o.food_flag_id,
+ flag_name=o.food_flag.name if o.food_flag else "",
+ action=o.action,
+ note=o.note,
+ username=o.user.username if o.user else "",
+ created_at=str(o.created_at) if o.created_at else "",
+ )
+ for o in overrides
+ ]
+
+
+@router.get("/recipes/{recipe_id}/flags/matrix")
+async def get_flag_matrix(
+ recipe_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Full ingredient × flag matrix data for the flag breakdown table."""
+ # Verify recipe
+ r_result = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ recipe = r_result.scalar_one_or_none()
+ if not recipe:
+ raise HTTPException(404, "Recipe not found")
+
+ # Get all flag categories and flags
+ cats = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == user.kitchen_id)
+ .order_by(FoodFlagCategory.sort_order)
+ )
+ categories = cats.scalars().all()
+
+ all_flags = []
+ required_cat_ids = set()
+ for cat in categories:
+ if cat.required:
+ required_cat_ids.add(cat.id)
+ for f in sorted(cat.flags, key=lambda x: x.sort_order):
+ all_flags.append({
+ "id": f.id, "name": f.name, "code": f.code,
+ "category_id": cat.id, "category_name": cat.name,
+ "propagation_type": cat.propagation_type,
+ "required": cat.required,
+ })
+
+ matrix_rows = []
+
+ # Direct recipe ingredients
+ ri_result = await db.execute(
+ select(RecipeIngredient)
+ .options(selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.flags))
+ .where(RecipeIngredient.recipe_id == recipe_id)
+ .order_by(RecipeIngredient.sort_order)
+ )
+ direct_ris = ri_result.scalars().all()
+
+ # Sub-recipe ingredients
+ sr_result = await db.execute(
+ select(RecipeSubRecipe)
+ .options(selectinload(RecipeSubRecipe.child_recipe))
+ .where(RecipeSubRecipe.parent_recipe_id == recipe_id)
+ .order_by(RecipeSubRecipe.sort_order)
+ )
+ sub_recipes = sr_result.scalars().all()
+
+ # Fetch child recipe ingredients for each sub-recipe
+ sub_recipe_ingredients = {} # child_id -> (child_recipe, [RecipeIngredient...])
+ for sr in sub_recipes:
+ child = sr.child_recipe
+ if not child:
+ continue
+ cri_result = await db.execute(
+ select(RecipeIngredient)
+ .options(selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.flags))
+ .where(RecipeIngredient.recipe_id == child.id)
+ .order_by(RecipeIngredient.sort_order)
+ )
+ sub_recipe_ingredients[child.id] = (child, cri_result.scalars().all())
+
+ # Collect all ingredient IDs from direct + sub-recipe ingredients
+ all_ingredient_ids = set()
+ for ri in direct_ris:
+ if ri.ingredient:
+ all_ingredient_ids.add(ri.ingredient.id)
+ for child_id, (child, cris) in sub_recipe_ingredients.items():
+ for cri in cris:
+ if cri.ingredient:
+ all_ingredient_ids.add(cri.ingredient.id)
+
+ # Batch-fetch all "None apply" records so they count as assessed
+ ingredient_nones = {}
+ if all_ingredient_ids:
+ none_result = await db.execute(
+ select(IngredientFlagNone.ingredient_id, IngredientFlagNone.category_id)
+ .where(IngredientFlagNone.ingredient_id.in_(list(all_ingredient_ids)))
+ )
+ for ing_id, cat_id in none_result.all():
+ if ing_id not in ingredient_nones:
+ ingredient_nones[ing_id] = set()
+ ingredient_nones[ing_id].add(cat_id)
+
+ # Compute open suggestion flag IDs per ingredient
+ ingredient_open_suggestions: dict[int, set[int]] = {} # ing_id -> set of flag_ids with open suggestions
+ if all_ingredient_ids:
+ # Load allergen keywords
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ all_keywords = kw_result.scalars().all()
+
+ if all_keywords:
+ # Batch-fetch dismissed flag IDs per ingredient
+ ingredient_dismissed: dict[int, set[int]] = {}
+ dismiss_result = await db.execute(
+ select(IngredientFlagDismissal.ingredient_id, IngredientFlagDismissal.food_flag_id)
+ .where(IngredientFlagDismissal.ingredient_id.in_(list(all_ingredient_ids)))
+ )
+ for ing_id, flag_id in dismiss_result.all():
+ if ing_id not in ingredient_dismissed:
+ ingredient_dismissed[ing_id] = set()
+ ingredient_dismissed[ing_id].add(flag_id)
+
+ # Batch-fetch ingredient names and product_ingredients
+ ing_data_result = await db.execute(
+ select(Ingredient.id, Ingredient.name, Ingredient.product_ingredients)
+ .where(Ingredient.id.in_(list(all_ingredient_ids)))
+ )
+ for ing_id, ing_name, prod_ing in ing_data_result.all():
+ texts = [ing_name or ""]
+ if prod_ing:
+ texts.append(prod_ing)
+ combined = " ".join(texts)
+ matches = match_allergen_keywords(combined, all_keywords)
+ if matches:
+ matched_flag_ids = set(m["flag_id"] for m in matches)
+ # Subtract active flags (from the ingredient's loaded flags)
+ # We'll do this per-row below since we already have ing_flag_ids there
+ # For now, subtract dismissed
+ matched_flag_ids -= ingredient_dismissed.get(ing_id, set())
+ if matched_flag_ids:
+ ingredient_open_suggestions[ing_id] = matched_flag_ids
+
+ # Build matrix rows for direct ingredients
+ for ri in direct_ris:
+ ing = ri.ingredient
+ if not ing:
+ continue
+ ing_flag_ids = {f.food_flag_id for f in (ing.flags or [])}
+ # Check which categories this ingredient has ANY flags in
+ assessed_cats = set()
+ for f in (ing.flags or []):
+ for cat in categories:
+ if f.food_flag_id in {cf.id for cf in cat.flags}:
+ assessed_cats.add(cat.id)
+ # Also count "None apply" categories as assessed
+ assessed_cats |= ingredient_nones.get(ing.id, set())
+
+ none_cats = ingredient_nones.get(ing.id, set())
+ open_sugg = ingredient_open_suggestions.get(ing.id, set()) - ing_flag_ids
+ flags_map = {}
+ for flag_info in all_flags:
+ fid = flag_info["id"]
+ cat_id = flag_info["category_id"]
+ # Non-required categories: never show as unassessed
+ is_unassessed = cat_id not in assessed_cats and cat_id in required_cat_ids
+ flags_map[fid] = MatrixCell(
+ has_flag=fid in ing_flag_ids,
+ is_unassessed=is_unassessed,
+ is_none=cat_id in none_cats,
+ has_open_suggestion=fid in open_sugg,
+ ).model_dump()
+
+ matrix_rows.append(MatrixIngredient(
+ ingredient_id=ing.id, ingredient_name=ing.name,
+ flags=flags_map,
+ ).model_dump())
+
+ # Build matrix rows for sub-recipe ingredients
+ for sr in sub_recipes:
+ child = sr.child_recipe
+ if not child or child.id not in sub_recipe_ingredients:
+ continue
+ _, cris = sub_recipe_ingredients[child.id]
+ for cri in cris:
+ cing = cri.ingredient
+ if not cing:
+ continue
+ ing_flag_ids = {f.food_flag_id for f in (cing.flags or [])}
+ assessed_cats = set()
+ for f in (cing.flags or []):
+ for cat in categories:
+ if f.food_flag_id in {cf.id for cf in cat.flags}:
+ assessed_cats.add(cat.id)
+ # Also count "None apply" categories as assessed
+ assessed_cats |= ingredient_nones.get(cing.id, set())
+
+ none_cats = ingredient_nones.get(cing.id, set())
+ open_sugg = ingredient_open_suggestions.get(cing.id, set()) - ing_flag_ids
+ flags_map = {}
+ for flag_info in all_flags:
+ fid = flag_info["id"]
+ cat_id = flag_info["category_id"]
+ is_unassessed = cat_id not in assessed_cats and cat_id in required_cat_ids
+ flags_map[fid] = MatrixCell(
+ has_flag=fid in ing_flag_ids,
+ is_unassessed=is_unassessed,
+ is_none=cat_id in none_cats,
+ has_open_suggestion=fid in open_sugg,
+ ).model_dump()
+
+ matrix_rows.append(MatrixIngredient(
+ ingredient_id=cing.id, ingredient_name=cing.name,
+ is_sub_recipe=True, sub_recipe_name=child.name,
+ flags=flags_map,
+ ).model_dump())
+
+ return {"flags": all_flags, "ingredients": matrix_rows}
+
+
+class MatrixBulkItem(BaseModel):
+ ingredient_id: int
+ food_flag_id: int
+ has_flag: bool
+
+
+class MatrixBulkUpdate(BaseModel):
+ updates: list[MatrixBulkItem]
+
+
+@router.put("/recipes/{recipe_id}/flags/matrix")
+async def update_flag_matrix(
+ recipe_id: int,
+ data: MatrixBulkUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Bulk update ingredient flags from the recipe flag matrix view."""
+ # Verify recipe belongs to kitchen
+ r_result = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not r_result.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ updated = 0
+ for item in data.updates:
+ # Verify ingredient belongs to this kitchen
+ ing_result = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == item.ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ ingredient = ing_result.scalar_one_or_none()
+ if not ingredient:
+ continue
+
+ # Verify flag belongs to this kitchen
+ flag_result = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.id == item.food_flag_id,
+ FoodFlag.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not flag_result.scalar_one_or_none():
+ continue
+
+ # Check if flag assignment exists
+ existing = await db.execute(
+ select(IngredientFlag).where(
+ IngredientFlag.ingredient_id == item.ingredient_id,
+ IngredientFlag.food_flag_id == item.food_flag_id,
+ )
+ )
+ flag_row = existing.scalar_one_or_none()
+
+ if item.has_flag and not flag_row:
+ # Add flag — also remove any "None apply" for this flag's category
+ flag_obj = flag_result.scalar_one_or_none() if not flag_result else None
+ # Re-fetch to get category_id
+ flag_detail = await db.execute(
+ select(FoodFlag).where(FoodFlag.id == item.food_flag_id)
+ )
+ flag_detail_obj = flag_detail.scalar_one_or_none()
+ if flag_detail_obj:
+ await db.execute(
+ delete(IngredientFlagNone).where(
+ IngredientFlagNone.ingredient_id == item.ingredient_id,
+ IngredientFlagNone.category_id == flag_detail_obj.category_id,
+ )
+ )
+ db.add(IngredientFlag(
+ ingredient_id=item.ingredient_id,
+ food_flag_id=item.food_flag_id,
+ flagged_by=user.id,
+ source="manual",
+ ))
+ updated += 1
+ elif not item.has_flag and flag_row:
+ # Remove flag (only manual ones; latched flags stay)
+ if flag_row.source == "manual":
+ await db.delete(flag_row)
+ updated += 1
+
+ if updated > 0:
+ # Bump recipe updated_at so menu staleness detection picks up flag changes
+ r2 = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
+ recipe_obj = r2.scalar_one_or_none()
+ if recipe_obj:
+ recipe_obj.updated_at = datetime.utcnow()
+ await db.commit()
+ return {"ok": True, "updated": updated}
+
+
+class MatrixNoneToggle(BaseModel):
+ ingredient_id: int
+ category_id: int
+
+
+@router.post("/recipes/{recipe_id}/flags/matrix/none")
+async def toggle_matrix_none(
+ recipe_id: int,
+ data: MatrixNoneToggle,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Toggle 'None apply' for a category on an ingredient, from the recipe matrix."""
+ # Verify recipe belongs to kitchen
+ r_result = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not r_result.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ # Check if already set
+ existing = await db.execute(
+ select(IngredientFlagNone).where(
+ IngredientFlagNone.ingredient_id == data.ingredient_id,
+ IngredientFlagNone.category_id == data.category_id,
+ )
+ )
+ none_row = existing.scalar_one_or_none()
+
+ # Helper to bump recipe updated_at for staleness detection
+ async def _bump_recipe():
+ r2 = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
+ robj = r2.scalar_one_or_none()
+ if robj:
+ robj.updated_at = datetime.utcnow()
+
+ if none_row:
+ # Toggle OFF
+ await db.delete(none_row)
+ await _bump_recipe()
+ await db.commit()
+ return {"ok": True, "is_none": False}
+ else:
+ # Toggle ON — remove any flags in this category first
+ cat_flag_ids = await db.execute(
+ select(FoodFlag.id).where(
+ FoodFlag.category_id == data.category_id,
+ FoodFlag.kitchen_id == user.kitchen_id,
+ )
+ )
+ flag_ids = [r for r in cat_flag_ids.scalars().all()]
+ if flag_ids:
+ await db.execute(
+ delete(IngredientFlag).where(
+ IngredientFlag.ingredient_id == data.ingredient_id,
+ IngredientFlag.food_flag_id.in_(flag_ids),
+ )
+ )
+ db.add(IngredientFlagNone(
+ ingredient_id=data.ingredient_id,
+ category_id=data.category_id,
+ ))
+ await _bump_recipe()
+ await db.commit()
+ return {"ok": True, "is_none": True}
+
+
+# ── Recipe text flag dismissals ──────────────────────────────────────────────
+
+class RecipeTextDismissalCreate(BaseModel):
+ food_flag_id: int
+ dismissed_by_name: str
+ reason: Optional[str] = None
+ matched_keyword: Optional[str] = None
+
+class RecipeTextDismissalResponse(BaseModel):
+ id: int
+ recipe_id: int
+ food_flag_id: int
+ dismissed_by_name: str
+ reason: Optional[str] = None
+ matched_keyword: Optional[str] = None
+ created_at: str = ""
+ class Config:
+ from_attributes = True
+
+
+@router.get("/recipes/{recipe_id}/text-dismissals")
+async def get_recipe_text_dismissals(
+ recipe_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get all dismissed recipe text allergen suggestions."""
+ r = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not r.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ result = await db.execute(
+ select(RecipeTextFlagDismissal)
+ .where(RecipeTextFlagDismissal.recipe_id == recipe_id)
+ .order_by(RecipeTextFlagDismissal.created_at.desc())
+ )
+ dismissals = result.scalars().all()
+ return [
+ RecipeTextDismissalResponse(
+ id=d.id,
+ recipe_id=d.recipe_id,
+ food_flag_id=d.food_flag_id,
+ dismissed_by_name=d.dismissed_by_name,
+ reason=d.reason,
+ matched_keyword=d.matched_keyword,
+ created_at=str(d.created_at) if d.created_at else "",
+ )
+ for d in dismissals
+ ]
+
+
+@router.post("/recipes/{recipe_id}/text-dismissals")
+async def create_recipe_text_dismissal(
+ recipe_id: int,
+ data: RecipeTextDismissalCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Dismiss a recipe text allergen suggestion (upsert)."""
+ r = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not r.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ existing = await db.execute(
+ select(RecipeTextFlagDismissal).where(
+ RecipeTextFlagDismissal.recipe_id == recipe_id,
+ RecipeTextFlagDismissal.food_flag_id == data.food_flag_id,
+ )
+ )
+ dismissal = existing.scalar_one_or_none()
+ if dismissal:
+ dismissal.dismissed_by_name = data.dismissed_by_name
+ dismissal.reason = data.reason
+ dismissal.matched_keyword = data.matched_keyword
+ else:
+ dismissal = RecipeTextFlagDismissal(
+ recipe_id=recipe_id,
+ food_flag_id=data.food_flag_id,
+ dismissed_by_name=data.dismissed_by_name,
+ reason=data.reason,
+ matched_keyword=data.matched_keyword,
+ )
+ db.add(dismissal)
+
+ await db.commit()
+ await db.refresh(dismissal)
+ return RecipeTextDismissalResponse(
+ id=dismissal.id,
+ recipe_id=dismissal.recipe_id,
+ food_flag_id=dismissal.food_flag_id,
+ dismissed_by_name=dismissal.dismissed_by_name,
+ reason=dismissal.reason,
+ matched_keyword=dismissal.matched_keyword,
+ created_at=str(dismissal.created_at) if dismissal.created_at else "",
+ )
+
+
+@router.delete("/recipes/{recipe_id}/text-dismissals/{dismissal_id}")
+async def delete_recipe_text_dismissal(
+ recipe_id: int,
+ dismissal_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Undo a recipe text dismissal (re-enables the suggestion)."""
+ r = await db.execute(
+ select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id)
+ )
+ if not r.scalar_one_or_none():
+ raise HTTPException(404, "Recipe not found")
+
+ result = await db.execute(
+ select(RecipeTextFlagDismissal).where(
+ RecipeTextFlagDismissal.id == dismissal_id,
+ RecipeTextFlagDismissal.recipe_id == recipe_id,
+ )
+ )
+ dismissal = result.scalar_one_or_none()
+ if not dismissal:
+ raise HTTPException(404, "Dismissal not found")
+
+ await db.delete(dismissal)
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Shared allergen keyword matching ─────────────────────────────────────────
+
+def match_allergen_keywords(text: str, keywords: list) -> list[dict]:
+ """Match text against allergen keywords using word boundary matching.
+ Allows optional plural suffixes (s, es, 's) so 'almond' matches 'almonds' etc."""
+ text_lower = text.lower()
+ matches: dict[int, dict] = {}
+ for kw in keywords:
+ # Word boundary + optional plural/possessive suffix to catch almonds, walnuts, etc.
+ pattern = r'\b' + re.escape(kw.keyword) + r"(?:'?e?s)?\b"
+ if re.search(pattern, text_lower):
+ fid = kw.food_flag_id
+ if fid not in matches:
+ flag = kw.food_flag
+ matches[fid] = {
+ "flag_id": fid,
+ "flag_name": flag.name if flag else "",
+ "flag_code": flag.code if flag else None,
+ "category_name": flag.category.name if flag and flag.category else "",
+ "matched_keywords": [],
+ }
+ matches[fid]["matched_keywords"].append(kw.keyword)
+ return list(matches.values())
+
+
+# ── Allergen keyword suggestion ──────────────────────────────────────────────
+
+@router.get("/suggest")
+async def suggest_allergens(
+ name: str = Query("", description="Ingredient name to check"),
+ text: str = Query("", description="Product ingredients text to check"),
+ line_item: str = Query("", description="Line item description to check"),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Suggest allergen flags based on ingredient name, line item description, and/or product ingredients text."""
+ if not any(len(s.strip()) >= 2 for s in [name, text, line_item]):
+ return []
+
+ result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ keywords = result.scalars().all()
+
+ # Match each source separately and annotate keywords with their origin
+ sources = [
+ (name, "name"),
+ (line_item, "line item"),
+ (text, "ingredients"),
+ ]
+ merged: dict[int, dict] = {}
+ for input_text, source_label in sources:
+ if not input_text or len(input_text.strip()) < 2:
+ continue
+ matches = match_allergen_keywords(input_text, keywords)
+ for m in matches:
+ fid = m["flag_id"]
+ if fid not in merged:
+ merged[fid] = {
+ "flag_id": m["flag_id"],
+ "flag_name": m["flag_name"],
+ "flag_code": m["flag_code"],
+ "category_name": m["category_name"],
+ "matched_keywords": [],
+ }
+ for kw in m["matched_keywords"]:
+ merged[fid]["matched_keywords"].append(f"{kw} ({source_label})")
+
+ return list(merged.values())
+
+
+@router.get("/suggest/bulk")
+async def suggest_allergens_bulk(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Return allergen keyword suggestions for ALL ingredients in the kitchen.
+ Single DB query for keywords, then match against each ingredient's name + product_ingredients.
+ Returns: { ingredient_id: [ { flag_id, flag_name, flag_code, category_name, matched_keywords } ] }
+ """
+ # Load all keywords once
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ keywords = kw_result.scalars().all()
+ if not keywords:
+ return {}
+
+ # Load all non-archived ingredients
+ ing_result = await db.execute(
+ select(Ingredient.id, Ingredient.name, Ingredient.product_ingredients)
+ .where(Ingredient.kitchen_id == user.kitchen_id, Ingredient.is_archived == False)
+ )
+ ingredients = ing_result.all()
+
+ result: dict[int, list[dict]] = {}
+ for ing_id, ing_name, product_ingredients in ingredients:
+ merged: dict[int, dict] = {}
+ for input_text, source_label in [(ing_name, "name"), (product_ingredients, "ingredients")]:
+ if not input_text or len(input_text.strip()) < 2:
+ continue
+ matches = match_allergen_keywords(input_text, keywords)
+ for m in matches:
+ fid = m["flag_id"]
+ if fid not in merged:
+ merged[fid] = {
+ "flag_id": m["flag_id"],
+ "flag_name": m["flag_name"],
+ "flag_code": m["flag_code"],
+ "category_name": m["category_name"],
+ "matched_keywords": [],
+ }
+ for kw in m["matched_keywords"]:
+ merged[fid]["matched_keywords"].append(f"{kw} ({source_label})")
+ if merged:
+ result[ing_id] = list(merged.values())
+
+ return result
+
+
+# ── LLM Label Analysis ──────────────────────────────────────────────────────
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+
+
+class AnalyseLabelRequest(BaseModel):
+ ingredients_text: str | None = None
+ ingredient_id: int | None = None
+
+
+@router.post("/analyse-label")
+async def analyse_label(
+ body: AnalyseLabelRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Analyse product ingredient text or recipe text for allergens using LLM.
+ Returns suggestions in the same format as keyword matching, with added status and source fields.
+ Falls back to keyword matching if LLM is unavailable.
+ """
+ # Get the text to analyse
+ text = body.ingredients_text
+ if not text and body.ingredient_id:
+ result = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == body.ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ ingredient = result.scalar_one_or_none()
+ if ingredient and ingredient.product_ingredients:
+ text = ingredient.product_ingredients
+
+ if not text or len(text.strip()) < 3:
+ return {"llm_status": "unavailable", "suggestions": [], "keyword_suggestions": []}
+
+ # Always run keyword matching as baseline
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ keywords = kw_result.scalars().all()
+ keyword_suggestions = match_allergen_keywords(text, keywords)
+
+ # Build flag categories for LLM
+ cat_result = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == user.kitchen_id)
+ )
+ categories = cat_result.scalars().all()
+
+ flag_categories = []
+ for cat in categories:
+ flag_categories.append({
+ "category_name": cat.name,
+ "propagation_type": cat.propagation_type,
+ "flags": [{"id": f.id, "name": f.name, "code": f.code} for f in cat.flags],
+ })
+
+ # Call LLM analysis
+ from services.llm_service import analyse_product_label
+ llm_result = await analyse_product_label(db, user.kitchen_id, text, flag_categories)
+
+ return {
+ "llm_status": llm_result["status"],
+ "suggestions": llm_result.get("suggestions") or [],
+ "keyword_suggestions": keyword_suggestions,
+ "error": llm_result.get("error"),
+ }
+
+
+# ── Allergen keyword CRUD (Settings) ────────────────────────────────────────
+
+class KeywordCreate(BaseModel):
+ food_flag_id: int
+ keyword: str
+
+@router.get("/keywords")
+async def list_keywords(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Return all allergen keywords grouped by flag."""
+ result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ .order_by(AllergenKeyword.food_flag_id, AllergenKeyword.keyword)
+ )
+ keywords = result.scalars().all()
+
+ groups: dict[int, dict] = {}
+ for kw in keywords:
+ fid = kw.food_flag_id
+ if fid not in groups:
+ flag = kw.food_flag
+ groups[fid] = {
+ "flag_id": fid,
+ "flag_name": flag.name if flag else "",
+ "flag_code": flag.code if flag else None,
+ "category_name": flag.category.name if flag and flag.category else "",
+ "keywords": [],
+ }
+ groups[fid]["keywords"].append({
+ "id": kw.id,
+ "keyword": kw.keyword,
+ "is_default": kw.is_default,
+ })
+ return list(groups.values())
+
+
+@router.post("/keywords")
+async def add_keyword(
+ data: KeywordCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Add a custom allergen keyword."""
+ # Verify flag belongs to this kitchen
+ flag = await db.get(FoodFlag, data.food_flag_id)
+ if not flag or flag.kitchen_id != user.kitchen_id:
+ raise HTTPException(404, "Flag not found")
+
+ kw = AllergenKeyword(
+ kitchen_id=user.kitchen_id,
+ food_flag_id=data.food_flag_id,
+ keyword=data.keyword.lower().strip(),
+ is_default=False,
+ )
+ db.add(kw)
+ try:
+ await db.commit()
+ await db.refresh(kw)
+ except Exception:
+ await db.rollback()
+ raise HTTPException(409, "Keyword already exists for this flag")
+ return {"id": kw.id, "keyword": kw.keyword, "is_default": False}
+
+
+@router.delete("/keywords/{keyword_id}")
+async def delete_keyword(
+ keyword_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Remove an allergen keyword."""
+ kw = await db.get(AllergenKeyword, keyword_id)
+ if not kw or kw.kitchen_id != user.kitchen_id:
+ raise HTTPException(404, "Keyword not found")
+ await db.delete(kw)
+ await db.commit()
+ return {"ok": True}
+
+
+@router.post("/keywords/reset-defaults")
+async def reset_default_keywords(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete all default keywords and re-seed from built-in dictionary. Preserves user-added keywords."""
+ from migrations.add_allergen_keywords import ALLERGEN_KEYWORDS
+ from sqlalchemy import text as sql_text
+
+ # Delete existing defaults
+ await db.execute(
+ delete(AllergenKeyword).where(
+ AllergenKeyword.kitchen_id == user.kitchen_id,
+ AllergenKeyword.is_default == True,
+ )
+ )
+ await db.flush()
+
+ # Re-seed defaults
+ seeded = 0
+ for flag_name, keywords in ALLERGEN_KEYWORDS.items():
+ flag_result = await db.execute(
+ select(FoodFlag).where(
+ FoodFlag.kitchen_id == user.kitchen_id,
+ FoodFlag.name == flag_name,
+ )
+ )
+ flag = flag_result.scalar_one_or_none()
+ if not flag:
+ continue
+ for kw_str in keywords:
+ kw = AllergenKeyword(
+ kitchen_id=user.kitchen_id,
+ food_flag_id=flag.id,
+ keyword=kw_str.lower(),
+ is_default=True,
+ )
+ db.add(kw)
+ seeded += 1
+
+ try:
+ await db.commit()
+ except Exception:
+ await db.rollback()
+ raise HTTPException(500, "Failed to re-seed keywords")
+
+ return {"ok": True, "seeded": seeded}
+
+
+# ── Label OCR scanning ──────────────────────────────────────────────────────
+
+@router.post("/scan-label")
+async def scan_label(
+ file: UploadFile = File(...),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """OCR a product ingredient label image and suggest allergen flags.
+ For create mode (ingredient doesn't exist yet) — returns raw text + suggestions.
+ """
+ # Validate file type
+ allowed = {"image/jpeg", "image/png", "image/webp", "image/heic"}
+ if file.content_type not in allowed:
+ raise HTTPException(400, f"Unsupported file type: {file.content_type}")
+
+ # Get Azure credentials
+ settings_result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == user.kitchen_id)
+ )
+ settings = settings_result.scalar_one_or_none()
+ if not settings or not settings.azure_endpoint or not settings.azure_key:
+ raise HTTPException(400, "Azure Document Intelligence not configured. Set it up in Settings.")
+
+ # Read file content
+ image_bytes = await file.read()
+
+ # OCR with Azure prebuilt-read
+ try:
+ from azure.ai.formrecognizer import DocumentAnalysisClient
+ from azure.core.credentials import AzureKeyCredential
+
+ client = DocumentAnalysisClient(settings.azure_endpoint, AzureKeyCredential(settings.azure_key))
+ poller = client.begin_analyze_document("prebuilt-read", document=image_bytes)
+ result = poller.result()
+ raw_text = " ".join([line.content for page in result.pages for line in page.lines])
+ except Exception as e:
+ logger.error(f"Azure OCR failed: {e}")
+ raise HTTPException(500, f"OCR failed: {str(e)}")
+
+ # Match keywords
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ keywords = kw_result.scalars().all()
+ suggestions = match_allergen_keywords(raw_text, keywords)
+
+ return {
+ "raw_text": raw_text,
+ "suggested_flags": suggestions,
+ }
+
+
+@router.post("/scan-label/{ingredient_id}")
+async def scan_label_for_ingredient(
+ ingredient_id: int,
+ file: UploadFile = File(...),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """OCR a product ingredient label, save the image, and suggest allergen flags.
+ For edit mode (ingredient exists) — saves label image to disk.
+ """
+ # Verify ingredient
+ ing = await db.get(Ingredient, ingredient_id)
+ if not ing or ing.kitchen_id != user.kitchen_id:
+ raise HTTPException(404, "Ingredient not found")
+
+ # Validate file type
+ allowed = {"image/jpeg", "image/png", "image/webp", "image/heic"}
+ if file.content_type not in allowed:
+ raise HTTPException(400, f"Unsupported file type: {file.content_type}")
+
+ # Get Azure credentials
+ settings_result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == user.kitchen_id)
+ )
+ settings = settings_result.scalar_one_or_none()
+ if not settings or not settings.azure_endpoint or not settings.azure_key:
+ raise HTTPException(400, "Azure Document Intelligence not configured. Set it up in Settings.")
+
+ # Read file content
+ image_bytes = await file.read()
+
+ # Save label image
+ ext = file.filename.rsplit(".", 1)[-1] if file.filename and "." in file.filename else "jpg"
+ label_dir = f"/app/data/{user.kitchen_id}/labels"
+ os.makedirs(label_dir, exist_ok=True)
+ filename = f"{uuid.uuid4()}.{ext}"
+ filepath = os.path.join(label_dir, filename)
+ async with aiofiles.open(filepath, "wb") as f:
+ await f.write(image_bytes)
+
+ # Update ingredient
+ ing.label_image_path = filepath
+ await db.flush()
+
+ # OCR with Azure prebuilt-read
+ try:
+ from azure.ai.formrecognizer import DocumentAnalysisClient
+ from azure.core.credentials import AzureKeyCredential
+
+ client = DocumentAnalysisClient(settings.azure_endpoint, AzureKeyCredential(settings.azure_key))
+ poller = client.begin_analyze_document("prebuilt-read", document=image_bytes)
+ result = poller.result()
+ raw_text = " ".join([line.content for page in result.pages for line in page.lines])
+ except Exception as e:
+ logger.error(f"Azure OCR failed: {e}")
+ await db.commit() # Still save the image even if OCR fails
+ raise HTTPException(500, f"OCR failed: {str(e)}")
+
+ # Update product_ingredients
+ ing.product_ingredients = raw_text
+ await db.commit()
+
+ # Match keywords
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == user.kitchen_id)
+ )
+ keywords = kw_result.scalars().all()
+ suggestions = match_allergen_keywords(raw_text, keywords)
+
+ return {
+ "raw_text": raw_text,
+ "suggested_flags": suggestions,
+ "label_saved": True,
+ }
+
+
+# ── Brakes product lookup ─────────────────────────────────────────────────
+
+@router.get("/brakes-lookup")
+async def brakes_lookup(
+ product_code: str = Query(..., description="Brakes product code"),
+ force: bool = Query(False, description="Bypass cache and re-fetch from website"),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Look up a Brakes product by code, returning ingredients + allergen suggestions.
+ Uses a cache table to avoid repeated requests to brake.co.uk.
+ """
+ import json
+ from datetime import datetime, timedelta
+ from services.brakes_scraper import fetch_brakes_product
+
+ clean_code = product_code.lstrip("$").strip()
+ if not clean_code:
+ raise HTTPException(400, "Product code required")
+
+ # Check cache
+ cache_result = await db.execute(
+ select(BrakesProductCache).where(BrakesProductCache.product_code == clean_code)
+ )
+ cached = cache_result.scalar_one_or_none()
+
+ now = datetime.utcnow()
+ cache_ttl = timedelta(days=30)
+ not_found_ttl = timedelta(days=7)
+
+ if cached and not force:
+ age = now - cached.fetched_at
+ if cached.not_found and age < not_found_ttl:
+ return {"found": False, "product_code": clean_code, "suggested_flags": [], "none_category_ids": []}
+ if not cached.not_found and age < cache_ttl:
+ # Serve from cache — build suggestions
+ contains = json.loads(cached.contains_allergens) if cached.contains_allergens else []
+ dietary = json.loads(cached.dietary_info) if cached.dietary_info else []
+ suggestions, none_cat_ids = await _build_brakes_suggestions(
+ db, user.kitchen_id, contains, cached.ingredients_text or "", dietary
+ )
+ return {
+ "found": True,
+ "product_code": clean_code,
+ "product_name": cached.product_name or "",
+ "ingredients_text": cached.ingredients_text or "",
+ "contains_allergens": contains,
+ "suitable_for": dietary,
+ "suggested_flags": suggestions,
+ "none_category_ids": none_cat_ids,
+ }
+
+ # Cache miss or stale — fetch from website
+ product = await fetch_brakes_product(clean_code)
+
+ if product is None or (not product.ingredients_text and not product.product_name):
+ # 404 or empty page — cache as not_found
+ if cached:
+ cached.not_found = True
+ cached.fetched_at = now
+ else:
+ db.add(BrakesProductCache(
+ product_code=clean_code,
+ not_found=True,
+ fetched_at=now,
+ ))
+ await db.commit()
+ return {"found": False, "product_code": clean_code, "suggested_flags": [], "none_category_ids": []}
+
+ # Store in cache
+ contains_json = json.dumps(product.contains_allergens)
+ dietary_json = json.dumps(product.suitable_for)
+ if cached:
+ cached.product_name = product.product_name
+ cached.ingredients_text = product.ingredients_text
+ cached.contains_allergens = contains_json
+ cached.dietary_info = dietary_json
+ cached.not_found = False
+ cached.fetched_at = now
+ else:
+ db.add(BrakesProductCache(
+ product_code=clean_code,
+ product_name=product.product_name,
+ ingredients_text=product.ingredients_text,
+ contains_allergens=contains_json,
+ dietary_info=dietary_json,
+ not_found=False,
+ fetched_at=now,
+ ))
+ await db.commit()
+
+ # Build suggestions
+ suggestions, none_cat_ids = await _build_brakes_suggestions(
+ db, user.kitchen_id, product.contains_allergens, product.ingredients_text, product.suitable_for
+ )
+
+ return {
+ "found": True,
+ "product_code": clean_code,
+ "product_name": product.product_name,
+ "ingredients_text": product.ingredients_text,
+ "contains_allergens": product.contains_allergens,
+ "suitable_for": product.suitable_for,
+ "suggested_flags": suggestions,
+ "none_category_ids": none_cat_ids,
+ }
+
+
+async def _build_brakes_suggestions(
+ db: AsyncSession,
+ kitchen_id: int,
+ contains_allergens: list[str],
+ ingredients_text: str,
+ suitable_for: list[str] | None = None,
+) -> list[dict]:
+ """Build allergen flag suggestions from Brakes 'Contains' statement + keyword matching + dietary suitability."""
+ # Get all flags for this kitchen
+ flags_result = await db.execute(
+ select(FoodFlag)
+ .options(selectinload(FoodFlag.category))
+ .where(FoodFlag.kitchen_id == kitchen_id)
+ )
+ all_flags = flags_result.scalars().all()
+ flag_by_name = {f.name.lower(): f for f in all_flags}
+
+ suggestions: dict[int, dict] = {}
+
+ def _find_flag(name: str):
+ """Match flag by exact name, then try singular/plural variants."""
+ n = name.lower().strip()
+ if n in flag_by_name:
+ return flag_by_name[n]
+ # Try removing trailing 's' (Eggs -> Egg, Crustaceans -> Crustacean)
+ if n.endswith("s") and n[:-1] in flag_by_name:
+ return flag_by_name[n[:-1]]
+ # Try adding 's' (Egg -> Eggs, Peanut -> Peanuts)
+ if f"{n}s" in flag_by_name:
+ return flag_by_name[f"{n}s"]
+ # Try 'es' removal (Mollusces -> Mollusc)
+ if n.endswith("es") and n[:-2] in flag_by_name:
+ return flag_by_name[n[:-2]]
+ return None
+
+ # 1. Direct match from "Contains" statement (high confidence)
+ for allergen_name in contains_allergens:
+ flag = _find_flag(allergen_name)
+ if flag:
+ suggestions[flag.id] = {
+ "flag_id": flag.id,
+ "flag_name": flag.name,
+ "flag_code": flag.code,
+ "category_name": flag.category.name if flag.category else "",
+ "source": "contains",
+ "matched_keywords": [],
+ }
+
+ # 1b. Dietary suitability — "Suitable for Vegetarians" / "Suitable for Vegans"
+ if suitable_for:
+ for diet in suitable_for:
+ flag = _find_flag(diet)
+ if flag and flag.id not in suggestions:
+ suggestions[flag.id] = {
+ "flag_id": flag.id,
+ "flag_name": flag.name,
+ "flag_code": flag.code,
+ "category_name": flag.category.name if flag.category else "",
+ "source": "dietary",
+ "matched_keywords": [],
+ }
+
+ # 2. Keyword matching against full ingredients text (catches extras)
+ if ingredients_text:
+ kw_result = await db.execute(
+ select(AllergenKeyword)
+ .options(selectinload(AllergenKeyword.food_flag).selectinload(FoodFlag.category))
+ .where(AllergenKeyword.kitchen_id == kitchen_id)
+ )
+ keywords = kw_result.scalars().all()
+ keyword_matches = match_allergen_keywords(ingredients_text, keywords)
+
+ for km in keyword_matches:
+ fid = km["flag_id"]
+ if fid not in suggestions:
+ km["source"] = "keyword"
+ suggestions[fid] = km
+ else:
+ # Already matched via "contains" — append keyword info
+ suggestions[fid]["matched_keywords"] = km.get("matched_keywords", [])
+
+ # 3. Determine none_category_ids: when Brakes says "Contains: None of the 14 Food Allergens"
+ # (empty contains_allergens but non-empty ingredients_text = product found with no allergens)
+ none_category_ids = []
+ if not contains_allergens and ingredients_text:
+ cat_result = await db.execute(
+ select(FoodFlagCategory.id).where(
+ FoodFlagCategory.kitchen_id == kitchen_id,
+ FoodFlagCategory.propagation_type == "contains",
+ )
+ )
+ none_category_ids = [r for r in cat_result.scalars().all()]
+
+ return list(suggestions.values()), none_category_ids
diff --git a/backend/api/imap.py b/backend/api/imap.py
new file mode 100644
index 0000000..b2863bb
--- /dev/null
+++ b/backend/api/imap.py
@@ -0,0 +1,341 @@
+"""
+IMAP Email Inbox API endpoints for settings and sync control.
+"""
+import logging
+from decimal import Decimal
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select, desc
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from auth import get_current_user, require_cap
+from database import get_db
+from models.user import User
+from models.settings import KitchenSettings
+from models.email_processing import EmailProcessingLog
+from services.imap_sync import ImapSyncService
+
+router = APIRouter(prefix="/imap", tags=["IMAP"])
+logger = logging.getLogger(__name__)
+
+
+# ============ Pydantic Schemas ============
+
+class ImapSettingsResponse(BaseModel):
+ imap_host: Optional[str]
+ imap_port: Optional[int]
+ imap_use_ssl: bool
+ imap_username: Optional[str]
+ imap_password_set: bool # Don't expose actual password
+ imap_folder: Optional[str]
+ imap_poll_interval: int
+ imap_enabled: bool
+ imap_confidence_threshold: Optional[float]
+ imap_last_sync: Optional[str]
+
+
+class ImapSettingsUpdate(BaseModel):
+ imap_host: Optional[str] = None
+ imap_port: Optional[int] = None
+ imap_use_ssl: Optional[bool] = None
+ imap_username: Optional[str] = None
+ imap_password: Optional[str] = None # Only set if provided
+ imap_folder: Optional[str] = None
+ imap_poll_interval: Optional[int] = None
+ imap_enabled: Optional[bool] = None
+ imap_confidence_threshold: Optional[float] = None
+
+
+class ImapTestRequest(BaseModel):
+ imap_host: Optional[str] = None
+ imap_port: Optional[int] = None
+ imap_use_ssl: Optional[bool] = None
+ imap_username: Optional[str] = None
+ imap_password: Optional[str] = None
+
+
+class EmailLogResponse(BaseModel):
+ id: int
+ message_id: str
+ email_subject: Optional[str]
+ email_from: Optional[str]
+ email_date: Optional[str]
+ attachments_count: int
+ invoices_created: int
+ confident_invoices: int
+ marked_as_read: bool
+ processing_status: str
+ error_message: Optional[str]
+ invoice_ids: Optional[list[int]]
+ processed_at: str
+
+
+# ============ Settings Endpoints ============
+
+@router.get("/settings", response_model=ImapSettingsResponse)
+async def get_imap_settings(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get IMAP settings (password masked)"""
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ return ImapSettingsResponse(
+ imap_host=settings.imap_host,
+ imap_port=settings.imap_port,
+ imap_use_ssl=settings.imap_use_ssl,
+ imap_username=settings.imap_username,
+ imap_password_set=bool(settings.imap_password),
+ imap_folder=settings.imap_folder,
+ imap_poll_interval=settings.imap_poll_interval,
+ imap_enabled=settings.imap_enabled,
+ imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None,
+ imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None
+ )
+
+
+@router.patch("/settings", response_model=ImapSettingsResponse)
+async def update_imap_settings(
+ update: ImapSettingsUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update IMAP settings (admin only)"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin access required")
+
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ # Update only provided fields
+ if update.imap_host is not None:
+ settings.imap_host = update.imap_host
+ if update.imap_port is not None:
+ settings.imap_port = update.imap_port
+ if update.imap_use_ssl is not None:
+ settings.imap_use_ssl = update.imap_use_ssl
+ if update.imap_username is not None:
+ settings.imap_username = update.imap_username
+ if update.imap_password is not None and update.imap_password:
+ settings.imap_password = update.imap_password
+ if update.imap_folder is not None:
+ settings.imap_folder = update.imap_folder
+ if update.imap_poll_interval is not None:
+ settings.imap_poll_interval = update.imap_poll_interval
+ if update.imap_enabled is not None:
+ settings.imap_enabled = update.imap_enabled
+ if update.imap_confidence_threshold is not None:
+ settings.imap_confidence_threshold = Decimal(str(update.imap_confidence_threshold))
+
+ await db.commit()
+
+ return ImapSettingsResponse(
+ imap_host=settings.imap_host,
+ imap_port=settings.imap_port,
+ imap_use_ssl=settings.imap_use_ssl,
+ imap_username=settings.imap_username,
+ imap_password_set=bool(settings.imap_password),
+ imap_folder=settings.imap_folder,
+ imap_poll_interval=settings.imap_poll_interval,
+ imap_enabled=settings.imap_enabled,
+ imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None,
+ imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None
+ )
+
+
+@router.post("/test-connection")
+async def test_imap_connection(
+ request: ImapTestRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Test IMAP connection with provided or saved settings"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin access required")
+
+ # Get current settings
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ # Use provided values or fall back to saved settings
+ test_settings = KitchenSettings(
+ kitchen_id=current_user.kitchen_id,
+ imap_host=request.imap_host or settings.imap_host,
+ imap_port=request.imap_port or settings.imap_port or 993,
+ imap_use_ssl=request.imap_use_ssl if request.imap_use_ssl is not None else settings.imap_use_ssl,
+ imap_username=request.imap_username or settings.imap_username,
+ imap_password=request.imap_password or settings.imap_password
+ )
+
+ if not test_settings.imap_host or not test_settings.imap_password:
+ return {"success": False, "error": "IMAP host and password are required"}
+
+ # Test connection
+ sync_service = ImapSyncService(current_user.kitchen_id, db)
+ sync_service._settings = test_settings
+ result = await sync_service.test_connection()
+
+ return result
+
+
+@router.post("/sync-now")
+async def trigger_manual_sync(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Manually trigger inbox sync (admin only)"""
+ if not current_user.is_admin:
+ raise HTTPException(status_code=403, detail="Admin access required")
+
+ # Get settings
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ if not settings.imap_host or not settings.imap_password:
+ raise HTTPException(status_code=400, detail="IMAP settings not configured")
+
+ # Run sync
+ try:
+ sync_service = ImapSyncService(current_user.kitchen_id, db)
+ results = await sync_service.process_inbox()
+ return {
+ "success": True,
+ "results": results
+ }
+ except Exception as e:
+ logger.error(f"Manual IMAP sync failed: {e}")
+ return {
+ "success": False,
+ "error": str(e)
+ }
+
+
+# ============ Log Endpoints ============
+
+@router.get("/logs", response_model=list[EmailLogResponse])
+async def get_processing_logs(
+ limit: int = 50,
+ offset: int = 0,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get recent email processing logs"""
+ result = await db.execute(
+ select(EmailProcessingLog)
+ .where(EmailProcessingLog.kitchen_id == current_user.kitchen_id)
+ .order_by(desc(EmailProcessingLog.processed_at))
+ .offset(offset)
+ .limit(limit)
+ )
+ logs = result.scalars().all()
+
+ return [
+ EmailLogResponse(
+ id=log.id,
+ message_id=log.message_id,
+ email_subject=log.email_subject,
+ email_from=log.email_from,
+ email_date=log.email_date.isoformat() if log.email_date else None,
+ attachments_count=log.attachments_count,
+ invoices_created=log.invoices_created,
+ confident_invoices=log.confident_invoices,
+ marked_as_read=log.marked_as_read,
+ processing_status=log.processing_status,
+ error_message=log.error_message,
+ invoice_ids=log.invoice_ids,
+ processed_at=log.processed_at.isoformat()
+ )
+ for log in logs
+ ]
+
+
+@router.get("/logs/stats")
+async def get_sync_stats(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get sync statistics"""
+ from sqlalchemy import func
+ from datetime import datetime, timedelta
+
+ now = datetime.utcnow()
+
+ # Last 24 hours
+ result_24h = await db.execute(
+ select(
+ func.count(EmailProcessingLog.id),
+ func.sum(EmailProcessingLog.invoices_created),
+ func.sum(EmailProcessingLog.confident_invoices)
+ ).where(
+ EmailProcessingLog.kitchen_id == current_user.kitchen_id,
+ EmailProcessingLog.processed_at >= now - timedelta(hours=24)
+ )
+ )
+ stats_24h = result_24h.one()
+
+ # Last 7 days
+ result_7d = await db.execute(
+ select(
+ func.count(EmailProcessingLog.id),
+ func.sum(EmailProcessingLog.invoices_created),
+ func.sum(EmailProcessingLog.confident_invoices)
+ ).where(
+ EmailProcessingLog.kitchen_id == current_user.kitchen_id,
+ EmailProcessingLog.processed_at >= now - timedelta(days=7)
+ )
+ )
+ stats_7d = result_7d.one()
+
+ # Last 30 days
+ result_30d = await db.execute(
+ select(
+ func.count(EmailProcessingLog.id),
+ func.sum(EmailProcessingLog.invoices_created),
+ func.sum(EmailProcessingLog.confident_invoices)
+ ).where(
+ EmailProcessingLog.kitchen_id == current_user.kitchen_id,
+ EmailProcessingLog.processed_at >= now - timedelta(days=30)
+ )
+ )
+ stats_30d = result_30d.one()
+
+ return {
+ "last_24h": {
+ "emails_processed": stats_24h[0] or 0,
+ "invoices_created": int(stats_24h[1] or 0),
+ "confident_invoices": int(stats_24h[2] or 0)
+ },
+ "last_7d": {
+ "emails_processed": stats_7d[0] or 0,
+ "invoices_created": int(stats_7d[1] or 0),
+ "confident_invoices": int(stats_7d[2] or 0)
+ },
+ "last_30d": {
+ "emails_processed": stats_30d[0] or 0,
+ "invoices_created": int(stats_30d[1] or 0),
+ "confident_invoices": int(stats_30d[2] or 0)
+ }
+ }
diff --git a/backend/api/ingredients.py b/backend/api/ingredients.py
new file mode 100644
index 0000000..2982884
--- /dev/null
+++ b/backend/api/ingredients.py
@@ -0,0 +1,2013 @@
+"""
+Ingredient Library API — categories, ingredients, sources, auto-price, duplicate detection.
+"""
+import logging
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, text, and_, or_, delete, update
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import selectinload
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.ingredient import Ingredient, IngredientCategory, IngredientSource, IngredientFlag, IngredientFlagNone, IngredientFlagDismissal
+from models.food_flag import FoodFlag, FoodFlagCategory
+from models.line_item import LineItem
+from models.invoice import Invoice
+from models.supplier import Supplier
+from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe
+from auth import get_current_user, require_cap
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+async def _bump_recipes_using_ingredient(ingredient_id: int, db: AsyncSession):
+ """Bump updated_at on all recipes (and their parents) that use this ingredient,
+ so menu staleness detection picks up allergen flag changes."""
+ ri_result = await db.execute(
+ select(RecipeIngredient.recipe_id).where(RecipeIngredient.ingredient_id == ingredient_id)
+ )
+ recipe_ids = set(r[0] for r in ri_result.fetchall())
+ # Also parent recipes (1 level up)
+ for rid in list(recipe_ids):
+ parent_result = await db.execute(
+ select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == rid)
+ )
+ for (parent_id,) in parent_result.fetchall():
+ recipe_ids.add(parent_id)
+ if recipe_ids:
+ now = datetime.utcnow()
+ for rid in recipe_ids:
+ r = await db.execute(select(Recipe).where(Recipe.id == rid))
+ recipe = r.scalar_one_or_none()
+ if recipe:
+ recipe.updated_at = now
+ await db.commit()
+
+
+# ── Unit conversion constants ────────────────────────────────────────────────
+
+UNIT_CONVERSIONS = {
+ "g": {"g": 1, "kg": 0.001},
+ "kg": {"g": 1000, "kg": 1},
+ "oz": {"g": 28.3495, "kg": 0.0283495},
+ "lb": {"g": 453.592, "kg": 0.453592},
+ "ml": {"ml": 1, "ltr": 0.001},
+ "cl": {"ml": 10, "ltr": 0.01},
+ "ltr": {"ml": 1000, "ltr": 1},
+ "each": {"each": 1},
+}
+
+
+def convert_to_standard(value: Decimal, from_unit: str, standard_unit: str) -> Optional[Decimal]:
+ """Convert a value from from_unit to standard_unit. Returns None if incompatible."""
+ from_unit = from_unit.lower().strip()
+ standard_unit = standard_unit.lower().strip()
+ if from_unit == standard_unit:
+ return value
+ conversions = UNIT_CONVERSIONS.get(from_unit, {})
+ factor = conversions.get(standard_unit)
+ if factor is None:
+ return None
+ return value * Decimal(str(factor))
+
+
+def calc_price_per_std_unit(
+ unit_price: Decimal,
+ pack_quantity: Optional[int],
+ unit_size: Optional[Decimal],
+ unit_size_type: Optional[str],
+ standard_unit: str,
+ line_item_unit: Optional[str] = None,
+) -> Optional[Decimal]:
+ """Calculate price per standard unit from source pack data.
+
+ If line_item_unit is a recognised weight/volume unit (kg, g, ml, ltr etc.)
+ that can convert to standard_unit, use direct conversion instead of pack
+ formula. This handles cases where the same SKU is sold as a box OR loose
+ by weight — e.g. carrots at £0.80/kg vs £7.98/10kg box.
+ """
+ if not unit_price:
+ return None
+
+ # If the line item unit is a measurable weight/volume, convert directly
+ if line_item_unit:
+ li_unit = line_item_unit.lower().strip()
+ if li_unit in UNIT_CONVERSIONS:
+ direct = convert_to_standard(Decimal("1"), li_unit, standard_unit)
+ if direct and direct > 0:
+ return unit_price / direct
+
+ # Fall back to pack formula
+ if not pack_quantity or not unit_size or not unit_size_type:
+ return None
+ total_in_source_unit = Decimal(str(pack_quantity)) * unit_size
+ total_in_std = convert_to_standard(total_in_source_unit, unit_size_type, standard_unit)
+ if not total_in_std or total_in_std == 0:
+ return None
+ return unit_price / total_in_std
+
+
+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())
+
+
+# ── Pydantic schemas ─────────────────────────────────────────────────────────
+
+class CategoryCreate(BaseModel):
+ name: str
+ sort_order: int = 0
+
+class CategoryUpdate(BaseModel):
+ name: Optional[str] = None
+ sort_order: Optional[int] = None
+
+class CategoryResponse(BaseModel):
+ id: int
+ name: str
+ sort_order: int
+ ingredient_count: int = 0
+ class Config:
+ from_attributes = True
+
+class IngredientCreate(BaseModel):
+ name: str
+ category_id: Optional[int] = None
+ standard_unit: str = "g"
+ yield_percent: float = 100.0
+ manual_price: Optional[float] = None
+ notes: Optional[str] = None
+ is_prepackaged: bool = False
+ is_free: bool = False
+ product_ingredients: Optional[str] = None
+
+class IngredientUpdate(BaseModel):
+ name: Optional[str] = None
+ category_id: Optional[int] = None
+ standard_unit: Optional[str] = None
+ yield_percent: Optional[float] = None
+ manual_price: Optional[float] = None
+ notes: Optional[str] = None
+ is_archived: Optional[bool] = None
+ is_prepackaged: Optional[bool] = None
+ is_free: Optional[bool] = None
+ product_ingredients: Optional[str] = None
+
+class SourceCreate(BaseModel):
+ supplier_id: int
+ 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
+ latest_unit_price: Optional[float] = None
+ invoice_id: Optional[int] = None
+ apply_to_existing: bool = False # Bulk-set ingredient_id on matching line items
+
+class SourceUpdate(BaseModel):
+ 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
+
+class SourceResponse(BaseModel):
+ id: int
+ supplier_id: int
+ supplier_name: str = ""
+ product_code: Optional[str] = None
+ description_pattern: Optional[str] = None
+ description_aliases: list[str] = []
+ pack_quantity: Optional[int] = None
+ unit_size: Optional[float] = None
+ unit_size_type: Optional[str] = None
+ latest_unit_price: Optional[float] = None
+ latest_invoice_date: Optional[date] = None
+ price_per_std_unit: Optional[float] = None
+ matched_line_items: Optional[int] = None # Count of line items bulk-updated
+ backfilled_count: Optional[int] = None # Count of line items with product codes backfilled
+
+ class Config:
+ from_attributes = True
+
+class FlagResponse(BaseModel):
+ id: int
+ food_flag_id: int
+ flag_name: str = ""
+ flag_code: Optional[str] = None
+ category_name: str = ""
+ propagation_type: str = "contains"
+ source: str = "manual"
+
+class IngredientResponse(BaseModel):
+ id: int
+ name: str
+ category_id: Optional[int] = None
+ category_name: Optional[str] = None
+ standard_unit: str
+ yield_percent: float
+ manual_price: Optional[float] = None
+ notes: Optional[str] = None
+ is_archived: bool = False
+ flags_assessed: bool = False
+ is_prepackaged: bool = False
+ is_free: bool = False
+ product_ingredients: Optional[str] = None
+ has_label_image: bool = False
+ source_count: int = 0
+ effective_price: Optional[float] = None
+ flags: list[FlagResponse] = []
+ none_categories: list[str] = []
+ created_at: str = ""
+
+ class Config:
+ from_attributes = True
+
+class SimilarIngredient(BaseModel):
+ id: int
+ name: str
+ similarity: float
+
+class SuggestResponse(BaseModel):
+ suggestions: list[SimilarIngredient]
+
+
+# ── Category endpoints ───────────────────────────────────────────────────────
+
+DEFAULT_INGREDIENT_CATEGORIES = [
+ ("Dairy", 0), ("Meat", 1), ("Seafood", 2), ("Produce", 3),
+ ("Dry Goods", 4), ("Canned & Jarred", 5), ("Frozen Goods", 6),
+ ("Oils & Fats", 7), ("Herbs & Spices", 7), ("Bakery", 8),
+ ("Beverages", 9), ("Condiments", 11), ("Other", 12),
+]
+
+
+@router.post("/categories/seed-defaults")
+async def seed_default_categories(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Seed default ingredient categories. Skips any that already exist by name."""
+ kid = user.kitchen_id
+ created = 0
+ for name, sort_order in DEFAULT_INGREDIENT_CATEGORIES:
+ exists = await db.execute(
+ select(IngredientCategory).where(
+ IngredientCategory.kitchen_id == kid,
+ IngredientCategory.name == name,
+ )
+ )
+ if exists.scalar_one_or_none():
+ continue
+ db.add(IngredientCategory(kitchen_id=kid, name=name, sort_order=sort_order))
+ created += 1
+ await db.commit()
+ return {"ok": True, "created": created}
+
+
+@router.get("/categories")
+async def list_categories(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ # Count ingredients per category
+ count_sub = (
+ select(Ingredient.category_id, func.count(Ingredient.id).label("cnt"))
+ .where(Ingredient.kitchen_id == user.kitchen_id)
+ .group_by(Ingredient.category_id)
+ .subquery()
+ )
+ result = await db.execute(
+ select(IngredientCategory, func.coalesce(count_sub.c.cnt, 0).label("ingredient_count"))
+ .outerjoin(count_sub, IngredientCategory.id == count_sub.c.category_id)
+ .where(IngredientCategory.kitchen_id == user.kitchen_id)
+ .order_by(IngredientCategory.sort_order, IngredientCategory.name)
+ )
+ rows = result.all()
+ return [
+ CategoryResponse(id=cat.id, name=cat.name, sort_order=cat.sort_order, ingredient_count=cnt)
+ for cat, cnt in rows
+ ]
+
+
+@router.post("/categories")
+async def create_category(
+ data: CategoryCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ cat = IngredientCategory(kitchen_id=user.kitchen_id, name=data.name, sort_order=data.sort_order)
+ db.add(cat)
+ await db.commit()
+ await db.refresh(cat)
+ return CategoryResponse(id=cat.id, name=cat.name, sort_order=cat.sort_order)
+
+
+@router.patch("/categories/{category_id}")
+async def update_category(
+ category_id: int,
+ data: CategoryUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(IngredientCategory).where(
+ IngredientCategory.id == category_id,
+ IngredientCategory.kitchen_id == user.kitchen_id,
+ )
+ )
+ cat = result.scalar_one_or_none()
+ if not cat:
+ raise HTTPException(404, "Category not found")
+ if data.name is not None:
+ cat.name = data.name
+ if data.sort_order is not None:
+ cat.sort_order = data.sort_order
+ await db.commit()
+ return CategoryResponse(id=cat.id, name=cat.name, sort_order=cat.sort_order)
+
+
+@router.delete("/categories/{category_id}")
+async def delete_category(
+ category_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(IngredientCategory).where(
+ IngredientCategory.id == category_id,
+ IngredientCategory.kitchen_id == user.kitchen_id,
+ )
+ )
+ cat = result.scalar_one_or_none()
+ if not cat:
+ raise HTTPException(404, "Category not found")
+ # Null out ingredients in this category
+ await db.execute(
+ select(Ingredient).where(Ingredient.category_id == category_id)
+ )
+ from sqlalchemy import update
+ await db.execute(
+ update(Ingredient).where(Ingredient.category_id == category_id).values(category_id=None)
+ )
+ await db.delete(cat)
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Ingredient endpoints ─────────────────────────────────────────────────────
+
+def _build_ingredient_response(ing: Ingredient, source_count: int = 0, flags: list = None) -> IngredientResponse:
+ """Build a response dict from an Ingredient model instance."""
+ # Calculate effective price from most recent source or manual_price
+ # (yield is now per recipe-ingredient use, not per raw ingredient)
+ effective_price = None
+ if ing.sources:
+ priced_sources = [s for s in ing.sources if s.price_per_std_unit is not None]
+ if priced_sources:
+ latest = max(priced_sources, key=lambda s: s.latest_invoice_date or date.min)
+ effective_price = float(latest.price_per_std_unit)
+ if effective_price is None and ing.manual_price:
+ effective_price = float(ing.manual_price)
+
+ flag_list = []
+ if flags:
+ flag_list = flags
+ elif hasattr(ing, 'flags') and ing.flags:
+ flag_list = [
+ FlagResponse(
+ id=f.id,
+ food_flag_id=f.food_flag_id,
+ flag_name=f.food_flag.name if f.food_flag else "",
+ flag_code=f.food_flag.code if f.food_flag else None,
+ category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "",
+ propagation_type=f.food_flag.category.propagation_type if f.food_flag and f.food_flag.category else "contains",
+ source=f.source,
+ )
+ for f in ing.flags
+ ]
+
+ return IngredientResponse(
+ id=ing.id,
+ name=ing.name,
+ category_id=ing.category_id,
+ category_name=ing.category.name if ing.category else None,
+ standard_unit=ing.standard_unit,
+ yield_percent=float(ing.yield_percent) if ing.yield_percent else 100.0,
+ manual_price=float(ing.manual_price) if ing.manual_price else None,
+ notes=ing.notes,
+ is_archived=ing.is_archived,
+ flags_assessed=ing.flags_assessed if hasattr(ing, 'flags_assessed') else False,
+ is_prepackaged=ing.is_prepackaged if hasattr(ing, 'is_prepackaged') else False,
+ is_free=ing.is_free if hasattr(ing, 'is_free') else False,
+ product_ingredients=ing.product_ingredients if hasattr(ing, 'product_ingredients') else None,
+ has_label_image=bool(ing.label_image_path) if hasattr(ing, 'label_image_path') else False,
+ source_count=source_count or (len(ing.sources) if ing.sources else 0),
+ effective_price=round(effective_price, 6) if effective_price else None,
+ flags=flag_list,
+ none_categories=[
+ fn.category.name for fn in ing.flag_nones if fn.category
+ ] if hasattr(ing, 'flag_nones') and ing.flag_nones else [],
+ created_at=str(ing.created_at) if ing.created_at else "",
+ )
+
+
+@router.get("")
+async def list_ingredients(
+ unmapped: bool = Query(False, description="Filter to ingredients with no sources"),
+ archived: bool = Query(False, description="Include archived ingredients"),
+ category_id: Optional[int] = Query(None),
+ search: Optional[str] = Query(None),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ query = (
+ select(Ingredient)
+ .options(
+ selectinload(Ingredient.category),
+ selectinload(Ingredient.sources),
+ selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category),
+ selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category),
+ )
+ .where(Ingredient.kitchen_id == user.kitchen_id)
+ )
+ if archived:
+ query = query.where(Ingredient.is_archived == True)
+ else:
+ query = query.where(Ingredient.is_archived == False)
+ if category_id:
+ query = query.where(Ingredient.category_id == category_id)
+ if search:
+ query = query.where(Ingredient.name.ilike(f"%{search}%"))
+
+ result = await db.execute(query.order_by(Ingredient.name))
+ ingredients = result.scalars().all()
+
+ responses = [_build_ingredient_response(ing) for ing in ingredients]
+
+ if unmapped:
+ responses = [r for r in responses if r.source_count == 0]
+
+ return responses
+
+
+@router.post("/")
+@router.post("")
+async def create_ingredient(
+ data: IngredientCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ # Check for exact duplicate name
+ existing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.kitchen_id == user.kitchen_id,
+ func.lower(Ingredient.name) == data.name.lower().strip(),
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(409, f"Ingredient '{data.name}' already exists")
+
+ ing = Ingredient(
+ kitchen_id=user.kitchen_id,
+ name=data.name.strip(),
+ category_id=data.category_id,
+ standard_unit=data.standard_unit,
+ yield_percent=Decimal(str(data.yield_percent)),
+ manual_price=Decimal(str(data.manual_price)) if data.manual_price else None,
+ notes=data.notes,
+ is_prepackaged=data.is_prepackaged,
+ is_free=data.is_free,
+ product_ingredients=data.product_ingredients,
+ created_by=user.id,
+ )
+ db.add(ing)
+ await db.commit()
+ # Re-query with full eager loading to avoid lazy-load in async context
+ result2 = await db.execute(
+ select(Ingredient)
+ .options(
+ selectinload(Ingredient.category),
+ selectinload(Ingredient.sources),
+ selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category),
+ selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category),
+ )
+ .where(Ingredient.id == ing.id)
+ )
+ ing = result2.scalar_one()
+ return _build_ingredient_response(ing)
+
+
+@router.get("/suggest")
+async def suggest_ingredients(
+ description: str = Query(..., min_length=2),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Suggest existing ingredient matches for a line item description using pg_trgm + ILIKE fallback."""
+ result = await db.execute(
+ text("""
+ SELECT i.id, i.name, i.standard_unit, i.yield_percent,
+ ic.name AS category_name,
+ similarity(i.name, :desc) AS sim
+ FROM ingredients i
+ LEFT JOIN ingredient_categories ic ON ic.id = i.category_id
+ WHERE i.kitchen_id = :kid
+ AND i.is_archived = false
+ AND (similarity(i.name, :desc) > 0.15 OR i.name ILIKE :like)
+ ORDER BY sim DESC
+ LIMIT 8
+ """),
+ {"desc": description, "kid": user.kitchen_id, "like": f"%{description}%"},
+ )
+ rows = result.fetchall()
+ return [
+ {
+ "id": r.id,
+ "name": r.name,
+ "standard_unit": r.standard_unit,
+ "yield_percent": float(r.yield_percent),
+ "category_name": r.category_name,
+ "similarity": round(r.sim, 3),
+ }
+ for r in rows
+ ]
+
+
+@router.get("/check-duplicate")
+async def check_duplicate(
+ name: str = Query(..., min_length=2),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Check for similar ingredient names before creation (pg_trgm)."""
+ result = await db.execute(
+ text("""
+ SELECT id, name, similarity(name, :name) AS sim
+ FROM ingredients
+ WHERE kitchen_id = :kid AND similarity(name, :name) > 0.3
+ ORDER BY sim DESC
+ LIMIT 5
+ """),
+ {"name": name, "kid": user.kitchen_id},
+ )
+ rows = result.fetchall()
+ return [
+ SimilarIngredient(id=r.id, name=r.name, similarity=round(r.sim, 3))
+ for r in rows
+ ]
+
+
+@router.get("/bulk-nones")
+async def get_bulk_nones(
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get all 'None' category entries for all ingredients in the kitchen (for bulk allergen grid)."""
+ result = await db.execute(
+ select(IngredientFlagNone.ingredient_id, IngredientFlagNone.category_id)
+ .join(Ingredient, Ingredient.id == IngredientFlagNone.ingredient_id)
+ .where(Ingredient.kitchen_id == user.kitchen_id)
+ )
+ nones = {}
+ for ing_id, cat_id in result.all():
+ if ing_id not in nones:
+ nones[ing_id] = []
+ nones[ing_id].append(cat_id)
+ return nones
+
+
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+# These must be defined BEFORE /{ingredient_id} to avoid path parameter conflicts
+@router.get("/ai-match")
+async def ai_match_ingredient(
+ description: str = Query(..., min_length=2),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """AI-powered ingredient matching when trigram confidence is low."""
+ from services.llm_service import rank_ingredient_matches
+
+ # First get trigram candidates
+ trgm_result = await db.execute(
+ text("""
+ SELECT i.id, i.name, i.standard_unit,
+ ic.name AS category_name,
+ similarity(i.name, :desc) AS sim
+ FROM ingredients i
+ LEFT JOIN ingredient_categories ic ON ic.id = i.category_id
+ WHERE i.kitchen_id = :kid
+ AND i.is_archived = false
+ AND (similarity(i.name, :desc) > 0.1 OR i.name ILIKE :like)
+ ORDER BY sim DESC
+ LIMIT 20
+ """),
+ {"desc": description, "kid": user.kitchen_id, "like": f"%{description}%"},
+ )
+ candidates = [
+ {"id": r.id, "name": r.name, "standard_unit": r.standard_unit,
+ "category_name": r.category_name, "similarity": round(r.sim, 3)}
+ for r in trgm_result.fetchall()
+ ]
+
+ if not candidates:
+ return {"llm_status": "unavailable", "ranked": [], "error": None}
+
+ result = await rank_ingredient_matches(
+ db=db,
+ kitchen_id=user.kitchen_id,
+ description=description,
+ candidates=candidates,
+ )
+
+ return {
+ "llm_status": result["status"],
+ "ranked": result.get("ranked") or [],
+ "trigram_candidates": candidates,
+ "error": result.get("error"),
+ }
+
+
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+@router.get("/ai-check-duplicate")
+async def ai_check_duplicate(
+ name: str = Query(..., min_length=2),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """AI-powered semantic duplicate detection for new ingredient names."""
+ from services.llm_service import check_duplicate_ingredient_llm
+
+ # Get top trigram matches
+ trgm_result = await db.execute(
+ text("""
+ SELECT id, name, similarity(name, :name) AS sim
+ FROM ingredients
+ WHERE kitchen_id = :kid AND similarity(name, :name) > 0.15
+ ORDER BY sim DESC
+ LIMIT 30
+ """),
+ {"name": name, "kid": user.kitchen_id},
+ )
+ existing = [
+ {"id": r.id, "name": r.name, "similarity": round(r.sim, 3)}
+ for r in trgm_result.fetchall()
+ ]
+
+ if not existing:
+ return {"llm_status": "unavailable", "duplicates": [], "error": None}
+
+ result = await check_duplicate_ingredient_llm(
+ db=db,
+ kitchen_id=user.kitchen_id,
+ name=name,
+ existing_ingredients=existing,
+ )
+
+ return {
+ "llm_status": result["status"],
+ "duplicates": result.get("duplicates") or [],
+ "error": result.get("error"),
+ }
+
+
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+# Feature G: AI yield estimation
+@router.get("/ai-estimate-yield")
+async def ai_estimate_yield(
+ name: str = Query(..., min_length=2),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """AI-powered yield percentage estimation for an ingredient name."""
+ from services.llm_service import estimate_yield
+
+ result = await estimate_yield(
+ db=db,
+ kitchen_id=user.kitchen_id,
+ ingredient_name=name,
+ )
+
+ return {
+ "llm_status": result["status"],
+ "yield_percent": result.get("yield_percent"),
+ "reason": result.get("reason"),
+ "error": result.get("error"),
+ }
+
+
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+@router.get("/ai-pack-size")
+async def ai_deduce_pack_size_from_description(
+ description: str = Query(..., min_length=2),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Deduce pack size from a product description.
+ Tier 1: regex parse. Tier 2: LLM product knowledge fallback.
+ Used by IngredientModal when creating ingredients from line items.
+ """
+ from ocr.azure_extractor import parse_pack_size
+
+ # Tier 1: Regex (free, instant)
+ parsed = parse_pack_size(description)
+ 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 description",
+ }
+
+ # Tier 2: LLM deduction
+ from services.llm_service import deduce_pack_size
+ llm_result = await deduce_pack_size(
+ db=db,
+ kitchen_id=user.kitchen_id,
+ description=description,
+ )
+
+ 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",
+ }
+
+
+@router.get("/{ingredient_id}")
+async def get_ingredient(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Ingredient)
+ .options(
+ selectinload(Ingredient.category),
+ selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
+ selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category),
+ selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category),
+ )
+ .where(Ingredient.id == ingredient_id, Ingredient.kitchen_id == user.kitchen_id)
+ )
+ ing = result.scalar_one_or_none()
+ if not ing:
+ raise HTTPException(404, "Ingredient not found")
+ return _build_ingredient_response(ing)
+
+
+@router.get("/{ingredient_id}/label-image")
+async def get_label_image(
+ ingredient_id: int,
+ token: str,
+ db: AsyncSession = Depends(get_db),
+):
+ """Serve the stored label image for a prepackaged ingredient.
+ Uses token query param for auth (allows window.open / img src usage)."""
+ import os
+ from auth import get_current_user, require_cap_from_token
+ user = await get_current_user_from_token(token, db)
+ if not user:
+ raise HTTPException(401, "Not authenticated")
+ result = await db.execute(
+ select(Ingredient.label_image_path).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ label_path = result.scalar_one_or_none()
+ if not label_path or not os.path.exists(label_path):
+ raise HTTPException(404, "Label image not found")
+ return FileResponse(label_path)
+
+
+@router.patch("/{ingredient_id}")
+async def update_ingredient(
+ ingredient_id: int,
+ data: IngredientUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Ingredient)
+ .options(selectinload(Ingredient.category), selectinload(Ingredient.sources))
+ .where(Ingredient.id == ingredient_id, Ingredient.kitchen_id == user.kitchen_id)
+ )
+ ing = result.scalar_one_or_none()
+ if not ing:
+ raise HTTPException(404, "Ingredient not found")
+
+ if data.name is not None:
+ ing.name = data.name.strip()
+ if data.category_id is not None:
+ ing.category_id = data.category_id
+ if data.standard_unit is not None:
+ ing.standard_unit = data.standard_unit
+ if data.yield_percent is not None:
+ ing.yield_percent = Decimal(str(data.yield_percent))
+ if data.manual_price is not None:
+ ing.manual_price = Decimal(str(data.manual_price))
+ if data.notes is not None:
+ ing.notes = data.notes
+ if data.is_archived is not None:
+ ing.is_archived = data.is_archived
+ if data.is_prepackaged is not None:
+ ing.is_prepackaged = data.is_prepackaged
+ if data.is_free is not None:
+ ing.is_free = data.is_free
+ if data.product_ingredients is not None:
+ ing.product_ingredients = data.product_ingredients
+
+ await db.commit()
+ # Re-query with full eager loading to avoid lazy-load in async context
+ result2 = await db.execute(
+ select(Ingredient)
+ .options(
+ selectinload(Ingredient.category),
+ selectinload(Ingredient.sources),
+ selectinload(Ingredient.flags).selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category),
+ selectinload(Ingredient.flag_nones).selectinload(IngredientFlagNone.category),
+ )
+ .where(Ingredient.id == ing.id)
+ )
+ ing = result2.scalar_one()
+ return _build_ingredient_response(ing)
+
+
+@router.delete("/{ingredient_id}")
+async def archive_ingredient(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ ing = result.scalar_one_or_none()
+ if not ing:
+ raise HTTPException(404, "Ingredient not found")
+ ing.is_archived = True
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Backfill helper ───────────────────────────────────────────────────────────
+
+async def backfill_product_codes_for_source(
+ source: IngredientSource,
+ kitchen_id: int,
+ db: AsyncSession,
+) -> int:
+ """
+ For a source that has both product_code and description_pattern,
+ find line items from same supplier where product_code IS NULL and
+ first line of description matches, then set their product_code and ingredient_id.
+ Returns count of updated line items.
+ """
+ if not source.product_code or not source.description_pattern:
+ return 0
+
+ norm_pattern = normalize_description(source.description_pattern)
+ if not norm_pattern:
+ return 0
+
+ # Find line items from same supplier with no product_code where first-line description matches
+ result = await db.execute(
+ text("""
+ UPDATE line_items li
+ SET product_code = :code,
+ ingredient_id = COALESCE(li.ingredient_id, :ing_id)
+ FROM invoices inv
+ WHERE li.invoice_id = inv.id
+ AND inv.kitchen_id = :kid
+ AND inv.supplier_id = :sid
+ AND (li.product_code IS NULL OR li.product_code = '')
+ AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:pattern)
+ """),
+ {
+ "code": source.product_code,
+ "ing_id": source.ingredient_id,
+ "kid": kitchen_id,
+ "sid": source.supplier_id,
+ "pattern": norm_pattern,
+ },
+ )
+ count = result.rowcount
+ if count > 0:
+ logger.info(f"Backfilled product_code '{source.product_code}' on {count} line items for source {source.id}")
+ return count
+
+
+# ── Source endpoints ──────────────────────────────────────────────────────────
+
+@router.get("/{ingredient_id}/sources")
+async def list_sources(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(IngredientSource)
+ .options(selectinload(IngredientSource.supplier))
+ .where(
+ IngredientSource.ingredient_id == ingredient_id,
+ IngredientSource.kitchen_id == user.kitchen_id,
+ )
+ .order_by(IngredientSource.created_at)
+ )
+ sources = result.scalars().all()
+ return [
+ SourceResponse(
+ id=s.id,
+ supplier_id=s.supplier_id,
+ supplier_name=s.supplier.name if s.supplier else "",
+ product_code=s.product_code,
+ description_pattern=s.description_pattern,
+ description_aliases=s.description_aliases or [],
+ pack_quantity=s.pack_quantity,
+ unit_size=float(s.unit_size) if s.unit_size else None,
+ unit_size_type=s.unit_size_type,
+ latest_unit_price=float(s.latest_unit_price) if s.latest_unit_price else None,
+ latest_invoice_date=str(s.latest_invoice_date) if s.latest_invoice_date else None,
+ price_per_std_unit=float(s.price_per_std_unit) if s.price_per_std_unit else None,
+ )
+ for s in sources
+ ]
+
+
+@router.post("/{ingredient_id}/sources")
+async def create_source(
+ ingredient_id: int,
+ data: SourceCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ # Verify ingredient belongs to kitchen
+ ing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not ing.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ # Validate: at least one of product_code or description_pattern required
+ if not data.product_code and not data.description_pattern:
+ raise HTTPException(400, "Either product_code or description_pattern is required")
+
+ # Calculate price_per_std_unit if pack data and price provided
+ price_per_std = None
+ unit_conversions = {
+ 'g': {'g': 1, 'kg': 0.001}, 'kg': {'g': 1000, 'kg': 1}, 'oz': {'g': 28.3495, 'kg': 0.0283495},
+ 'ml': {'ml': 1, 'ltr': 0.001}, 'cl': {'ml': 10, 'ltr': 0.01}, 'ltr': {'ml': 1000, 'ltr': 1},
+ 'each': {'each': 1},
+ }
+ # Re-fetch ingredient for standard_unit
+ ing_obj = await db.execute(
+ select(Ingredient).where(Ingredient.id == ingredient_id, Ingredient.kitchen_id == user.kitchen_id)
+ )
+ ingredient = ing_obj.scalar_one_or_none()
+ if data.latest_unit_price and data.unit_size and data.unit_size_type and ingredient:
+ pq = data.pack_quantity or 1
+ conv_factor = unit_conversions.get(data.unit_size_type, {}).get(ingredient.standard_unit, 0)
+ if conv_factor:
+ total_std = pq * data.unit_size * conv_factor
+ price_per_std = Decimal(str(data.latest_unit_price)) / Decimal(str(total_std))
+
+ # Get invoice date if invoice_id provided
+ invoice_date = None
+ if data.invoice_id:
+ from models.invoice import Invoice
+ inv_result = await db.execute(select(Invoice.invoice_date).where(Invoice.id == data.invoice_id))
+ invoice_date = inv_result.scalar_one_or_none()
+
+ # Check for existing source (upsert: update pack data if already mapped)
+ existing_conditions = [
+ IngredientSource.kitchen_id == user.kitchen_id,
+ IngredientSource.ingredient_id == ingredient_id,
+ IngredientSource.supplier_id == data.supplier_id,
+ ]
+ if data.product_code:
+ existing_conditions.append(IngredientSource.product_code == data.product_code)
+ else:
+ existing_conditions.append(IngredientSource.product_code.is_(None))
+ if data.description_pattern:
+ existing_conditions.append(func.lower(IngredientSource.description_pattern) == data.description_pattern.lower())
+
+ existing_src = await db.execute(select(IngredientSource).where(and_(*existing_conditions)))
+ source = existing_src.scalar_one_or_none()
+
+ if source:
+ # Update existing source with new pack/price data
+ source.pack_quantity = data.pack_quantity or 1
+ if data.unit_size is not None:
+ source.unit_size = Decimal(str(data.unit_size))
+ if data.unit_size_type:
+ source.unit_size_type = data.unit_size_type
+ if data.latest_unit_price is not None:
+ source.latest_unit_price = Decimal(str(data.latest_unit_price))
+ if data.invoice_id:
+ source.latest_invoice_id = data.invoice_id
+ if invoice_date:
+ source.latest_invoice_date = invoice_date
+ if price_per_std is not None:
+ source.price_per_std_unit = price_per_std
+ await db.commit()
+ else:
+ source = IngredientSource(
+ kitchen_id=user.kitchen_id,
+ ingredient_id=ingredient_id,
+ supplier_id=data.supplier_id,
+ product_code=data.product_code,
+ description_pattern=data.description_pattern,
+ pack_quantity=data.pack_quantity or 1,
+ unit_size=Decimal(str(data.unit_size)) if data.unit_size else None,
+ unit_size_type=data.unit_size_type,
+ latest_unit_price=Decimal(str(data.latest_unit_price)) if data.latest_unit_price else None,
+ latest_invoice_id=data.invoice_id,
+ latest_invoice_date=invoice_date,
+ price_per_std_unit=price_per_std,
+ )
+ db.add(source)
+ try:
+ await db.commit()
+ except IntegrityError:
+ await db.rollback()
+ raise HTTPException(409, "This supplier product is already mapped to this ingredient")
+ await db.refresh(source)
+
+ # Bulk-set ingredient_id on matching line items if requested
+ matched_count = None
+ if data.apply_to_existing:
+ match_conditions = [
+ Invoice.kitchen_id == user.kitchen_id,
+ Invoice.supplier_id == data.supplier_id,
+ LineItem.invoice_id == Invoice.id,
+ LineItem.ingredient_id.is_(None),
+ ]
+ if data.product_code:
+ match_conditions.append(LineItem.product_code == data.product_code)
+ elif data.description_pattern:
+ match_conditions.append(
+ func.lower(LineItem.description).contains(data.description_pattern.lower())
+ )
+
+ result = await db.execute(
+ update(LineItem)
+ .where(*match_conditions)
+ .values(ingredient_id=ingredient_id)
+ )
+ matched_count = result.rowcount
+ await db.commit()
+ logger.info(f"Bulk-mapped {matched_count} line items to ingredient {ingredient_id}")
+
+ # Auto-backfill product codes on line items with matching description but missing code
+ backfilled = await backfill_product_codes_for_source(source, user.kitchen_id, db)
+ if backfilled > 0:
+ await db.commit()
+
+ # Load supplier name
+ sup = await db.execute(select(Supplier).where(Supplier.id == data.supplier_id))
+ supplier = sup.scalar_one_or_none()
+
+ return SourceResponse(
+ id=source.id,
+ supplier_id=source.supplier_id,
+ supplier_name=supplier.name if supplier else "",
+ product_code=source.product_code,
+ description_pattern=source.description_pattern,
+ description_aliases=source.description_aliases or [],
+ pack_quantity=source.pack_quantity,
+ unit_size=float(source.unit_size) if source.unit_size else None,
+ unit_size_type=source.unit_size_type,
+ latest_unit_price=float(source.latest_unit_price) if source.latest_unit_price else None,
+ latest_invoice_date=source.latest_invoice_date,
+ price_per_std_unit=float(source.price_per_std_unit) if source.price_per_std_unit else None,
+ matched_line_items=matched_count,
+ backfilled_count=backfilled if backfilled > 0 else None,
+ )
+
+
+@router.patch("/sources/{source_id}")
+async def update_source(
+ source_id: int,
+ data: SourceUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(IngredientSource).where(
+ IngredientSource.id == source_id,
+ IngredientSource.kitchen_id == user.kitchen_id,
+ )
+ )
+ source = result.scalar_one_or_none()
+ if not source:
+ raise HTTPException(404, "Source not found")
+
+ if data.product_code is not None:
+ source.product_code = data.product_code
+ if data.description_pattern is not None:
+ source.description_pattern = data.description_pattern
+ if data.pack_quantity is not None:
+ source.pack_quantity = data.pack_quantity
+ if data.unit_size is not None:
+ source.unit_size = Decimal(str(data.unit_size))
+ if data.unit_size_type is not None:
+ source.unit_size_type = data.unit_size_type
+
+ # Recalculate price_per_std_unit if we have price and new pack data
+ if source.latest_unit_price and source.pack_quantity and source.unit_size and source.unit_size_type:
+ ing = await db.execute(select(Ingredient).where(Ingredient.id == source.ingredient_id))
+ ingredient = ing.scalar_one_or_none()
+ if ingredient:
+ source.price_per_std_unit = calc_price_per_std_unit(
+ source.latest_unit_price,
+ source.pack_quantity,
+ source.unit_size,
+ source.unit_size_type,
+ ingredient.standard_unit,
+ )
+
+ # Auto-backfill product codes if source has both code and description
+ backfilled = await backfill_product_codes_for_source(source, user.kitchen_id, db)
+
+ await db.commit()
+ return {"ok": True, "backfilled_count": backfilled}
+
+
+@router.delete("/sources/{source_id}")
+async def delete_source(
+ source_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(IngredientSource).where(
+ IngredientSource.id == source_id,
+ IngredientSource.kitchen_id == user.kitchen_id,
+ )
+ )
+ source = result.scalar_one_or_none()
+ if not source:
+ raise HTTPException(404, "Source not found")
+ await db.delete(source)
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Description alias endpoints ───────────────────────────────────────────────
+
+class AddAliasRequest(BaseModel):
+ alias: str
+
+class AliasSuggestionItem(BaseModel):
+ description: str
+ price: Optional[float] = None
+
+class AliasSuggestionsRequest(BaseModel):
+ supplier_id: int
+ items: list[AliasSuggestionItem]
+
+class BackfillCodesRequest(BaseModel):
+ source_id: Optional[int] = None
+ supplier_id: Optional[int] = None
+
+
+@router.post("/sources/{source_id}/aliases")
+async def add_description_alias(
+ source_id: int,
+ data: AddAliasRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Add a description alias to an ingredient source and normalize matching line items."""
+ result = await db.execute(
+ select(IngredientSource).where(
+ IngredientSource.id == source_id,
+ IngredientSource.kitchen_id == user.kitchen_id,
+ )
+ )
+ source = result.scalar_one_or_none()
+ if not source:
+ raise HTTPException(404, "Source not found")
+ if not source.description_pattern:
+ raise HTTPException(400, "Source has no description_pattern to normalize to")
+
+ alias = data.alias.strip()
+ if not alias:
+ raise HTTPException(400, "Alias cannot be empty")
+
+ # Add alias (case-insensitive dedup) — create new list for SQLAlchemy change detection
+ current_aliases = list(source.description_aliases or [])
+ if alias.lower() not in [a.lower() for a in current_aliases]:
+ current_aliases.append(alias)
+ source.description_aliases = current_aliases
+
+ # Bulk-rename line items: same supplier, first line matches alias (case-insensitive)
+ # 1. Save original description into description_alt (only if not already saved)
+ # 2. Replace first line of description with master description_pattern
+ # 3. Set ingredient_id
+ # Also backfill product_code if source has one
+ master = source.description_pattern
+ code_clause = ", product_code = :code" if source.product_code else ""
+ code_params = {"code": source.product_code} if source.product_code else {}
+
+ rename_result = await db.execute(
+ text(f"""
+ UPDATE line_items li
+ SET description = :master || CASE
+ WHEN position(E'\\n' IN li.description) > 0
+ THEN substring(li.description FROM position(E'\\n' IN li.description))
+ ELSE ''
+ END,
+ description_alt = CASE
+ WHEN li.description_alt IS NULL THEN li.description
+ ELSE li.description_alt
+ END,
+ ingredient_id = COALESCE(li.ingredient_id, :ing_id)
+ {code_clause}
+ FROM invoices inv
+ WHERE li.invoice_id = inv.id
+ AND inv.kitchen_id = :kid
+ AND inv.supplier_id = :sid
+ AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:alias)
+ """),
+ {
+ "master": master,
+ "ing_id": source.ingredient_id,
+ "kid": user.kitchen_id,
+ "sid": source.supplier_id,
+ "alias": alias,
+ **code_params,
+ },
+ )
+ renamed_count = rename_result.rowcount
+
+ await db.commit()
+ logger.info(f"Added alias '{alias}' to source {source_id}, renamed {renamed_count} line items")
+
+ return {"ok": True, "alias": alias, "renamed_count": renamed_count}
+
+
+@router.post("/sources/alias-suggestions")
+async def get_alias_suggestions(
+ data: AliasSuggestionsRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Find close description matches for unmapped line items from a supplier."""
+ # Load all ingredient sources for this supplier + kitchen
+ src_result = await db.execute(
+ select(IngredientSource)
+ .options(selectinload(IngredientSource.ingredient))
+ .where(
+ IngredientSource.kitchen_id == user.kitchen_id,
+ IngredientSource.supplier_id == data.supplier_id,
+ )
+ )
+ sources = src_result.scalars().all()
+ if not sources:
+ return []
+
+ # Build candidate list: (canonical_description, source)
+ candidates = []
+ known_patterns = set() # All known patterns + aliases (lowered) for exact-match skip
+ for s in sources:
+ if s.description_pattern:
+ norm = s.description_pattern.lower().strip()
+ candidates.append((s.description_pattern, s))
+ known_patterns.add(norm)
+ for alias in (s.description_aliases or []):
+ known_patterns.add(alias.lower().strip())
+
+ if not candidates:
+ return []
+
+ suggestions = []
+ for item in data.items:
+ desc = item.description.strip()
+ first_line = desc.split('\n')[0].strip()
+ if not first_line:
+ continue
+ # Skip if already an exact match to a known pattern or alias
+ if first_line.lower() in known_patterns:
+ continue
+
+ # Find best match using pg_trgm similarity
+ best_match = None
+ best_sim = 0.0
+ for pattern, source in candidates:
+ sim_result = await db.execute(
+ text("SELECT similarity(:a, :b) AS sim"),
+ {"a": first_line, "b": pattern},
+ )
+ sim = float(sim_result.scalar())
+ if sim > best_sim and sim >= 0.3:
+ best_sim = sim
+ best_match = (pattern, source)
+
+ if best_match:
+ pattern, source = best_match
+ # Calculate price difference if available
+ price_diff = None
+ if item.price and source.latest_unit_price:
+ price_diff = round(
+ abs(item.price - float(source.latest_unit_price)) / float(source.latest_unit_price) * 100, 1
+ )
+
+ suggestions.append({
+ "description": first_line,
+ "source_id": source.id,
+ "ingredient_id": source.ingredient_id,
+ "ingredient_name": source.ingredient.name if source.ingredient else None,
+ "canonical_description": pattern,
+ "product_code": source.product_code,
+ "similarity": round(best_sim, 3),
+ "price_difference": price_diff,
+ })
+
+ # Sort by similarity descending
+ suggestions.sort(key=lambda x: x["similarity"], reverse=True)
+ return suggestions
+
+
+@router.post("/sources/backfill-codes")
+async def backfill_codes(
+ data: BackfillCodesRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Retroactively fill in missing product codes on line items that match by description."""
+ if not data.source_id and not data.supplier_id:
+ raise HTTPException(400, "Provide either source_id or supplier_id")
+
+ query = select(IngredientSource).where(
+ IngredientSource.kitchen_id == user.kitchen_id,
+ IngredientSource.product_code.isnot(None),
+ IngredientSource.description_pattern.isnot(None),
+ )
+ if data.source_id:
+ query = query.where(IngredientSource.id == data.source_id)
+ if data.supplier_id:
+ query = query.where(IngredientSource.supplier_id == data.supplier_id)
+
+ result = await db.execute(query)
+ sources = result.scalars().all()
+
+ total_updated = 0
+ for source in sources:
+ count = await backfill_product_codes_for_source(source, user.kitchen_id, db)
+ total_updated += count
+
+ if total_updated > 0:
+ await db.commit()
+
+ return {"ok": True, "updated_count": total_updated}
+
+
+# ── Flag endpoints (on ingredients) ──────────────────────────────────────────
+
+@router.get("/{ingredient_id}/flags")
+async def get_ingredient_flags(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(IngredientFlag)
+ .options(selectinload(IngredientFlag.food_flag).selectinload(FoodFlag.category))
+ .where(IngredientFlag.ingredient_id == ingredient_id)
+ )
+ flags = result.scalars().all()
+ return [
+ FlagResponse(
+ id=f.id,
+ food_flag_id=f.food_flag_id,
+ flag_name=f.food_flag.name if f.food_flag else "",
+ flag_code=f.food_flag.code if f.food_flag else None,
+ category_name=f.food_flag.category.name if f.food_flag and f.food_flag.category else "",
+ source=f.source,
+ )
+ for f in flags
+ ]
+
+
+class FlagSetRequest(BaseModel):
+ food_flag_ids: list[int]
+
+
+@router.put("/{ingredient_id}/flags")
+async def set_ingredient_flags(
+ ingredient_id: int,
+ data: FlagSetRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Full replacement of ingredient flags (manual source)."""
+ # Verify ingredient
+ ing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not ing.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ # Delete existing manual flags (keep latched ones)
+ await db.execute(
+ delete(IngredientFlag).where(
+ IngredientFlag.ingredient_id == ingredient_id,
+ IngredientFlag.source == "manual",
+ )
+ )
+
+ # Add new flags
+ for flag_id in data.food_flag_ids:
+ # Check if latched flag already exists
+ existing = await db.execute(
+ select(IngredientFlag).where(
+ IngredientFlag.ingredient_id == ingredient_id,
+ IngredientFlag.food_flag_id == flag_id,
+ )
+ )
+ if not existing.scalar_one_or_none():
+ db.add(IngredientFlag(
+ ingredient_id=ingredient_id,
+ food_flag_id=flag_id,
+ flagged_by=user.id,
+ source="manual",
+ ))
+
+ # When flags are set for a category, remove any "None" entries for that category
+ if data.food_flag_ids:
+ # Get category IDs for the flags being set
+ cat_result = await db.execute(
+ select(FoodFlag.category_id).where(FoodFlag.id.in_(data.food_flag_ids)).distinct()
+ )
+ cat_ids = [r for r in cat_result.scalars().all()]
+ if cat_ids:
+ await db.execute(
+ delete(IngredientFlagNone).where(
+ IngredientFlagNone.ingredient_id == ingredient_id,
+ IngredientFlagNone.category_id.in_(cat_ids),
+ )
+ )
+
+ await db.commit()
+ await _bump_recipes_using_ingredient(ingredient_id, db)
+ return {"ok": True}
+
+
+class FlagNoneRequest(BaseModel):
+ category_id: int
+
+
+@router.post("/{ingredient_id}/flags/none")
+async def toggle_flag_none(
+ ingredient_id: int,
+ data: FlagNoneRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Toggle 'None apply' for a specific flag category on an ingredient."""
+ # Verify ingredient
+ result = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not result.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ # Check if "None" already exists for this category
+ existing = await db.execute(
+ select(IngredientFlagNone).where(
+ IngredientFlagNone.ingredient_id == ingredient_id,
+ IngredientFlagNone.category_id == data.category_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ # Remove "None" (toggle off)
+ await db.execute(
+ delete(IngredientFlagNone).where(
+ IngredientFlagNone.ingredient_id == ingredient_id,
+ IngredientFlagNone.category_id == data.category_id,
+ )
+ )
+ else:
+ # Set "None" — also remove any actual flags from this category
+ flag_ids_result = await db.execute(
+ select(FoodFlag.id).where(FoodFlag.category_id == data.category_id)
+ )
+ flag_ids = [r for r in flag_ids_result.scalars().all()]
+ if flag_ids:
+ await db.execute(
+ delete(IngredientFlag).where(
+ IngredientFlag.ingredient_id == ingredient_id,
+ IngredientFlag.food_flag_id.in_(flag_ids),
+ )
+ )
+ db.add(IngredientFlagNone(
+ ingredient_id=ingredient_id,
+ category_id=data.category_id,
+ ))
+
+ await db.commit()
+ await _bump_recipes_using_ingredient(ingredient_id, db)
+ return {"ok": True}
+
+
+@router.get("/{ingredient_id}/flags/nones")
+async def get_flag_nones(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get category IDs where 'None' is set for an ingredient."""
+ result = await db.execute(
+ select(IngredientFlagNone.category_id).where(
+ IngredientFlagNone.ingredient_id == ingredient_id,
+ )
+ )
+ return {"none_category_ids": [r for r in result.scalars().all()]}
+
+
+# ── Dismissal endpoints (allergen suggestion dismissals) ─────────────────────
+
+class DismissalCreate(BaseModel):
+ food_flag_id: int
+ dismissed_by_name: str
+ reason: Optional[str] = None
+ matched_keyword: Optional[str] = None
+
+class DismissalResponse(BaseModel):
+ id: int
+ ingredient_id: int
+ food_flag_id: int
+ flag_name: Optional[str] = None
+ dismissed_by_name: str
+ reason: Optional[str] = None
+ matched_keyword: Optional[str] = None
+ created_at: str = ""
+ class Config:
+ from_attributes = True
+
+class DismissalBatchRequest(BaseModel):
+ dismissals: list[DismissalCreate]
+
+
+@router.get("/{ingredient_id}/flags/dismissals")
+async def get_dismissals(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get all dismissed allergen suggestions for an ingredient."""
+ # Verify ingredient belongs to kitchen
+ ing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not ing.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ result = await db.execute(
+ select(IngredientFlagDismissal)
+ .options(selectinload(IngredientFlagDismissal.food_flag))
+ .where(IngredientFlagDismissal.ingredient_id == ingredient_id)
+ .order_by(IngredientFlagDismissal.created_at.desc())
+ )
+ dismissals = result.scalars().all()
+ return [
+ DismissalResponse(
+ id=d.id,
+ ingredient_id=d.ingredient_id,
+ food_flag_id=d.food_flag_id,
+ flag_name=d.food_flag.name if d.food_flag else None,
+ dismissed_by_name=d.dismissed_by_name,
+ reason=d.reason,
+ matched_keyword=d.matched_keyword,
+ created_at=str(d.created_at) if d.created_at else "",
+ )
+ for d in dismissals
+ ]
+
+
+@router.post("/{ingredient_id}/flags/dismissals")
+async def create_dismissal(
+ ingredient_id: int,
+ data: DismissalCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Dismiss an allergen suggestion for an ingredient (upsert)."""
+ # Verify ingredient belongs to kitchen
+ ing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not ing.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ # Upsert: check if already dismissed
+ existing = await db.execute(
+ select(IngredientFlagDismissal).where(
+ IngredientFlagDismissal.ingredient_id == ingredient_id,
+ IngredientFlagDismissal.food_flag_id == data.food_flag_id,
+ )
+ )
+ dismissal = existing.scalar_one_or_none()
+ if dismissal:
+ # Update existing
+ dismissal.dismissed_by_name = data.dismissed_by_name
+ dismissal.reason = data.reason
+ dismissal.matched_keyword = data.matched_keyword
+ else:
+ dismissal = IngredientFlagDismissal(
+ ingredient_id=ingredient_id,
+ food_flag_id=data.food_flag_id,
+ dismissed_by_name=data.dismissed_by_name,
+ reason=data.reason,
+ matched_keyword=data.matched_keyword,
+ )
+ db.add(dismissal)
+
+ await db.commit()
+ await db.refresh(dismissal)
+ return DismissalResponse(
+ id=dismissal.id,
+ ingredient_id=dismissal.ingredient_id,
+ food_flag_id=dismissal.food_flag_id,
+ dismissed_by_name=dismissal.dismissed_by_name,
+ reason=dismissal.reason,
+ matched_keyword=dismissal.matched_keyword,
+ created_at=str(dismissal.created_at) if dismissal.created_at else "",
+ )
+
+
+@router.post("/{ingredient_id}/flags/dismissals/batch")
+async def batch_create_dismissals(
+ ingredient_id: int,
+ data: DismissalBatchRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Batch persist dismissals (used after ingredient creation in create mode)."""
+ # Verify ingredient belongs to kitchen
+ ing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not ing.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ created = 0
+ for d in data.dismissals:
+ # Upsert each
+ existing = await db.execute(
+ select(IngredientFlagDismissal).where(
+ IngredientFlagDismissal.ingredient_id == ingredient_id,
+ IngredientFlagDismissal.food_flag_id == d.food_flag_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ continue # Already dismissed, skip
+ db.add(IngredientFlagDismissal(
+ ingredient_id=ingredient_id,
+ food_flag_id=d.food_flag_id,
+ dismissed_by_name=d.dismissed_by_name,
+ reason=d.reason,
+ matched_keyword=d.matched_keyword,
+ ))
+ created += 1
+
+ await db.commit()
+ return {"ok": True, "created": created}
+
+
+@router.delete("/{ingredient_id}/flags/dismissals/{dismissal_id}")
+async def delete_dismissal(
+ ingredient_id: int,
+ dismissal_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Undo a dismissal (re-enables the suggestion)."""
+ # Verify ingredient belongs to kitchen
+ ing = await db.execute(
+ select(Ingredient).where(
+ Ingredient.id == ingredient_id,
+ Ingredient.kitchen_id == user.kitchen_id,
+ )
+ )
+ if not ing.scalar_one_or_none():
+ raise HTTPException(404, "Ingredient not found")
+
+ result = await db.execute(
+ select(IngredientFlagDismissal).where(
+ IngredientFlagDismissal.id == dismissal_id,
+ IngredientFlagDismissal.ingredient_id == ingredient_id,
+ )
+ )
+ dismissal = result.scalar_one_or_none()
+ if not dismissal:
+ raise HTTPException(404, "Dismissal not found")
+
+ await db.delete(dismissal)
+ await db.commit()
+ return {"ok": True}
+
+
+@router.get("/{ingredient_id}/recipes")
+async def get_ingredient_recipes(
+ ingredient_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get recipes that use this ingredient."""
+ result = await db.execute(
+ select(Recipe.id, Recipe.name, Recipe.recipe_type)
+ .join(RecipeIngredient, RecipeIngredient.recipe_id == Recipe.id)
+ .where(
+ RecipeIngredient.ingredient_id == ingredient_id,
+ Recipe.kitchen_id == user.kitchen_id,
+ Recipe.is_archived == False,
+ )
+ .order_by(Recipe.name)
+ )
+ return [
+ {"id": r.id, "name": r.name, "recipe_type": r.recipe_type}
+ for r in result.all()
+ ]
+
+
+# ── Auto-normalize hook (called from invoices.py) ────────────────────────────
+
+async def auto_normalize_line_items(
+ invoice_id: int,
+ kitchen_id: int,
+ supplier_id: int,
+ db: AsyncSession,
+) -> int:
+ """
+ Auto-normalize line items on a new invoice:
+ 1. Fill missing product codes from sources with matching descriptions
+ 2. Rename descriptions that match known aliases to the master description
+ Returns total count of line items modified.
+ """
+ # Load all sources for this supplier
+ src_result = await db.execute(
+ select(IngredientSource).where(
+ IngredientSource.kitchen_id == kitchen_id,
+ IngredientSource.supplier_id == supplier_id,
+ )
+ )
+ sources = src_result.scalars().all()
+ if not sources:
+ return 0
+
+ total_updated = 0
+
+ # Pass 1: Fill missing product codes
+ for source in sources:
+ if source.product_code and source.description_pattern:
+ norm_pattern = normalize_description(source.description_pattern)
+ if not norm_pattern:
+ continue
+ result = await db.execute(
+ text("""
+ UPDATE line_items li
+ SET product_code = :code,
+ ingredient_id = COALESCE(li.ingredient_id, :ing_id)
+ FROM invoices inv
+ WHERE li.invoice_id = :inv_id
+ AND li.invoice_id = inv.id
+ AND (li.product_code IS NULL OR li.product_code = '')
+ AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:pattern)
+ """),
+ {
+ "code": source.product_code,
+ "ing_id": source.ingredient_id,
+ "inv_id": invoice_id,
+ "pattern": norm_pattern,
+ },
+ )
+ total_updated += result.rowcount
+
+ # Pass 2: Rename alias descriptions to master
+ for source in sources:
+ if not source.description_pattern or not source.description_aliases:
+ continue
+ master = source.description_pattern
+ code_clause = ", product_code = :code" if source.product_code else ""
+ code_params = {"code": source.product_code} if source.product_code else {}
+
+ for alias in source.description_aliases:
+ result = await db.execute(
+ text(f"""
+ UPDATE line_items li
+ SET description = :master || CASE
+ WHEN position(E'\\n' IN li.description) > 0
+ THEN substring(li.description FROM position(E'\\n' IN li.description))
+ ELSE ''
+ END,
+ description_alt = CASE
+ WHEN li.description_alt IS NULL THEN li.description
+ ELSE li.description_alt
+ END,
+ ingredient_id = COALESCE(li.ingredient_id, :ing_id)
+ {code_clause}
+ WHERE li.invoice_id = :inv_id
+ AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = LOWER(:alias)
+ """),
+ {
+ "master": master,
+ "ing_id": source.ingredient_id,
+ "inv_id": invoice_id,
+ "alias": alias,
+ **code_params,
+ },
+ )
+ total_updated += result.rowcount
+
+ # Pass 3: Fill missing product codes from sibling line items (same supplier + description)
+ # This catches cases where no ingredient source exists yet but another invoice
+ # from the same supplier has the product code for the same description.
+ result = await db.execute(
+ text("""
+ UPDATE line_items li
+ SET product_code = known.code
+ FROM (
+ SELECT DISTINCT ON (LOWER(TRIM(split_part(li2.description, E'\\n', 1))))
+ LOWER(TRIM(split_part(li2.description, E'\\n', 1))) AS norm_desc,
+ li2.product_code AS code
+ FROM line_items li2
+ JOIN invoices inv ON inv.id = li2.invoice_id
+ WHERE inv.supplier_id = :sid
+ AND li2.product_code IS NOT NULL
+ AND li2.product_code != ''
+ ORDER BY LOWER(TRIM(split_part(li2.description, E'\\n', 1))),
+ li2.id DESC
+ ) known
+ WHERE li.invoice_id = :inv_id
+ AND (li.product_code IS NULL OR li.product_code = '')
+ AND LOWER(TRIM(split_part(li.description, E'\\n', 1))) = known.norm_desc
+ """),
+ {"sid": supplier_id, "inv_id": invoice_id},
+ )
+ total_updated += result.rowcount
+
+ if total_updated > 0:
+ logger.info(f"Auto-normalized {total_updated} line items on invoice {invoice_id}")
+
+ return total_updated
+
+
+# ── Auto-price hook (called from invoices.py) ────────────────────────────────
+
+async def update_ingredient_prices_for_invoice(
+ invoice_id: int,
+ kitchen_id: int,
+ db: AsyncSession,
+):
+ """
+ Called after line items are saved/updated for an invoice.
+ Matches line items to ingredient sources and updates pricing.
+ Also sets line_item.ingredient_id for matched items.
+ """
+ # Get invoice with supplier
+ inv_result = await db.execute(
+ select(Invoice).where(Invoice.id == invoice_id)
+ )
+ invoice = inv_result.scalar_one_or_none()
+ if not invoice or not invoice.supplier_id:
+ return
+
+ supplier_id = invoice.supplier_id
+ invoice_date = invoice.invoice_date
+
+ # Get all line items for this invoice
+ li_result = await db.execute(
+ select(LineItem).where(LineItem.invoice_id == invoice_id)
+ )
+ line_items = li_result.scalars().all()
+
+ # Get all ingredient sources for this supplier + kitchen
+ src_result = await db.execute(
+ select(IngredientSource)
+ .where(
+ IngredientSource.kitchen_id == kitchen_id,
+ IngredientSource.supplier_id == supplier_id,
+ )
+ )
+ sources = src_result.scalars().all()
+
+ if not sources:
+ return
+
+ # Build lookup structures
+ code_sources = {} # product_code -> source
+ desc_sources = [] # [(normalized_pattern, source)] sorted by length desc
+
+ for s in sources:
+ if s.product_code:
+ code_sources[s.product_code.strip()] = s
+ if s.description_pattern:
+ desc_sources.append((normalize_description(s.description_pattern), s))
+
+ # Sort description patterns by length descending (longer = more specific)
+ desc_sources.sort(key=lambda x: len(x[0]), reverse=True)
+
+ updated_ingredients = {} # {ingredient_id: {"name": str, "old_price": float|None, "new_price": float}}
+
+ for li in line_items:
+ matched_source = None
+
+ # Priority 1: product_code exact match
+ if li.product_code and li.product_code.strip() in code_sources:
+ matched_source = code_sources[li.product_code.strip()]
+
+ # Priority 2: description_pattern contains match
+ if not matched_source and li.description:
+ norm_desc = normalize_description(li.description)
+ for pattern, source in desc_sources:
+ if pattern and pattern in norm_desc:
+ matched_source = source
+ break
+
+ if matched_source:
+ # Always link line item to ingredient for traceability
+ if not li.ingredient_id:
+ li.ingredient_id = matched_source.ingredient_id
+
+ # Only update source pricing for real invoices with positive prices
+ # Skip credit notes and zero/free replacements
+ if li.unit_price and li.unit_price > 0 and invoice.document_type != 'credit_note':
+ matched_source.latest_unit_price = li.unit_price
+ matched_source.latest_invoice_id = invoice_id
+ matched_source.latest_invoice_date = invoice_date
+
+ # Get ingredient standard unit for conversion
+ ing_result = await db.execute(
+ select(Ingredient).where(Ingredient.id == matched_source.ingredient_id)
+ )
+ ingredient = ing_result.scalar_one_or_none()
+
+ if ingredient and (li.unit or (matched_source.pack_quantity and matched_source.unit_size and matched_source.unit_size_type)):
+ old_price = float(matched_source.price_per_std_unit) if matched_source.price_per_std_unit else None
+ matched_source.price_per_std_unit = calc_price_per_std_unit(
+ li.unit_price,
+ matched_source.pack_quantity,
+ matched_source.unit_size,
+ matched_source.unit_size_type,
+ ingredient.standard_unit,
+ line_item_unit=li.unit,
+ )
+ new_price = float(matched_source.price_per_std_unit) if matched_source.price_per_std_unit else None
+ # Only log as a change if the price actually changed
+ if new_price is not None and (old_price is None or abs(new_price - old_price) > 0.000001):
+ updated_ingredients[ingredient.id] = {
+ "name": ingredient.name,
+ "unit": ingredient.standard_unit or "",
+ "old_price": old_price,
+ "new_price": new_price,
+ }
+
+ return updated_ingredients
diff --git a/backend/api/internal.py b/backend/api/internal.py
new file mode 100644
index 0000000..0d9ba2e
--- /dev/null
+++ b/backend/api/internal.py
@@ -0,0 +1,64 @@
+"""
+Internal API — endpoints consumed by other stack apps (not public).
+nginx denies /kitchen/api/internal/ from the public side; these endpoints
+are called directly on the backend port (8000) from within the docker bridge.
+"""
+from datetime import date
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from database import get_db
+from auth import verify_internal_secret
+from models.resos import ResosBooking
+
+router = APIRouter()
+
+
+@router.get("/api/internal/resos/bookings")
+async def internal_resos_bookings(
+ booking_date: Optional[str] = Query(None, alias="date"),
+ _: None = Depends(verify_internal_secret),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Cached ResOS bookings for a given date — consumed by the KDS app bookings screen.
+ KDS calls: GET http://10.10.10.110:8000/api/internal/resos/bookings?date=YYYY-MM-DD
+ Authorization: Bearer {STACK_INTERNAL_SECRET}
+ """
+ try:
+ target_date = date.fromisoformat(booking_date) if booking_date else date.today()
+ except (ValueError, TypeError):
+ raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD")
+
+ result = await db.execute(
+ select(ResosBooking)
+ .where(
+ ResosBooking.kitchen_id == 1,
+ ResosBooking.booking_date == target_date,
+ )
+ .order_by(ResosBooking.booking_time)
+ )
+ bookings = result.scalars().all()
+
+ return [
+ {
+ "id": b.id,
+ "resos_booking_id": b.resos_booking_id,
+ "booking_date": b.booking_date.isoformat(),
+ "booking_time": b.booking_time.strftime("%H:%M"),
+ "people": b.people,
+ "status": b.status,
+ "seating_area": b.seating_area,
+ "table_name": b.table_name,
+ "hotel_booking_number": b.hotel_booking_number,
+ "is_hotel_guest": b.is_hotel_guest,
+ "is_dbb": b.is_dbb,
+ "allergies": b.allergies,
+ "notes": b.notes,
+ "opening_hour_name": b.opening_hour_name,
+ }
+ for b in bookings
+ ]
diff --git a/backend/api/invoices.py b/backend/api/invoices.py
new file mode 100644
index 0000000..8e3d686
--- /dev/null
+++ b/backend/api/invoices.py
@@ -0,0 +1,4077 @@
+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
+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)
+ 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)
+ 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",
+ }
diff --git a/backend/api/logbook.py b/backend/api/logbook.py
new file mode 100644
index 0000000..3acac85
--- /dev/null
+++ b/backend/api/logbook.py
@@ -0,0 +1,745 @@
+"""
+Logbook API for wastage, transfers, staff food, and manual adjustments.
+"""
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_, or_, func
+from sqlalchemy.orm import selectinload
+from datetime import date, datetime
+from typing import Optional, List
+from pydantic import BaseModel
+from decimal import Decimal
+import os
+import logging
+
+from auth import get_current_user, require_cap
+from database import get_db
+from models.user import User
+from models.logbook import (
+ LogbookEntry, LogbookLineItem, LogbookAttachment,
+ EntryType, WastageReason, TransferStatus
+)
+# from models.products import Product # TODO: Add Product model
+
+router = APIRouter(prefix="/logbook", tags=["Logbook"])
+logger = logging.getLogger(__name__)
+
+
+# ============ Pydantic Schemas ============
+
+class LineItemInput(BaseModel):
+ product_id: Optional[int] = None
+ product_name: str
+ product_code: Optional[str] = None
+ supplier_name: Optional[str] = None
+ quantity: float
+ unit: Optional[str] = None
+ unit_price: Optional[float] = None
+ total_cost: float
+ notes: Optional[str] = None
+
+
+class WastageEntryInput(BaseModel):
+ entry_date: date
+ reason: WastageReason
+ line_items: List[LineItemInput]
+ notes: Optional[str] = None
+ reference_number: Optional[str] = None
+
+
+class TransferEntryInput(BaseModel):
+ entry_date: date
+ destination_kitchen_id: int
+ status: TransferStatus = TransferStatus.PENDING
+ line_items: List[LineItemInput]
+ notes: Optional[str] = None
+ reference_number: Optional[str] = None
+
+
+class StaffFoodEntryInput(BaseModel):
+ entry_date: date
+ meal_type: str # breakfast, lunch, dinner, snack
+ staff_count: Optional[int] = None
+ line_items: List[LineItemInput]
+ notes: Optional[str] = None
+
+
+class ManualAdjustmentInput(BaseModel):
+ entry_date: date
+ adjustment_reason: str
+ original_invoice_id: Optional[int] = None
+ line_items: List[LineItemInput]
+ notes: Optional[str] = None
+ reference_number: Optional[str] = None
+
+
+class LineItemResponse(BaseModel):
+ id: int
+ product_id: Optional[int]
+ product_name: str
+ product_code: Optional[str]
+ supplier_name: Optional[str]
+ quantity: float
+ unit: Optional[str]
+ unit_price: Optional[float]
+ total_cost: float
+ notes: Optional[str]
+
+
+class AttachmentResponse(BaseModel):
+ id: int
+ file_name: str
+ file_path: str
+ file_type: str
+ file_size_bytes: int
+ description: Optional[str]
+ uploaded_at: str
+
+
+class LogbookEntryResponse(BaseModel):
+ id: int
+ entry_type: str
+ entry_date: str
+ reference_number: Optional[str]
+ total_cost: float
+ notes: Optional[str]
+ type_data: dict
+ created_by: int
+ created_by_name: Optional[str]
+ created_at: str
+ line_items: List[LineItemResponse]
+ attachments: List[AttachmentResponse]
+
+
+class LogbookSummary(BaseModel):
+ total_entries: int
+ total_cost: float
+ by_type: dict
+
+
+# ============ Helper Functions ============
+
+def build_entry_response(entry: LogbookEntry) -> LogbookEntryResponse:
+ """Convert LogbookEntry model to response"""
+ return LogbookEntryResponse(
+ id=entry.id,
+ entry_type=entry.entry_type.value,
+ entry_date=entry.entry_date.isoformat(),
+ reference_number=entry.reference_number,
+ total_cost=float(entry.total_cost),
+ notes=entry.notes,
+ type_data=entry.type_data or {},
+ created_by=entry.created_by,
+ created_by_name=entry.created_by_user.name if entry.created_by_user else None,
+ created_at=entry.created_at.isoformat(),
+ line_items=[
+ LineItemResponse(
+ id=item.id,
+ product_id=item.product_id,
+ product_name=item.product_name,
+ product_code=item.product_code,
+ supplier_name=item.supplier_name,
+ quantity=float(item.quantity),
+ unit=item.unit,
+ unit_price=float(item.unit_price) if item.unit_price else None,
+ total_cost=float(item.total_cost),
+ notes=item.notes
+ )
+ for item in entry.line_items
+ ],
+ attachments=[
+ AttachmentResponse(
+ id=att.id,
+ file_name=att.file_name,
+ file_path=att.file_path,
+ file_type=att.file_type,
+ file_size_bytes=att.file_size_bytes,
+ description=att.description,
+ uploaded_at=att.uploaded_at.isoformat()
+ )
+ for att in entry.attachments
+ ]
+ )
+
+
+async def create_line_items(
+ db: AsyncSession,
+ entry_id: int,
+ kitchen_id: int,
+ items: List[LineItemInput]
+) -> Decimal:
+ """Create line items for an entry and return total cost"""
+ total_cost = Decimal(0)
+
+ for item_input in items:
+ line_item = LogbookLineItem(
+ entry_id=entry_id,
+ kitchen_id=kitchen_id,
+ product_id=item_input.product_id,
+ product_name=item_input.product_name,
+ product_code=item_input.product_code,
+ supplier_name=item_input.supplier_name,
+ quantity=Decimal(str(item_input.quantity)),
+ unit=item_input.unit,
+ unit_price=Decimal(str(item_input.unit_price)) if item_input.unit_price else None,
+ total_cost=Decimal(str(item_input.total_cost)),
+ notes=item_input.notes
+ )
+ db.add(line_item)
+ total_cost += line_item.total_cost
+
+ return total_cost
+
+
+# ============ Endpoints ============
+
+@router.get("")
+async def get_logbook_entries(
+ entry_type: Optional[EntryType] = None,
+ date_from: Optional[date] = None,
+ date_to: Optional[date] = None,
+ search: Optional[str] = None,
+ limit: int = 100,
+ offset: int = 0,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> List[LogbookEntryResponse]:
+ """Get logbook entries with filters"""
+
+ query = select(LogbookEntry).options(
+ selectinload(LogbookEntry.line_items),
+ selectinload(LogbookEntry.attachments),
+ selectinload(LogbookEntry.created_by_user)
+ ).where(
+ and_(
+ LogbookEntry.kitchen_id == current_user.kitchen_id,
+ LogbookEntry.is_deleted == False
+ )
+ )
+
+ if entry_type:
+ query = query.where(LogbookEntry.entry_type == entry_type)
+
+ if date_from:
+ query = query.where(LogbookEntry.entry_date >= date_from)
+
+ if date_to:
+ query = query.where(LogbookEntry.entry_date <= date_to)
+
+ if search:
+ # Search in notes, reference number, and line item product names
+ query = query.outerjoin(LogbookLineItem).where(
+ or_(
+ LogbookEntry.notes.ilike(f"%{search}%"),
+ LogbookEntry.reference_number.ilike(f"%{search}%"),
+ LogbookLineItem.product_name.ilike(f"%{search}%")
+ )
+ ).distinct()
+
+ query = query.order_by(LogbookEntry.entry_date.desc(), LogbookEntry.created_at.desc())
+ query = query.limit(limit).offset(offset)
+
+ try:
+ result = await db.execute(query)
+ entries = result.scalars().unique().all()
+ return [build_entry_response(entry) for entry in entries]
+ except Exception as e:
+ logger.exception(f"Error fetching logbook entries: {e}")
+ raise HTTPException(status_code=500, detail=f"Error fetching entries: {str(e)}")
+
+
+@router.get("/summary")
+async def get_logbook_summary(
+ date_from: Optional[date] = None,
+ date_to: Optional[date] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> LogbookSummary:
+ """Get summary statistics for logbook entries"""
+
+ query = select(
+ LogbookEntry.entry_type,
+ func.count(LogbookEntry.id).label('count'),
+ func.sum(LogbookEntry.total_cost).label('total')
+ ).where(
+ and_(
+ LogbookEntry.kitchen_id == current_user.kitchen_id,
+ LogbookEntry.is_deleted == False
+ )
+ ).group_by(LogbookEntry.entry_type)
+
+ if date_from:
+ query = query.where(LogbookEntry.entry_date >= date_from)
+
+ if date_to:
+ query = query.where(LogbookEntry.entry_date <= date_to)
+
+ result = await db.execute(query)
+ rows = result.all()
+
+ by_type = {}
+ total_entries = 0
+ total_cost = Decimal(0)
+
+ for row in rows:
+ entry_type, count, cost = row
+ by_type[entry_type.value] = {
+ 'count': count,
+ 'total_cost': float(cost or 0)
+ }
+ total_entries += count
+ total_cost += cost or 0
+
+ return LogbookSummary(
+ total_entries=total_entries,
+ total_cost=float(total_cost),
+ by_type=by_type
+ )
+
+
+@router.get("/daily-stats")
+async def get_daily_logbook_stats(
+ date_from: Optional[date] = None,
+ date_to: Optional[date] = None,
+ entry_type: Optional[EntryType] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get daily logbook statistics for a date range (for purchases chart integration)"""
+
+ query = select(
+ LogbookEntry.entry_date,
+ LogbookEntry.entry_type,
+ func.count(LogbookEntry.id).label("count"),
+ func.sum(LogbookEntry.total_cost).label("total_cost")
+ ).where(
+ and_(
+ LogbookEntry.kitchen_id == current_user.kitchen_id,
+ LogbookEntry.is_deleted == False
+ )
+ )
+
+ if date_from:
+ query = query.where(LogbookEntry.entry_date >= date_from)
+ if date_to:
+ query = query.where(LogbookEntry.entry_date <= date_to)
+ if entry_type:
+ query = query.where(LogbookEntry.entry_type == entry_type)
+
+ query = query.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type)
+ query = query.order_by(LogbookEntry.entry_date)
+
+ result = await db.execute(query)
+ rows = result.all()
+
+ # Group by date, then by type
+ daily_stats = {}
+ for row in rows:
+ date_str = row.entry_date.isoformat()
+ if date_str not in daily_stats:
+ daily_stats[date_str] = {}
+ daily_stats[date_str][row.entry_type.value] = {
+ "count": row.count,
+ "total_cost": float(row.total_cost or 0)
+ }
+
+ return {"daily_stats": daily_stats}
+
+
+@router.get("/products/search")
+async def search_products(
+ query: str,
+ limit: int = 20,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Search products from invoice line items for logbook entry"""
+ from models.line_item import LineItem
+ from models.invoice import Invoice
+ from models.supplier import Supplier
+
+ # Search line items from invoices for this kitchen
+ # Get distinct products with most recent price
+ result = await db.execute(
+ select(
+ LineItem.description,
+ LineItem.product_code,
+ LineItem.unit,
+ LineItem.unit_price,
+ Supplier.name.label('supplier_name')
+ )
+ .join(Invoice, LineItem.invoice_id == Invoice.id)
+ .outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
+ .where(
+ and_(
+ Invoice.kitchen_id == current_user.kitchen_id,
+ or_(
+ LineItem.description.ilike(f"%{query}%"),
+ LineItem.product_code.ilike(f"%{query}%")
+ )
+ )
+ )
+ .order_by(Invoice.invoice_date.desc())
+ .limit(limit * 3) # Get more to allow for deduplication
+ )
+ rows = result.all()
+
+ # Deduplicate by description, keeping first (most recent) price
+ seen = set()
+ products = []
+ for row in rows:
+ key = (row.description or '').lower()
+ if key and key not in seen:
+ seen.add(key)
+ products.append({
+ "id": 0, # No persistent product ID
+ "name": row.description,
+ "product_code": row.product_code,
+ "supplier_name": row.supplier_name,
+ "unit": row.unit,
+ "last_price": float(row.unit_price) if row.unit_price else None
+ })
+ if len(products) >= limit:
+ break
+
+ return products
+
+
+@router.get("/{entry_id}")
+async def get_logbook_entry(
+ entry_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> LogbookEntryResponse:
+ """Get single logbook entry with details"""
+
+ result = await db.execute(
+ select(LogbookEntry).where(
+ and_(
+ LogbookEntry.id == entry_id,
+ LogbookEntry.kitchen_id == current_user.kitchen_id,
+ LogbookEntry.is_deleted == False
+ )
+ )
+ )
+ entry = result.scalar_one_or_none()
+
+ if not entry:
+ raise HTTPException(status_code=404, detail="Entry not found")
+
+ return build_entry_response(entry)
+
+
+@router.post("/wastage")
+async def create_wastage_entry(
+ entry_input: WastageEntryInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> LogbookEntryResponse:
+ """Create wastage entry"""
+
+ if not entry_input.line_items:
+ raise HTTPException(status_code=400, detail="At least one line item is required")
+
+ entry = LogbookEntry(
+ kitchen_id=current_user.kitchen_id,
+ entry_type=EntryType.WASTAGE,
+ entry_date=entry_input.entry_date,
+ reference_number=entry_input.reference_number,
+ notes=entry_input.notes,
+ type_data={"reason": entry_input.reason.value},
+ created_by=current_user.id
+ )
+
+ db.add(entry)
+ await db.flush() # Get entry.id
+
+ total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
+ entry.total_cost = total_cost
+
+ await db.commit()
+ await db.refresh(entry)
+
+ logger.info(f"Created wastage entry {entry.id} for kitchen {current_user.kitchen_id}, cost: {total_cost}")
+
+ return await get_logbook_entry(entry.id, current_user, db)
+
+
+@router.post("/transfer")
+async def create_transfer_entry(
+ entry_input: TransferEntryInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> LogbookEntryResponse:
+ """Create transfer entry"""
+
+ if not entry_input.line_items:
+ raise HTTPException(status_code=400, detail="At least one line item is required")
+
+ entry = LogbookEntry(
+ kitchen_id=current_user.kitchen_id,
+ entry_type=EntryType.TRANSFER,
+ entry_date=entry_input.entry_date,
+ reference_number=entry_input.reference_number,
+ notes=entry_input.notes,
+ type_data={
+ "destination_kitchen_id": entry_input.destination_kitchen_id,
+ "status": entry_input.status.value
+ },
+ created_by=current_user.id
+ )
+
+ db.add(entry)
+ await db.flush()
+
+ total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
+ entry.total_cost = total_cost
+
+ await db.commit()
+ await db.refresh(entry)
+
+ logger.info(f"Created transfer entry {entry.id} for kitchen {current_user.kitchen_id} -> {entry_input.destination_kitchen_id}")
+
+ # TODO: Send notification to destination kitchen
+
+ return await get_logbook_entry(entry.id, current_user, db)
+
+
+@router.post("/staff-food")
+async def create_staff_food_entry(
+ entry_input: StaffFoodEntryInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> LogbookEntryResponse:
+ """Create staff food entry"""
+
+ if not entry_input.line_items:
+ raise HTTPException(status_code=400, detail="At least one line item is required")
+
+ entry = LogbookEntry(
+ kitchen_id=current_user.kitchen_id,
+ entry_type=EntryType.STAFF_FOOD,
+ entry_date=entry_input.entry_date,
+ notes=entry_input.notes,
+ type_data={
+ "meal_type": entry_input.meal_type,
+ "staff_count": entry_input.staff_count
+ },
+ created_by=current_user.id
+ )
+
+ db.add(entry)
+ await db.flush()
+
+ total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
+ entry.total_cost = total_cost
+
+ await db.commit()
+ await db.refresh(entry)
+
+ logger.info(f"Created staff food entry {entry.id} for kitchen {current_user.kitchen_id}, meal: {entry_input.meal_type}")
+
+ return await get_logbook_entry(entry.id, current_user, db)
+
+
+@router.post("/manual-adjustment")
+async def create_manual_adjustment_entry(
+ entry_input: ManualAdjustmentInput,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+) -> LogbookEntryResponse:
+ """Create manual adjustment entry"""
+
+ if not entry_input.line_items:
+ raise HTTPException(status_code=400, detail="At least one line item is required")
+
+ entry = LogbookEntry(
+ kitchen_id=current_user.kitchen_id,
+ entry_type=EntryType.MANUAL_ADJUSTMENT,
+ entry_date=entry_input.entry_date,
+ reference_number=entry_input.reference_number,
+ notes=entry_input.notes,
+ type_data={
+ "adjustment_reason": entry_input.adjustment_reason,
+ "original_invoice_id": entry_input.original_invoice_id
+ },
+ created_by=current_user.id
+ )
+
+ db.add(entry)
+ await db.flush()
+
+ total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
+ entry.total_cost = total_cost
+
+ await db.commit()
+ await db.refresh(entry)
+
+ logger.info(f"Created manual adjustment entry {entry.id} for kitchen {current_user.kitchen_id}")
+
+ return await get_logbook_entry(entry.id, current_user, db)
+
+
+@router.patch("/{entry_id}")
+async def update_logbook_entry(
+ entry_id: int,
+ notes: Optional[str] = None,
+ reference_number: Optional[str] = None,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update logbook entry (limited fields)"""
+
+ result = await db.execute(
+ select(LogbookEntry).where(
+ and_(
+ LogbookEntry.id == entry_id,
+ LogbookEntry.kitchen_id == current_user.kitchen_id,
+ LogbookEntry.is_deleted == False
+ )
+ )
+ )
+ entry = result.scalar_one_or_none()
+
+ if not entry:
+ raise HTTPException(status_code=404, detail="Entry not found")
+
+ if notes is not None:
+ entry.notes = notes
+
+ if reference_number is not None:
+ entry.reference_number = reference_number
+
+ entry.updated_at = datetime.utcnow()
+ await db.commit()
+
+ return {"status": "updated"}
+
+
+@router.delete("/{entry_id}")
+async def delete_logbook_entry(
+ entry_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Soft delete logbook entry"""
+
+ result = await db.execute(
+ select(LogbookEntry).where(
+ and_(
+ LogbookEntry.id == entry_id,
+ LogbookEntry.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ entry = result.scalar_one_or_none()
+
+ if not entry:
+ raise HTTPException(status_code=404, detail="Entry not found")
+
+ entry.is_deleted = True
+ entry.updated_at = datetime.utcnow()
+ await db.commit()
+
+ logger.info(f"Deleted logbook entry {entry_id} for kitchen {current_user.kitchen_id}")
+
+ return {"status": "deleted"}
+
+
+@router.post("/{entry_id}/attachments")
+async def upload_attachment(
+ entry_id: int,
+ file: UploadFile = File(...),
+ description: Optional[str] = Form(None),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Upload photo or document to logbook entry"""
+
+ # Verify entry exists and belongs to user's kitchen
+ result = await db.execute(
+ select(LogbookEntry).where(
+ and_(
+ LogbookEntry.id == entry_id,
+ LogbookEntry.kitchen_id == current_user.kitchen_id,
+ LogbookEntry.is_deleted == False
+ )
+ )
+ )
+ entry = result.scalar_one_or_none()
+ if not entry:
+ raise HTTPException(status_code=404, detail="Entry not found")
+
+ # Validate file type
+ allowed_types = ["image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"]
+ if file.content_type not in allowed_types:
+ raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed. Allowed: {allowed_types}")
+
+ # Save file
+ upload_dir = f"/app/attachments/logbook/kitchen_{current_user.kitchen_id}"
+ os.makedirs(upload_dir, exist_ok=True)
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ file_extension = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
+ file_name = f"entry_{entry_id}_{timestamp}{file_extension}"
+ file_path = f"{upload_dir}/{file_name}"
+
+ content = await file.read()
+ with open(file_path, "wb") as f:
+ f.write(content)
+
+ # Create attachment record
+ attachment = LogbookAttachment(
+ entry_id=entry_id,
+ kitchen_id=current_user.kitchen_id,
+ file_name=file.filename or file_name,
+ file_path=file_path,
+ file_type=file.content_type,
+ file_size_bytes=len(content),
+ description=description,
+ uploaded_by=current_user.id
+ )
+
+ db.add(attachment)
+ await db.commit()
+
+ logger.info(f"Uploaded attachment {attachment.id} to entry {entry_id}")
+
+ return {
+ "id": attachment.id,
+ "file_name": attachment.file_name,
+ "file_path": attachment.file_path
+ }
+
+
+@router.delete("/{entry_id}/attachments/{attachment_id}")
+async def delete_attachment(
+ entry_id: int,
+ attachment_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Delete attachment from logbook entry"""
+
+ result = await db.execute(
+ select(LogbookAttachment).where(
+ and_(
+ LogbookAttachment.id == attachment_id,
+ LogbookAttachment.entry_id == entry_id,
+ LogbookAttachment.kitchen_id == current_user.kitchen_id
+ )
+ )
+ )
+ attachment = result.scalar_one_or_none()
+
+ if not attachment:
+ raise HTTPException(status_code=404, detail="Attachment not found")
+
+ # Delete file from disk
+ if os.path.exists(attachment.file_path):
+ os.remove(attachment.file_path)
+
+ await db.delete(attachment)
+ await db.commit()
+
+ return {"status": "deleted"}
diff --git a/backend/api/menus.py b/backend/api/menus.py
new file mode 100644
index 0000000..0af6049
--- /dev/null
+++ b/backend/api/menus.py
@@ -0,0 +1,1308 @@
+"""
+Menus API — CRUD for menus, divisions, items + publish/republish + image upload + duplication.
+Prefix: /api/menus/
+"""
+import os
+import uuid
+import logging
+from datetime import datetime
+from decimal import Decimal
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
+from fastapi.responses import FileResponse
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, delete
+from sqlalchemy.orm import selectinload
+
+from database import get_db
+from auth import get_current_user, require_cap
+from models.user import User
+from models.menu import Menu, MenuDivision, MenuItem
+from models.recipe import Recipe, RecipeSubRecipe
+from api.food_flags import compute_recipe_flags, _collect_recipe_ingredient_ids
+from models.food_flag import FoodFlagCategory, FoodFlag
+from models.ingredient import IngredientFlag, IngredientFlagNone
+
+logger = logging.getLogger(__name__)
+DATA_DIR = os.getenv("DATA_DIR", "/app/data")
+
+router = APIRouter()
+
+DEFAULT_DIVISIONS = ["Starters", "Mains", "Sides", "Desserts"]
+
+
+# ── Pydantic Models ──────────────────────────────────────────────────────────
+
+class MenuCreate(BaseModel):
+ name: str
+ description: Optional[str] = None
+ notes: Optional[str] = None
+ preset_divisions: bool = False
+
+class MenuUpdate(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ notes: Optional[str] = None
+ is_active: Optional[bool] = None
+
+class DivisionCreate(BaseModel):
+ name: str
+
+class DivisionUpdate(BaseModel):
+ name: str
+
+class ItemPublish(BaseModel):
+ recipe_id: int
+ division_id: int
+ display_name: str
+ description: Optional[str] = None
+ price: Optional[Decimal] = None
+ confirmed_by_name: str
+
+class ItemUpdate(BaseModel):
+ display_name: Optional[str] = None
+ description: Optional[str] = None
+ price: Optional[Decimal] = None
+ division_id: Optional[int] = None
+
+class RepublishRequest(BaseModel):
+ confirmed_by_name: str
+
+class BulkPublishItem(BaseModel):
+ recipe_id: int
+ display_name: str
+ description: Optional[str] = None
+ price: Optional[Decimal] = None
+
+class BulkPublishRequest(BaseModel):
+ division_id: int
+ confirmed_by_name: str
+ items: list[BulkPublishItem]
+
+class BatchRepublishItem(BaseModel):
+ id: int
+ confirmed: bool = True
+
+class BatchRepublishRequest(BaseModel):
+ confirmed_by_name: str
+ items: list[BatchRepublishItem]
+
+class MenuDuplicate(BaseModel):
+ name: str
+
+class ReorderRequest(BaseModel):
+ ids: list[int]
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+async def _get_menu(menu_id: int, kitchen_id: int, db: AsyncSession) -> Menu:
+ result = await db.execute(
+ select(Menu).where(Menu.id == menu_id, Menu.kitchen_id == kitchen_id)
+ )
+ menu = result.scalar_one_or_none()
+ if not menu:
+ raise HTTPException(404, "Menu not found")
+ return menu
+
+
+async def _collect_sub_recipe_ids(recipe_id: int, db: AsyncSession, depth: int = 0) -> list[int]:
+ """Recursively collect all sub-recipe IDs in a recipe tree."""
+ if depth > 5:
+ return []
+ ids = [recipe_id]
+ sr_result = await db.execute(
+ select(RecipeSubRecipe.child_recipe_id).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
+ )
+ for (child_id,) in sr_result.fetchall():
+ ids.extend(await _collect_sub_recipe_ids(child_id, db, depth + 1))
+ return ids
+
+
+async def _compute_staleness(items: list[MenuItem], db: AsyncSession) -> dict[int, dict]:
+ """Batch compute staleness for menu items. Returns {item_id: {is_stale, stale_reason, is_archived}}."""
+ result = {}
+ recipe_ids = [item.recipe_id for item in items if item.recipe_id is not None]
+
+ if not recipe_ids:
+ for item in items:
+ result[item.id] = {
+ "is_stale": False,
+ "stale_reason": None,
+ "is_archived": item.recipe_id is None,
+ }
+ return result
+
+ # Batch load recipe updated_at and is_archived
+ recipe_result = await db.execute(
+ select(Recipe.id, Recipe.updated_at, Recipe.is_archived).where(Recipe.id.in_(recipe_ids))
+ )
+ recipe_info = {r.id: (r.updated_at, r.is_archived) for r in recipe_result.fetchall()}
+
+ # Collect all sub-recipe trees for all menu item recipes
+ all_tree_ids: dict[int, list[int]] = {}
+ for rid in recipe_ids:
+ all_tree_ids[rid] = await _collect_sub_recipe_ids(rid, db)
+
+ # Batch query max updated_at for all sub-recipes
+ all_sub_ids = set()
+ for tree in all_tree_ids.values():
+ all_sub_ids.update(tree)
+
+ sub_updated = {}
+ if all_sub_ids:
+ sub_result = await db.execute(
+ select(Recipe.id, Recipe.updated_at).where(Recipe.id.in_(list(all_sub_ids)))
+ )
+ sub_updated = {r.id: r.updated_at for r in sub_result.fetchall()}
+
+ for item in items:
+ if item.recipe_id is None:
+ result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True}
+ continue
+
+ info = recipe_info.get(item.recipe_id)
+ if not info:
+ # Recipe was fully deleted (shouldn't happen with SET NULL but handle it)
+ result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True}
+ continue
+
+ recipe_updated, recipe_archived = info
+
+ if recipe_archived:
+ result[item.id] = {"is_stale": False, "stale_reason": None, "is_archived": True}
+ continue
+
+ # Check staleness: recipe or any sub-recipe updated after publish
+ is_stale = False
+ stale_reason = None
+
+ if recipe_updated and item.published_at and recipe_updated > item.published_at:
+ is_stale = True
+ stale_reason = "Dish edited after publishing"
+
+ if not is_stale:
+ tree_ids = all_tree_ids.get(item.recipe_id, [])
+ for sub_id in tree_ids:
+ if sub_id == item.recipe_id:
+ continue
+ sub_up = sub_updated.get(sub_id)
+ if sub_up and item.published_at and sub_up > item.published_at:
+ is_stale = True
+ stale_reason = "Sub-recipe edited after publishing"
+ break
+
+ result[item.id] = {"is_stale": is_stale, "stale_reason": stale_reason, "is_archived": False}
+
+ return result
+
+
+async def _build_snapshot(item: MenuItem, flags: list, user_id: int, confirmed_by_name: str) -> dict:
+ """Build the snapshot JSON blob for a menu item at publish time."""
+ return {
+ "display_name": item.display_name,
+ "description": item.description,
+ "price": str(item.price) if item.price is not None else None,
+ "confirmed_flags": [
+ {
+ "id": f.food_flag_id,
+ "name": f.flag_name,
+ "code": f.flag_code,
+ "icon": f.flag_icon,
+ "category": f.category_name,
+ "propagation": f.propagation_type,
+ "excludable": f.excludable_on_request,
+ }
+ for f in flags if f.is_active
+ ],
+ "confirmed_by_name": confirmed_by_name,
+ "confirmed_by_user_id": user_id,
+ "published_at": datetime.utcnow().isoformat(),
+ }
+
+
+async def _check_unassessed(recipe_id: int, kitchen_id: int, db: AsyncSession) -> list[dict]:
+ """Check for unassessed ingredients in a recipe. Returns list of unassessed if any."""
+ all_ing_ids = await _collect_recipe_ingredient_ids(recipe_id, db)
+ unique_ids = list(set(all_ing_ids))
+ if not unique_ids:
+ return []
+
+ req_cat_result = await db.execute(
+ select(FoodFlagCategory.id, FoodFlagCategory.name).where(
+ FoodFlagCategory.kitchen_id == kitchen_id,
+ FoodFlagCategory.required == True,
+ )
+ )
+ required_cats = req_cat_result.all()
+ if not required_cats:
+ return []
+
+ cat_flag_map: dict[int, set[int]] = {}
+ for cat_id, _ in required_cats:
+ rf_result = await db.execute(select(FoodFlag.id).where(FoodFlag.category_id == cat_id))
+ cat_flag_map[cat_id] = set(rf_result.scalars().all())
+
+ from models.ingredient import Ingredient
+ unassessed = []
+ for ing_id in unique_ids:
+ ing_result = await db.execute(select(Ingredient.name).where(Ingredient.id == ing_id))
+ name = ing_result.scalar()
+ if not name:
+ continue
+
+ none_result = await db.execute(
+ select(IngredientFlagNone.category_id).where(IngredientFlagNone.ingredient_id == ing_id)
+ )
+ none_cat_ids = set(none_result.scalars().all())
+
+ for cat_id, cat_name in required_cats:
+ if cat_id in none_cat_ids:
+ continue
+ flag_ids = cat_flag_map.get(cat_id, set())
+ if not flag_ids:
+ continue
+ flag_count = await db.execute(
+ select(func.count(IngredientFlag.id)).where(
+ IngredientFlag.ingredient_id == ing_id,
+ IngredientFlag.food_flag_id.in_(flag_ids),
+ )
+ )
+ if flag_count.scalar() == 0:
+ unassessed.append({"id": ing_id, "name": name, "category": cat_name})
+ break
+
+ return unassessed
+
+
+# ── Menu CRUD ────────────────────────────────────────────────────────────────
+
+@router.get("")
+async def list_menus(
+ search: Optional[str] = Query(None),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """List menus with division/item counts."""
+ query = (
+ select(Menu)
+ .options(selectinload(Menu.divisions), selectinload(Menu.items))
+ .where(Menu.kitchen_id == user.kitchen_id)
+ .order_by(Menu.sort_order, Menu.name)
+ )
+ result = await db.execute(query)
+ menus = result.scalars().all()
+
+ if search:
+ search_lower = search.lower()
+ menus = [m for m in menus if search_lower in m.name.lower()]
+
+ items_list = []
+ for m in menus:
+ # Compute staleness for this menu's items
+ all_items = list(m.items) if m.items else []
+ staleness = await _compute_staleness(all_items, db) if all_items else {}
+ stale_count = sum(1 for s in staleness.values() if s.get("is_stale"))
+
+ items_list.append({
+ "id": m.id,
+ "name": m.name,
+ "description": m.description,
+ "notes": m.notes,
+ "is_active": m.is_active,
+ "sort_order": m.sort_order,
+ "division_count": len(m.divisions) if m.divisions else 0,
+ "item_count": len(all_items),
+ "stale_count": stale_count,
+ "created_at": m.created_at.isoformat() if m.created_at else None,
+ "updated_at": m.updated_at.isoformat() if m.updated_at else None,
+ })
+
+ return items_list
+
+
+@router.post("")
+async def create_menu(
+ body: MenuCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Create a new menu, optionally with preset divisions."""
+ # Check unique name
+ existing = await db.execute(
+ select(Menu).where(Menu.kitchen_id == user.kitchen_id, Menu.name == body.name)
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(400, "A menu with this name already exists")
+
+ # Get next sort_order
+ max_order = await db.execute(
+ select(func.max(Menu.sort_order)).where(Menu.kitchen_id == user.kitchen_id)
+ )
+ next_order = (max_order.scalar() or 0) + 1
+
+ menu = Menu(
+ kitchen_id=user.kitchen_id,
+ name=body.name,
+ description=body.description,
+ notes=body.notes,
+ sort_order=next_order,
+ )
+ db.add(menu)
+ await db.flush()
+
+ if body.preset_divisions:
+ for i, name in enumerate(DEFAULT_DIVISIONS):
+ db.add(MenuDivision(menu_id=menu.id, name=name, sort_order=i))
+
+ await db.commit()
+ await db.refresh(menu)
+
+ return {
+ "id": menu.id,
+ "name": menu.name,
+ "description": menu.description,
+ "notes": menu.notes,
+ "is_active": menu.is_active,
+ "sort_order": menu.sort_order,
+ }
+
+
+@router.get("/{menu_id}")
+async def get_menu(
+ menu_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Full menu detail with divisions, items, staleness, and archived indicators."""
+ result = await db.execute(
+ select(Menu)
+ .options(
+ selectinload(Menu.divisions).selectinload(MenuDivision.items),
+ selectinload(Menu.items),
+ )
+ .where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id)
+ )
+ menu = result.scalar_one_or_none()
+ if not menu:
+ raise HTTPException(404, "Menu not found")
+
+ # Compute staleness for all items
+ all_items = menu.items or []
+ staleness = await _compute_staleness(all_items, db)
+
+ # Build divisions with items
+ divisions_data = []
+ for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
+ div_items = sorted(
+ [i for i in all_items if i.division_id == div.id],
+ key=lambda i: i.sort_order,
+ )
+ items_data = []
+ for item in div_items:
+ stale_info = staleness.get(item.id, {"is_stale": False, "stale_reason": None, "is_archived": False})
+ items_data.append({
+ "id": item.id,
+ "recipe_id": item.recipe_id,
+ "display_name": item.display_name,
+ "description": item.description,
+ "price": str(item.price) if item.price is not None else None,
+ "sort_order": item.sort_order,
+ "snapshot_json": item.snapshot_json,
+ "confirmed_by_name": item.confirmed_by_name,
+ "confirmed_by_user_id": item.confirmed_by_user_id,
+ "published_at": item.published_at.isoformat() if item.published_at else None,
+ "has_image": bool(item.image_path),
+ "is_stale": stale_info["is_stale"],
+ "stale_reason": stale_info["stale_reason"],
+ "is_archived": stale_info["is_archived"],
+ })
+ divisions_data.append({
+ "id": div.id,
+ "name": div.name,
+ "sort_order": div.sort_order,
+ "items": items_data,
+ })
+
+ return {
+ "id": menu.id,
+ "name": menu.name,
+ "description": menu.description,
+ "notes": menu.notes,
+ "is_active": menu.is_active,
+ "sort_order": menu.sort_order,
+ "created_at": menu.created_at.isoformat() if menu.created_at else None,
+ "updated_at": menu.updated_at.isoformat() if menu.updated_at else None,
+ "divisions": divisions_data,
+ }
+
+
+@router.put("/{menu_id}")
+async def update_menu(
+ menu_id: int,
+ body: MenuUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Update menu name, description, notes, or active status."""
+ menu = await _get_menu(menu_id, user.kitchen_id, db)
+
+ if body.name is not None and body.name != menu.name:
+ existing = await db.execute(
+ select(Menu).where(
+ Menu.kitchen_id == user.kitchen_id,
+ Menu.name == body.name,
+ Menu.id != menu_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(400, "A menu with this name already exists")
+ menu.name = body.name
+
+ if body.description is not None:
+ menu.description = body.description
+ if body.notes is not None:
+ menu.notes = body.notes
+ if body.is_active is not None:
+ menu.is_active = body.is_active
+
+ menu.updated_at = datetime.utcnow()
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/{menu_id}")
+async def delete_menu(
+ menu_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete a menu and all its divisions/items."""
+ menu = await _get_menu(menu_id, user.kitchen_id, db)
+
+ # Clean up menu item images from disk
+ items_result = await db.execute(
+ select(MenuItem.image_path).where(MenuItem.menu_id == menu_id, MenuItem.image_path != None)
+ )
+ for (path,) in items_result.fetchall():
+ if path and os.path.exists(path):
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+
+ await db.delete(menu)
+ await db.commit()
+ return {"ok": True}
+
+
+@router.patch("/reorder")
+async def reorder_menus(
+ body: ReorderRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Reorder menus."""
+ for i, mid in enumerate(body.ids):
+ result = await db.execute(
+ select(Menu).where(Menu.id == mid, Menu.kitchen_id == user.kitchen_id)
+ )
+ m = result.scalar_one_or_none()
+ if m:
+ m.sort_order = i
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Division CRUD ────────────────────────────────────────────────────────────
+
+@router.post("/{menu_id}/divisions")
+async def add_division(
+ menu_id: int,
+ body: DivisionCreate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Add a division to a menu."""
+ menu = await _get_menu(menu_id, user.kitchen_id, db)
+
+ existing = await db.execute(
+ select(MenuDivision).where(MenuDivision.menu_id == menu_id, MenuDivision.name == body.name)
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(400, "A division with this name already exists in this menu")
+
+ max_order = await db.execute(
+ select(func.max(MenuDivision.sort_order)).where(MenuDivision.menu_id == menu_id)
+ )
+ next_order = (max_order.scalar() or 0) + 1
+
+ div = MenuDivision(menu_id=menu_id, name=body.name, sort_order=next_order)
+ db.add(div)
+ await db.commit()
+ await db.refresh(div)
+
+ return {"id": div.id, "name": div.name, "sort_order": div.sort_order}
+
+
+@router.put("/{menu_id}/divisions/{division_id}")
+async def update_division(
+ menu_id: int,
+ division_id: int,
+ body: DivisionUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Rename a division."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuDivision).where(MenuDivision.id == division_id, MenuDivision.menu_id == menu_id)
+ )
+ div = result.scalar_one_or_none()
+ if not div:
+ raise HTTPException(404, "Division not found")
+
+ # Check unique name
+ existing = await db.execute(
+ select(MenuDivision).where(
+ MenuDivision.menu_id == menu_id,
+ MenuDivision.name == body.name,
+ MenuDivision.id != division_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(400, "A division with this name already exists in this menu")
+
+ div.name = body.name
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/{menu_id}/divisions/{division_id}")
+async def delete_division(
+ menu_id: int,
+ division_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete a division and its items."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuDivision).where(MenuDivision.id == division_id, MenuDivision.menu_id == menu_id)
+ )
+ div = result.scalar_one_or_none()
+ if not div:
+ raise HTTPException(404, "Division not found")
+
+ # Clean up item images
+ items_result = await db.execute(
+ select(MenuItem.image_path).where(MenuItem.division_id == division_id, MenuItem.image_path != None)
+ )
+ for (path,) in items_result.fetchall():
+ if path and os.path.exists(path):
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+
+ await db.delete(div)
+ await db.commit()
+ return {"ok": True}
+
+
+@router.patch("/{menu_id}/divisions/reorder")
+async def reorder_divisions(
+ menu_id: int,
+ body: ReorderRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Reorder divisions within a menu."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ for i, div_id in enumerate(body.ids):
+ result = await db.execute(
+ select(MenuDivision).where(MenuDivision.id == div_id, MenuDivision.menu_id == menu_id)
+ )
+ div = result.scalar_one_or_none()
+ if div:
+ div.sort_order = i
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Publish / Items ──────────────────────────────────────────────────────────
+
+@router.post("/{menu_id}/items")
+async def publish_item(
+ menu_id: int,
+ body: ItemPublish,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Publish a dish to a menu with allergen confirmation."""
+ menu = await _get_menu(menu_id, user.kitchen_id, db)
+
+ # Verify recipe is a non-archived dish
+ recipe_result = await db.execute(
+ select(Recipe).where(
+ Recipe.id == body.recipe_id,
+ Recipe.kitchen_id == user.kitchen_id,
+ Recipe.recipe_type == "dish",
+ Recipe.is_archived == False,
+ )
+ )
+ recipe = recipe_result.scalar_one_or_none()
+ if not recipe:
+ raise HTTPException(404, "Dish not found or is archived")
+
+ # Check not already on this menu
+ existing = await db.execute(
+ select(MenuItem).where(MenuItem.menu_id == menu_id, MenuItem.recipe_id == body.recipe_id)
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(400, "This dish is already on this menu")
+
+ # Verify division belongs to this menu
+ div_result = await db.execute(
+ select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id)
+ )
+ if not div_result.scalar_one_or_none():
+ raise HTTPException(400, "Division not found in this menu")
+
+ # Check for unassessed ingredients
+ unassessed = await _check_unassessed(body.recipe_id, user.kitchen_id, db)
+ if unassessed:
+ raise HTTPException(400, detail={
+ "message": "Cannot publish: dish has unassessed ingredients",
+ "unassessed_ingredients": unassessed,
+ })
+
+ # Compute flags
+ flags = await compute_recipe_flags(body.recipe_id, user.kitchen_id, db)
+
+ # Get next sort_order
+ max_order = await db.execute(
+ select(func.max(MenuItem.sort_order)).where(
+ MenuItem.menu_id == menu_id, MenuItem.division_id == body.division_id
+ )
+ )
+ next_order = (max_order.scalar() or 0) + 1
+
+ item = MenuItem(
+ menu_id=menu_id,
+ division_id=body.division_id,
+ recipe_id=body.recipe_id,
+ display_name=body.display_name,
+ description=body.description,
+ price=body.price,
+ sort_order=next_order,
+ confirmed_by_user_id=user.id,
+ confirmed_by_name=body.confirmed_by_name,
+ published_at=datetime.utcnow(),
+ )
+
+ # Build snapshot
+ item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
+
+ db.add(item)
+ await db.commit()
+ await db.refresh(item)
+
+ return {
+ "id": item.id,
+ "display_name": item.display_name,
+ "published_at": item.published_at.isoformat() if item.published_at else None,
+ }
+
+
+@router.post("/{menu_id}/items/bulk")
+async def bulk_publish_items(
+ menu_id: int,
+ body: BulkPublishRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Bulk publish multiple dishes to a menu."""
+ menu = await _get_menu(menu_id, user.kitchen_id, db)
+
+ # Verify division
+ div_result = await db.execute(
+ select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id)
+ )
+ if not div_result.scalar_one_or_none():
+ raise HTTPException(400, "Division not found in this menu")
+
+ # Validate all dishes first
+ errors = []
+ valid_items = []
+ for bi in body.items:
+ recipe_result = await db.execute(
+ select(Recipe).where(
+ Recipe.id == bi.recipe_id,
+ Recipe.kitchen_id == user.kitchen_id,
+ Recipe.recipe_type == "dish",
+ Recipe.is_archived == False,
+ )
+ )
+ recipe = recipe_result.scalar_one_or_none()
+ if not recipe:
+ errors.append({"recipe_id": bi.recipe_id, "error": "Dish not found or is archived"})
+ continue
+
+ existing = await db.execute(
+ select(MenuItem).where(MenuItem.menu_id == menu_id, MenuItem.recipe_id == bi.recipe_id)
+ )
+ if existing.scalar_one_or_none():
+ errors.append({"recipe_id": bi.recipe_id, "error": "Already on this menu"})
+ continue
+
+ unassessed = await _check_unassessed(bi.recipe_id, user.kitchen_id, db)
+ if unassessed:
+ errors.append({
+ "recipe_id": bi.recipe_id,
+ "error": "Has unassessed ingredients",
+ "unassessed_ingredients": unassessed,
+ })
+ continue
+
+ valid_items.append((bi, recipe))
+
+ if errors:
+ raise HTTPException(400, detail={"message": "Some dishes failed validation", "errors": errors})
+
+ # All valid — publish
+ max_order = await db.execute(
+ select(func.max(MenuItem.sort_order)).where(
+ MenuItem.menu_id == menu_id, MenuItem.division_id == body.division_id
+ )
+ )
+ next_order = (max_order.scalar() or 0) + 1
+
+ created = []
+ for i, (bi, recipe) in enumerate(valid_items):
+ flags = await compute_recipe_flags(bi.recipe_id, user.kitchen_id, db)
+
+ item = MenuItem(
+ menu_id=menu_id,
+ division_id=body.division_id,
+ recipe_id=bi.recipe_id,
+ display_name=bi.display_name,
+ description=bi.description,
+ price=bi.price,
+ sort_order=next_order + i,
+ confirmed_by_user_id=user.id,
+ confirmed_by_name=body.confirmed_by_name,
+ published_at=datetime.utcnow(),
+ )
+ item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
+ db.add(item)
+ created.append(bi.recipe_id)
+
+ await db.commit()
+ return {"ok": True, "published_count": len(created), "published_recipe_ids": created}
+
+
+@router.put("/{menu_id}/items/{item_id}")
+async def update_item(
+ menu_id: int,
+ item_id: int,
+ body: ItemUpdate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Edit a menu item's display info or move to a different division."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Menu item not found")
+
+ if body.display_name is not None:
+ item.display_name = body.display_name
+ if body.description is not None:
+ item.description = body.description
+ if body.price is not None:
+ item.price = body.price
+ if body.division_id is not None:
+ div_result = await db.execute(
+ select(MenuDivision).where(MenuDivision.id == body.division_id, MenuDivision.menu_id == menu_id)
+ )
+ if not div_result.scalar_one_or_none():
+ raise HTTPException(400, "Target division not found in this menu")
+ item.division_id = body.division_id
+
+ # Update snapshot display fields
+ if item.snapshot_json:
+ snapshot = dict(item.snapshot_json)
+ if body.display_name is not None:
+ snapshot["display_name"] = body.display_name
+ if body.description is not None:
+ snapshot["description"] = body.description
+ if body.price is not None:
+ snapshot["price"] = str(body.price)
+ item.snapshot_json = snapshot
+
+ await db.commit()
+ return {"ok": True}
+
+
+@router.delete("/{menu_id}/items/{item_id}")
+async def delete_item(
+ menu_id: int,
+ item_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Remove a dish from a menu."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Menu item not found")
+
+ # Clean up image
+ if item.image_path and os.path.exists(item.image_path):
+ try:
+ os.remove(item.image_path)
+ except OSError:
+ pass
+
+ await db.delete(item)
+ await db.commit()
+ return {"ok": True}
+
+
+@router.post("/{menu_id}/items/{item_id}/republish")
+async def republish_item(
+ menu_id: int,
+ item_id: int,
+ body: RepublishRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Republish a menu item — re-confirm allergens and update snapshot."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Menu item not found")
+
+ if item.recipe_id is None:
+ raise HTTPException(400, "Cannot republish: dish has been archived or deleted")
+
+ # Check unassessed
+ unassessed = await _check_unassessed(item.recipe_id, user.kitchen_id, db)
+ if unassessed:
+ raise HTTPException(400, detail={
+ "message": "Cannot republish: dish has unassessed ingredients",
+ "unassessed_ingredients": unassessed,
+ })
+
+ # Recompute flags
+ flags = await compute_recipe_flags(item.recipe_id, user.kitchen_id, db)
+
+ item.confirmed_by_user_id = user.id
+ item.confirmed_by_name = body.confirmed_by_name
+ item.published_at = datetime.utcnow()
+ item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
+
+ await db.commit()
+ return {"ok": True, "published_at": item.published_at.isoformat()}
+
+
+@router.patch("/{menu_id}/items/reorder")
+async def reorder_items(
+ menu_id: int,
+ body: ReorderRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Reorder items within a menu."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ for i, item_id in enumerate(body.ids):
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if item:
+ item.sort_order = i
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Batch Republish ──────────────────────────────────────────────────────────
+
+@router.post("/{menu_id}/republish-stale")
+async def batch_republish_stale(
+ menu_id: int,
+ body: BatchRepublishRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Batch republish stale items on a menu."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ results = []
+ for bi in body.items:
+ if not bi.confirmed:
+ results.append({"id": bi.id, "status": "skipped"})
+ continue
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == bi.id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ results.append({"id": bi.id, "status": "error", "message": "Item not found"})
+ continue
+
+ if item.recipe_id is None:
+ results.append({"id": bi.id, "status": "error", "message": "Dish archived"})
+ continue
+
+ unassessed = await _check_unassessed(item.recipe_id, user.kitchen_id, db)
+ if unassessed:
+ results.append({
+ "id": bi.id, "status": "blocked",
+ "message": "Has unassessed ingredients",
+ "unassessed_ingredients": unassessed,
+ })
+ continue
+
+ flags = await compute_recipe_flags(item.recipe_id, user.kitchen_id, db)
+ item.confirmed_by_user_id = user.id
+ item.confirmed_by_name = body.confirmed_by_name
+ item.published_at = datetime.utcnow()
+ item.snapshot_json = await _build_snapshot(item, flags, user.id, body.confirmed_by_name)
+ results.append({"id": bi.id, "status": "republished"})
+
+ await db.commit()
+ return {"results": results}
+
+
+# ── Menu Item Image ──────────────────────────────────────────────────────────
+
+@router.post("/{menu_id}/items/{item_id}/image")
+async def upload_item_image(
+ menu_id: int,
+ item_id: int,
+ file: UploadFile = File(...),
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Upload a customer-quality image for a menu item."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Menu item not found")
+
+ # Remove old image if exists
+ if item.image_path and os.path.exists(item.image_path):
+ try:
+ os.remove(item.image_path)
+ except OSError:
+ pass
+
+ # Save new image
+ img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "menus")
+ os.makedirs(img_dir, exist_ok=True)
+
+ ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
+ filename = f"{uuid.uuid4()}{ext}"
+ filepath = os.path.join(img_dir, filename)
+
+ content = await file.read()
+ with open(filepath, "wb") as f:
+ f.write(content)
+
+ item.image_path = filepath
+ item.uploaded_by = user.id
+
+ # Update snapshot with image indicator
+ if item.snapshot_json:
+ snapshot = dict(item.snapshot_json)
+ snapshot["has_image"] = True
+ item.snapshot_json = snapshot
+
+ await db.commit()
+ return {"ok": True}
+
+
+@router.get("/{menu_id}/items/{item_id}/image")
+async def serve_item_image(
+ menu_id: int,
+ item_id: int,
+ current_user=Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item or not item.image_path:
+ raise HTTPException(404, "Image not found")
+
+ if not os.path.exists(item.image_path):
+ raise HTTPException(404, "Image file not found")
+
+ return FileResponse(item.image_path)
+
+
+@router.delete("/{menu_id}/items/{item_id}/image")
+async def delete_item_image(
+ menu_id: int,
+ item_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Remove a menu item's image."""
+ await _get_menu(menu_id, user.kitchen_id, db)
+
+ result = await db.execute(
+ select(MenuItem).where(MenuItem.id == item_id, MenuItem.menu_id == menu_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ raise HTTPException(404, "Menu item not found")
+
+ if item.image_path and os.path.exists(item.image_path):
+ try:
+ os.remove(item.image_path)
+ except OSError:
+ pass
+
+ item.image_path = None
+ item.uploaded_by = None
+
+ if item.snapshot_json:
+ snapshot = dict(item.snapshot_json)
+ snapshot.pop("has_image", None)
+ item.snapshot_json = snapshot
+
+ await db.commit()
+ return {"ok": True}
+
+
+# ── Menu Duplication ─────────────────────────────────────────────────────────
+
+@router.post("/{menu_id}/duplicate")
+async def duplicate_menu(
+ menu_id: int,
+ body: MenuDuplicate,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Clone a menu with new name, copying divisions and items (not images)."""
+ menu = await _get_menu(menu_id, user.kitchen_id, db)
+
+ # Check unique name
+ existing = await db.execute(
+ select(Menu).where(Menu.kitchen_id == user.kitchen_id, Menu.name == body.name)
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(400, "A menu with this name already exists")
+
+ # Load full menu
+ result = await db.execute(
+ select(Menu)
+ .options(selectinload(Menu.divisions).selectinload(MenuDivision.items))
+ .where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id)
+ )
+ source = result.scalar_one_or_none()
+
+ max_order = await db.execute(
+ select(func.max(Menu.sort_order)).where(Menu.kitchen_id == user.kitchen_id)
+ )
+ next_order = (max_order.scalar() or 0) + 1
+
+ new_menu = Menu(
+ kitchen_id=user.kitchen_id,
+ name=body.name,
+ description=source.description,
+ notes=source.notes,
+ is_active=False, # Start inactive
+ sort_order=next_order,
+ )
+ db.add(new_menu)
+ await db.flush()
+
+ # Copy divisions and items
+ for div in sorted(source.divisions or [], key=lambda d: d.sort_order):
+ new_div = MenuDivision(menu_id=new_menu.id, name=div.name, sort_order=div.sort_order)
+ db.add(new_div)
+ await db.flush()
+
+ for item in sorted(div.items or [], key=lambda i: i.sort_order):
+ new_item = MenuItem(
+ menu_id=new_menu.id,
+ division_id=new_div.id,
+ recipe_id=item.recipe_id,
+ display_name=item.display_name,
+ description=item.description,
+ price=item.price,
+ sort_order=item.sort_order,
+ snapshot_json=item.snapshot_json,
+ confirmed_by_user_id=item.confirmed_by_user_id,
+ confirmed_by_name=item.confirmed_by_name,
+ published_at=item.published_at,
+ # image_path not copied — new menu needs own images
+ )
+ db.add(new_item)
+
+ await db.commit()
+ return {"id": new_menu.id, "name": new_menu.name}
+
+
+# ── Flag Matrix (for print) ─────────────────────────────────────────────────
+
+@router.get("/{menu_id}/flags")
+async def get_menu_flag_matrix(
+ menu_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Consolidated flag matrix for all items on a menu (for print view)."""
+ result = await db.execute(
+ select(Menu)
+ .options(
+ selectinload(Menu.divisions).selectinload(MenuDivision.items),
+ selectinload(Menu.items),
+ )
+ .where(Menu.id == menu_id, Menu.kitchen_id == user.kitchen_id)
+ )
+ menu = result.scalar_one_or_none()
+ if not menu:
+ raise HTTPException(404, "Menu not found")
+
+ # Get all flag categories and flags for this kitchen
+ cat_result = await db.execute(
+ select(FoodFlagCategory)
+ .options(selectinload(FoodFlagCategory.flags))
+ .where(FoodFlagCategory.kitchen_id == user.kitchen_id)
+ .order_by(FoodFlagCategory.sort_order)
+ )
+ categories = cat_result.scalars().all()
+
+ all_flags = []
+ for cat in categories:
+ for f in sorted(cat.flags, key=lambda x: x.sort_order):
+ all_flags.append({
+ "id": f.id,
+ "name": f.name,
+ "code": f.code,
+ "icon": f.icon,
+ "category_id": cat.id,
+ "category_name": cat.name,
+ })
+
+ # Build matrix from snapshots
+ divisions_data = []
+ for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
+ div_items = sorted(
+ [i for i in (menu.items or []) if i.division_id == div.id],
+ key=lambda i: i.sort_order,
+ )
+ items_matrix = []
+ for item in div_items:
+ snapshot_flags = {}
+ if item.snapshot_json and "confirmed_flags" in item.snapshot_json:
+ snapshot_flags = {f["id"]: True for f in item.snapshot_json["confirmed_flags"]}
+
+ flag_cells = {}
+ for f in all_flags:
+ flag_cells[str(f["id"])] = snapshot_flags.get(f["id"], False)
+
+ items_matrix.append({
+ "id": item.id,
+ "display_name": item.display_name,
+ "flags": flag_cells,
+ })
+
+ divisions_data.append({
+ "name": div.name,
+ "items": items_matrix,
+ })
+
+ return {
+ "menu_name": menu.name,
+ "all_flags": all_flags,
+ "divisions": divisions_data,
+ }
+
+
+# ── Dish-on-menu lookup (for DishEditor/DishList indicators) ─────────────────
+
+@router.get("/dish/{recipe_id}/menus")
+async def get_dish_menus(
+ recipe_id: int,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get list of menus a dish is published on."""
+ result = await db.execute(
+ select(MenuItem.menu_id, Menu.name, Menu.is_active)
+ .join(Menu, MenuItem.menu_id == Menu.id)
+ .where(
+ MenuItem.recipe_id == recipe_id,
+ Menu.kitchen_id == user.kitchen_id,
+ )
+ )
+ rows = result.fetchall()
+ return [
+ {"menu_id": r.menu_id, "menu_name": r.name, "is_active": r.is_active}
+ for r in rows
+ ]
+
+
+# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
+class GenerateDescriptionRequest(BaseModel):
+ recipe_id: int
+ recipe_name: str
+ ingredients: list[str] = []
+ allergen_flags: list[str] = []
+ steps_summary: Optional[str] = None
+
+
+@router.post("/generate-description")
+async def generate_description(
+ body: GenerateDescriptionRequest,
+ user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Generate a customer-facing menu description using AI."""
+ from services.llm_service import generate_menu_description
+
+ result = await generate_menu_description(
+ db=db,
+ kitchen_id=user.kitchen_id,
+ recipe_name=body.recipe_name,
+ ingredients=body.ingredients,
+ allergen_flags=body.allergen_flags,
+ steps_summary=body.steps_summary,
+ )
+
+ return {
+ "llm_status": result["status"],
+ "description": result.get("description"),
+ "error": result.get("error"),
+ }
diff --git a/backend/api/newbook.py b/backend/api/newbook.py
new file mode 100644
index 0000000..985b1e0
--- /dev/null
+++ b/backend/api/newbook.py
@@ -0,0 +1,1016 @@
+"""
+Newbook API Endpoints
+
+Handles Newbook configuration, GL account management, and data sync operations.
+"""
+import logging
+from datetime import date, datetime, timedelta
+
+logger = logging.getLogger(__name__)
+from decimal import Decimal
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, delete
+from pydantic import BaseModel
+
+from database import get_db
+from models.user import User
+from models.settings import KitchenSettings
+from models.newbook import (
+ NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog, NewbookRoomCategory
+)
+from models.resos import ResosBooking, ResosOpeningHour
+from auth import get_current_user, require_cap
+from services.newbook_sync import NewbookSyncService
+from services.newbook_api import NewbookAPIClient, NewbookAPIError
+
+router = APIRouter()
+
+
+# ============ Pydantic Schemas ============
+
+class NewbookSettingsResponse(BaseModel):
+ newbook_api_username: str | None
+ newbook_api_password_set: bool
+ newbook_api_key_set: bool
+ newbook_api_region: str | None
+ newbook_instance_id: str | None
+ newbook_last_sync: datetime | None
+ newbook_auto_sync_enabled: bool
+ newbook_upcoming_sync_enabled: bool
+ newbook_upcoming_sync_interval: int
+ newbook_last_upcoming_sync: datetime | None
+ newbook_breakfast_gl_codes: str | None
+ newbook_dinner_gl_codes: str | None
+ newbook_breakfast_vat_rate: Decimal | None
+ newbook_dinner_vat_rate: Decimal | None
+
+ class Config:
+ from_attributes = True
+
+
+class NewbookSettingsUpdate(BaseModel):
+ newbook_api_username: str | None = None
+ newbook_api_password: str | None = None
+ newbook_api_key: str | None = None
+ newbook_api_region: str | None = None # au, ap, eu, us
+ newbook_instance_id: str | None = None
+ newbook_auto_sync_enabled: bool | None = None
+ newbook_upcoming_sync_enabled: bool | None = None
+ newbook_upcoming_sync_interval: int | None = None
+ newbook_breakfast_gl_codes: str | None = None
+ newbook_dinner_gl_codes: str | None = None
+ newbook_breakfast_vat_rate: Decimal | None = None
+ newbook_dinner_vat_rate: Decimal | None = None
+
+
+class GLAccountResponse(BaseModel):
+ id: int
+ gl_account_id: str
+ gl_code: str | None
+ gl_name: str
+ gl_type: str | None
+ gl_group_id: str | None
+ gl_group_name: str | None
+ is_tracked: bool
+ display_order: int
+
+ class Config:
+ from_attributes = True
+
+
+class GLAccountUpdate(BaseModel):
+ is_tracked: bool
+ display_order: int | None = None
+
+
+class GLAccountBulkUpdateItem(BaseModel):
+ id: int
+ is_tracked: bool
+ display_order: int | None = None
+
+
+class GLAccountBulkUpdate(BaseModel):
+ updates: list[GLAccountBulkUpdateItem]
+
+
+class RoomCategoryResponse(BaseModel):
+ id: int
+ site_id: str
+ site_name: str
+ site_type: str | None
+ room_count: int = 0
+ is_included: bool
+ display_order: int
+
+ class Config:
+ from_attributes = True
+
+
+class RoomCategoryUpdate(BaseModel):
+ is_included: bool
+ display_order: int | None = None
+
+
+class RoomCategoryBulkUpdateItem(BaseModel):
+ id: int
+ is_included: bool
+ display_order: int | None = None
+
+
+class RoomCategoryBulkUpdate(BaseModel):
+ updates: list[RoomCategoryBulkUpdateItem]
+
+
+class DailyRevenueResponse(BaseModel):
+ date: date
+ gl_account_id: int
+ gl_account_name: str
+ amount_net: Decimal
+
+ class Config:
+ from_attributes = True
+
+
+class RevenueSummaryResponse(BaseModel):
+ start_date: date
+ end_date: date
+ total_revenue: Decimal
+ by_account: list[dict] # {gl_account_name, total}
+ by_date: list[dict] # {date, total}
+
+
+class OccupancyResponse(BaseModel):
+ date: date
+ total_rooms: int | None
+ occupied_rooms: int | None
+ occupancy_percentage: Decimal | None
+ total_guests: int | None
+ breakfast_allocation_qty: int | None
+ breakfast_allocation_netvalue: Decimal | None
+ dinner_allocation_qty: int | None
+ dinner_allocation_netvalue: Decimal | None
+ is_forecast: bool
+
+ class Config:
+ from_attributes = True
+
+
+class SyncLogResponse(BaseModel):
+ id: int
+ sync_type: str
+ started_at: datetime
+ completed_at: datetime | None
+ status: str
+ records_fetched: int
+ error_message: str | None
+ date_from: date | None
+ date_to: date | None
+
+ class Config:
+ from_attributes = True
+
+
+class HistoricalSyncRequest(BaseModel):
+ date_from: date
+ date_to: date
+
+
+class CalendarDayData(BaseModel):
+ date: date
+ has_data: bool
+ is_forecast: bool
+ # Occupancy
+ total_rooms: int | None = None
+ occupied_rooms: int | None = None
+ occupancy_percentage: Decimal | None = None
+ total_guests: int | None = None
+ # Meal allocations
+ breakfast_allocation_qty: int | None = None
+ breakfast_allocation_netvalue: Decimal | None = None
+ dinner_allocation_qty: int | None = None
+ dinner_allocation_netvalue: Decimal | None = None
+ # Revenue
+ total_revenue: Decimal | None = None
+ revenue_by_account: list[dict] | None = None # [{gl_name, amount}]
+
+
+class CalendarDataResponse(BaseModel):
+ year: int
+ month: int
+ days: list[CalendarDayData]
+
+
+# ============ Settings Endpoints ============
+
+@router.get("/settings", response_model=NewbookSettingsResponse)
+async def get_newbook_settings(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get Newbook API settings"""
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ return NewbookSettingsResponse(
+ newbook_api_username=settings.newbook_api_username,
+ newbook_api_password_set=bool(settings.newbook_api_password),
+ newbook_api_key_set=bool(settings.newbook_api_key),
+ newbook_api_region=settings.newbook_api_region,
+ newbook_instance_id=settings.newbook_instance_id,
+ newbook_last_sync=settings.newbook_last_sync,
+ newbook_auto_sync_enabled=settings.newbook_auto_sync_enabled,
+ newbook_upcoming_sync_enabled=settings.newbook_upcoming_sync_enabled,
+ newbook_upcoming_sync_interval=settings.newbook_upcoming_sync_interval,
+ newbook_last_upcoming_sync=settings.newbook_last_upcoming_sync,
+ newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes,
+ newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes,
+ newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate,
+ newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate
+ )
+
+
+@router.patch("/settings", response_model=NewbookSettingsResponse)
+async def update_newbook_settings(
+ update: NewbookSettingsUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update Newbook API settings"""
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ # Validate region if provided
+ if update.newbook_api_region and update.newbook_api_region not in ["au", "ap", "eu", "us"]:
+ raise HTTPException(status_code=400, detail="Invalid region. Must be: au, ap, eu, us")
+
+ # Update fields
+ update_data = update.model_dump(exclude_unset=True)
+ logger.info(f"Newbook settings update for kitchen {current_user.kitchen_id}: {update_data}")
+
+ for field, value in update_data.items():
+ if value is not None:
+ old_value = getattr(settings, field, None)
+ setattr(settings, field, value)
+ if field == 'newbook_upcoming_sync_enabled':
+ logger.info(f"Newbook upcoming sync enabled changed: {old_value} -> {value}")
+
+ await db.commit()
+ await db.refresh(settings)
+
+ logger.info(f"Newbook settings saved - upcoming_sync_enabled={settings.newbook_upcoming_sync_enabled}")
+
+ return NewbookSettingsResponse(
+ newbook_api_username=settings.newbook_api_username,
+ newbook_api_password_set=bool(settings.newbook_api_password),
+ newbook_api_key_set=bool(settings.newbook_api_key),
+ newbook_api_region=settings.newbook_api_region,
+ newbook_instance_id=settings.newbook_instance_id,
+ newbook_last_sync=settings.newbook_last_sync,
+ newbook_auto_sync_enabled=settings.newbook_auto_sync_enabled,
+ newbook_upcoming_sync_enabled=settings.newbook_upcoming_sync_enabled,
+ newbook_upcoming_sync_interval=settings.newbook_upcoming_sync_interval,
+ newbook_last_upcoming_sync=settings.newbook_last_upcoming_sync,
+ newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes,
+ newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes,
+ newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate,
+ newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate
+ )
+
+
+@router.get("/debug-upcoming-sync")
+async def debug_upcoming_sync(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Debug endpoint to check upcoming sync setting in database"""
+ from sqlalchemy import text
+
+ # Get value from SQLAlchemy model
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ # Also get raw value from database
+ raw_result = await db.execute(
+ text("SELECT newbook_upcoming_sync_enabled, newbook_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"),
+ {"kid": current_user.kitchen_id}
+ )
+ raw_row = raw_result.fetchone()
+
+ return {
+ "kitchen_id": current_user.kitchen_id,
+ "model_value": settings.newbook_upcoming_sync_enabled if settings else None,
+ "model_interval": settings.newbook_upcoming_sync_interval if settings else None,
+ "raw_db_enabled": raw_row[0] if raw_row else "column_not_found",
+ "raw_db_interval": raw_row[1] if raw_row else "column_not_found",
+ "last_sync": settings.newbook_last_upcoming_sync.isoformat() if settings and settings.newbook_last_upcoming_sync else None
+ }
+
+
+@router.post("/test-connection")
+async def test_newbook_connection(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Test Newbook API connection"""
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings:
+ raise HTTPException(status_code=404, detail="Settings not found")
+
+ if not all([
+ settings.newbook_api_username,
+ settings.newbook_api_password,
+ settings.newbook_api_key,
+ settings.newbook_api_region
+ ]):
+ raise HTTPException(status_code=400, detail="Newbook credentials not fully configured")
+
+ try:
+ async with NewbookAPIClient(
+ username=settings.newbook_api_username,
+ password=settings.newbook_api_password,
+ api_key=settings.newbook_api_key,
+ region=settings.newbook_api_region,
+ instance_id=settings.newbook_instance_id
+ ) as client:
+ success = await client.test_connection()
+
+ if success:
+ return {"status": "success", "message": "Newbook connection successful"}
+ else:
+ raise HTTPException(status_code=400, detail="Connection test failed")
+
+ except NewbookAPIError as e:
+ raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}")
+
+
+# ============ GL Account Endpoints ============
+
+@router.get("/gl-accounts", response_model=list[GLAccountResponse])
+async def list_gl_accounts(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """List all GL accounts for this kitchen, sorted by group then name"""
+ result = await db.execute(
+ select(NewbookGLAccount)
+ .where(NewbookGLAccount.kitchen_id == current_user.kitchen_id)
+ .order_by(NewbookGLAccount.gl_group_name, NewbookGLAccount.display_order, NewbookGLAccount.gl_name)
+ )
+ return list(result.scalars().all())
+
+
+@router.post("/gl-accounts/fetch")
+async def fetch_gl_accounts(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Fetch/refresh GL accounts from Newbook API"""
+ try:
+ sync_service = NewbookSyncService(db, current_user.kitchen_id)
+ accounts = await sync_service.sync_gl_accounts()
+
+ return {
+ "status": "success",
+ "message": f"Fetched {len(accounts)} GL accounts",
+ "count": len(accounts)
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except NewbookAPIError as e:
+ raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}")
+
+
+@router.patch("/gl-accounts/bulk-update")
+async def bulk_update_gl_accounts(
+ request: GLAccountBulkUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Bulk update GL account selections"""
+ updated = 0
+ for upd in request.updates:
+ result = await db.execute(
+ select(NewbookGLAccount).where(
+ NewbookGLAccount.id == upd.id,
+ NewbookGLAccount.kitchen_id == current_user.kitchen_id
+ )
+ )
+ account = result.scalar_one_or_none()
+ if account:
+ account.is_tracked = upd.is_tracked
+ if upd.display_order is not None:
+ account.display_order = upd.display_order
+ updated += 1
+
+ await db.commit()
+ return {"status": "success", "updated": updated}
+
+
+@router.patch("/gl-accounts/{account_id}", response_model=GLAccountResponse)
+async def update_gl_account(
+ account_id: int,
+ update: GLAccountUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update GL account tracking settings"""
+ result = await db.execute(
+ select(NewbookGLAccount).where(
+ NewbookGLAccount.id == account_id,
+ NewbookGLAccount.kitchen_id == current_user.kitchen_id
+ )
+ )
+ account = result.scalar_one_or_none()
+
+ if not account:
+ raise HTTPException(status_code=404, detail="GL account not found")
+
+ account.is_tracked = update.is_tracked
+ if update.display_order is not None:
+ account.display_order = update.display_order
+
+ await db.commit()
+ await db.refresh(account)
+ return account
+
+
+# ============ Room Category Endpoints ============
+
+@router.get("/room-categories", response_model=list[RoomCategoryResponse])
+async def get_room_categories(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get all room categories for the kitchen"""
+ result = await db.execute(
+ select(NewbookRoomCategory)
+ .where(NewbookRoomCategory.kitchen_id == current_user.kitchen_id)
+ .order_by(NewbookRoomCategory.display_order, NewbookRoomCategory.site_name)
+ )
+ return list(result.scalars().all())
+
+
+@router.post("/room-categories/fetch")
+async def fetch_room_categories(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Fetch room categories from Newbook API and store in database"""
+ # Get settings
+ result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = result.scalar_one_or_none()
+
+ if not settings or not settings.newbook_api_username:
+ raise HTTPException(status_code=400, detail="Newbook credentials not configured")
+
+ try:
+ async with NewbookAPIClient(
+ username=settings.newbook_api_username,
+ password=settings.newbook_api_password,
+ api_key=settings.newbook_api_key,
+ region=settings.newbook_api_region or "au",
+ instance_id=settings.newbook_instance_id
+ ) as client:
+ categories = await client.get_site_list()
+
+ # Delete all existing room categories for this kitchen (fresh replace)
+ await db.execute(
+ delete(NewbookRoomCategory).where(
+ NewbookRoomCategory.kitchen_id == current_user.kitchen_id
+ )
+ )
+
+ # Insert fresh aggregated room types
+ for cat in categories:
+ new_cat = NewbookRoomCategory(
+ kitchen_id=current_user.kitchen_id,
+ site_id=cat["id"],
+ site_name=cat["name"],
+ site_type=cat.get("type"),
+ room_count=cat.get("count", 0),
+ is_included=True, # Default to included
+ )
+ db.add(new_cat)
+
+ await db.commit()
+
+ return {
+ "status": "success",
+ "count": len(categories)
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except NewbookAPIError as e:
+ raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}")
+
+
+@router.patch("/room-categories/bulk-update")
+async def bulk_update_room_categories(
+ request: RoomCategoryBulkUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Bulk update room category selections"""
+ updated = 0
+ for upd in request.updates:
+ result = await db.execute(
+ select(NewbookRoomCategory).where(
+ NewbookRoomCategory.id == upd.id,
+ NewbookRoomCategory.kitchen_id == current_user.kitchen_id
+ )
+ )
+ category = result.scalar_one_or_none()
+ if category:
+ category.is_included = upd.is_included
+ if upd.display_order is not None:
+ category.display_order = upd.display_order
+ updated += 1
+
+ await db.commit()
+ return {"status": "success", "updated": updated}
+
+
+@router.patch("/room-categories/{category_id}", response_model=RoomCategoryResponse)
+async def update_room_category(
+ category_id: int,
+ update: RoomCategoryUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Update room category settings"""
+ result = await db.execute(
+ select(NewbookRoomCategory).where(
+ NewbookRoomCategory.id == category_id,
+ NewbookRoomCategory.kitchen_id == current_user.kitchen_id
+ )
+ )
+ category = result.scalar_one_or_none()
+
+ if not category:
+ raise HTTPException(status_code=404, detail="Room category not found")
+
+ category.is_included = update.is_included
+ if update.display_order is not None:
+ category.display_order = update.display_order
+
+ await db.commit()
+ await db.refresh(category)
+ return category
+
+
+# ============ Revenue Endpoints ============
+
+@router.get("/revenue", response_model=list[DailyRevenueResponse])
+async def get_revenue(
+ start_date: date,
+ end_date: date,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get daily revenue data for a date range"""
+ result = await db.execute(
+ select(NewbookDailyRevenue, NewbookGLAccount.gl_name)
+ .join(NewbookGLAccount)
+ .where(
+ NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
+ NewbookDailyRevenue.date >= start_date,
+ NewbookDailyRevenue.date <= end_date,
+ NewbookGLAccount.is_tracked == True
+ )
+ .order_by(NewbookDailyRevenue.date, NewbookGLAccount.display_order)
+ )
+
+ revenue_data = []
+ for rev, gl_name in result.all():
+ revenue_data.append(DailyRevenueResponse(
+ date=rev.date,
+ gl_account_id=rev.gl_account_id,
+ gl_account_name=gl_name,
+ amount_net=rev.amount_net
+ ))
+
+ return revenue_data
+
+
+@router.get("/revenue/summary", response_model=RevenueSummaryResponse)
+async def get_revenue_summary(
+ start_date: date,
+ end_date: date,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get aggregated revenue summary for a date range"""
+ # Total revenue
+ total_result = await db.execute(
+ select(func.sum(NewbookDailyRevenue.amount_net))
+ .join(NewbookGLAccount)
+ .where(
+ NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
+ NewbookDailyRevenue.date >= start_date,
+ NewbookDailyRevenue.date <= end_date,
+ NewbookGLAccount.is_tracked == True
+ )
+ )
+ total_revenue = total_result.scalar() or Decimal("0.00")
+
+ # By account
+ by_account_result = await db.execute(
+ select(NewbookGLAccount.gl_name, func.sum(NewbookDailyRevenue.amount_net))
+ .join(NewbookGLAccount)
+ .where(
+ NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
+ NewbookDailyRevenue.date >= start_date,
+ NewbookDailyRevenue.date <= end_date,
+ NewbookGLAccount.is_tracked == True
+ )
+ .group_by(NewbookGLAccount.gl_name)
+ .order_by(func.sum(NewbookDailyRevenue.amount_net).desc())
+ )
+ by_account = [{"gl_account_name": name, "total": float(total)} for name, total in by_account_result.all()]
+
+ # By date
+ by_date_result = await db.execute(
+ select(NewbookDailyRevenue.date, func.sum(NewbookDailyRevenue.amount_net))
+ .join(NewbookGLAccount)
+ .where(
+ NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
+ NewbookDailyRevenue.date >= start_date,
+ NewbookDailyRevenue.date <= end_date,
+ NewbookGLAccount.is_tracked == True
+ )
+ .group_by(NewbookDailyRevenue.date)
+ .order_by(NewbookDailyRevenue.date)
+ )
+ by_date = [{"date": d.isoformat(), "total": float(total)} for d, total in by_date_result.all()]
+
+ return RevenueSummaryResponse(
+ start_date=start_date,
+ end_date=end_date,
+ total_revenue=total_revenue,
+ by_account=by_account,
+ by_date=by_date
+ )
+
+
+# ============ Occupancy Endpoints ============
+
+@router.get("/occupancy", response_model=list[OccupancyResponse])
+async def get_occupancy(
+ start_date: date,
+ end_date: date,
+ include_forecast: bool = True,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get occupancy data for a date range"""
+ query = select(NewbookDailyOccupancy).where(
+ NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
+ NewbookDailyOccupancy.date >= start_date,
+ NewbookDailyOccupancy.date <= end_date
+ )
+
+ if not include_forecast:
+ query = query.where(NewbookDailyOccupancy.is_forecast == False)
+
+ query = query.order_by(NewbookDailyOccupancy.date)
+ result = await db.execute(query)
+
+ return list(result.scalars().all())
+
+
+# ============ Calendar Data Endpoint ============
+
+@router.get("/calendar/{year}/{month}", response_model=CalendarDataResponse)
+async def get_calendar_data(
+ year: int,
+ month: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get calendar view data for a specific month"""
+ import calendar
+ from datetime import date as date_type
+ from collections import defaultdict
+
+ # Calculate month date range
+ _, last_day = calendar.monthrange(year, month)
+ month_start = date_type(year, month, 1)
+ month_end = date_type(year, month, last_day)
+ today = date_type.today()
+
+ # Fetch occupancy data for the month
+ occupancy_result = await db.execute(
+ select(NewbookDailyOccupancy)
+ .where(
+ NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
+ NewbookDailyOccupancy.date >= month_start,
+ NewbookDailyOccupancy.date <= month_end
+ )
+ )
+ occupancy_by_date = {occ.date: occ for occ in occupancy_result.scalars().all()}
+
+ # Fetch revenue data for the month (grouped by date and GL account)
+ revenue_result = await db.execute(
+ select(
+ NewbookDailyRevenue.date,
+ NewbookGLAccount.gl_name,
+ NewbookDailyRevenue.amount_net
+ )
+ .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id)
+ .where(
+ NewbookDailyRevenue.kitchen_id == current_user.kitchen_id,
+ NewbookDailyRevenue.date >= month_start,
+ NewbookDailyRevenue.date <= month_end,
+ NewbookGLAccount.is_tracked == True
+ )
+ .order_by(NewbookDailyRevenue.date, NewbookGLAccount.gl_name)
+ )
+
+ # Group revenue by date
+ revenue_by_date = defaultdict(list)
+ for row_date, gl_name, amount in revenue_result.all():
+ revenue_by_date[row_date].append({
+ "gl_name": gl_name,
+ "amount": float(amount)
+ })
+
+ # Build calendar days
+ days = []
+ for day in range(1, last_day + 1):
+ current_date = date_type(year, month, day)
+ occupancy = occupancy_by_date.get(current_date)
+ revenue_entries = revenue_by_date.get(current_date, [])
+
+ has_data = occupancy is not None or len(revenue_entries) > 0
+
+ # Determine if forecast: today or future date, or marked as forecast in occupancy
+ # Today is considered "current" (updatable), not historical (locked)
+ is_forecast = current_date >= today
+ if occupancy and occupancy.is_forecast:
+ is_forecast = True
+
+ # Calculate total revenue for the day
+ total_revenue = sum(entry["amount"] for entry in revenue_entries) if revenue_entries else None
+
+ day_data = CalendarDayData(
+ date=current_date,
+ has_data=has_data,
+ is_forecast=is_forecast,
+ total_rooms=occupancy.total_rooms if occupancy else None,
+ occupied_rooms=occupancy.occupied_rooms if occupancy else None,
+ occupancy_percentage=occupancy.occupancy_percentage if occupancy else None,
+ total_guests=occupancy.total_guests if occupancy else None,
+ breakfast_allocation_qty=occupancy.breakfast_allocation_qty if occupancy else None,
+ breakfast_allocation_netvalue=occupancy.breakfast_allocation_netvalue if occupancy else None,
+ dinner_allocation_qty=occupancy.dinner_allocation_qty if occupancy else None,
+ dinner_allocation_netvalue=occupancy.dinner_allocation_netvalue if occupancy else None,
+ total_revenue=Decimal(str(total_revenue)) if total_revenue else None,
+ revenue_by_account=revenue_entries if revenue_entries else None
+ )
+ days.append(day_data)
+
+ return CalendarDataResponse(
+ year=year,
+ month=month,
+ days=days
+ )
+
+
+# ============ Sync Endpoints ============
+
+@router.post("/sync/forecast")
+async def sync_forecast_data(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Sync forecast period data (next ~2 months)"""
+ try:
+ sync_service = NewbookSyncService(db, current_user.kitchen_id)
+ results = await sync_service.sync_forecast_period()
+
+ return {
+ "status": "success",
+ "message": "Forecast data synced",
+ "results": results
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except NewbookAPIError as e:
+ raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}")
+
+
+@router.post("/sync/historical")
+async def sync_historical_data(
+ request: HistoricalSyncRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Sync historical data for specific date range"""
+ if request.date_from > request.date_to:
+ raise HTTPException(status_code=400, detail="date_from must be before date_to")
+
+ if (request.date_to - request.date_from).days > 365:
+ raise HTTPException(status_code=400, detail="Date range cannot exceed 1 year")
+
+ try:
+ sync_service = NewbookSyncService(db, current_user.kitchen_id)
+ results = await sync_service.sync_historical_range(request.date_from, request.date_to)
+
+ return {
+ "status": "success",
+ "message": f"Historical data synced for {request.date_from} to {request.date_to}",
+ "results": results
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except NewbookAPIError as e:
+ raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}")
+
+
+@router.get("/sync/logs", response_model=list[SyncLogResponse])
+async def get_sync_logs(
+ limit: int = 20,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get recent sync logs"""
+ result = await db.execute(
+ select(NewbookSyncLog)
+ .where(NewbookSyncLog.kitchen_id == current_user.kitchen_id)
+ .order_by(NewbookSyncLog.started_at.desc())
+ .limit(limit)
+ )
+ return list(result.scalars().all())
+
+
+# ============ Dashboard Endpoints ============
+
+class ArrivalDayStats(BaseModel):
+ date: date
+ day_name: str # "Today", "Tomorrow", day of week
+ arrival_count: int
+ arrival_guests: int
+ table_bookings: int
+ table_covers: int
+ matched_arrivals: int # Arrivals with table bookings
+ unmatched_arrivals: int # Arrivals without table bookings
+ opportunity_guests: int # Guests from unmatched arrivals
+
+
+class ArrivalDashboardResponse(BaseModel):
+ days: list[ArrivalDayStats]
+ service_filter_name: str | None = None # Name of service type being filtered (e.g., "Dinner")
+
+
+@router.get("/dashboard/arrivals", response_model=ArrivalDashboardResponse)
+async def get_arrival_dashboard(
+ days: int = 3,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db)
+):
+ """Get arrival statistics for dashboard widget (next N days)"""
+ today = date.today()
+ end_date = today + timedelta(days=days - 1)
+
+ # Fetch kitchen settings to get service filter
+ settings_result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
+ )
+ settings = settings_result.scalar_one_or_none()
+ service_filter_type = settings.resos_arrival_widget_service_filter if settings else None
+
+ # Get opening hour IDs that match the service type filter
+ service_filter_name = None
+ opening_hour_ids = []
+
+ if service_filter_type and settings and settings.resos_opening_hours_mapping:
+ # Find all opening hours mapped to this service type
+ for mapping in settings.resos_opening_hours_mapping:
+ if isinstance(mapping, dict) and mapping.get("service_type") == service_filter_type:
+ opening_hour_ids.append(mapping.get("resos_id"))
+
+ # Set display name to capitalized service type
+ if opening_hour_ids:
+ service_filter_name = service_filter_type.capitalize()
+
+ # Fetch occupancy data with arrival info
+ occupancy_result = await db.execute(
+ select(NewbookDailyOccupancy)
+ .where(
+ NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
+ NewbookDailyOccupancy.date >= today,
+ NewbookDailyOccupancy.date <= end_date
+ )
+ .order_by(NewbookDailyOccupancy.date)
+ )
+ occupancy_by_date = {occ.date: occ for occ in occupancy_result.scalars().all()}
+
+ # Fetch restaurant bookings for the same period (with optional service filter)
+ # Note: We fetch ALL bookings (not just guest-linked ones) to show total booking count
+ bookings_query = select(ResosBooking).where(
+ ResosBooking.kitchen_id == current_user.kitchen_id,
+ ResosBooking.booking_date >= today,
+ ResosBooking.booking_date <= end_date
+ )
+
+ # Apply service filter if set - filter by ANY opening hour that matches the service type
+ if opening_hour_ids:
+ bookings_query = bookings_query.where(ResosBooking.opening_hour_id.in_(opening_hour_ids))
+
+ bookings_result = await db.execute(bookings_query)
+ bookings = list(bookings_result.scalars().all())
+
+ # Group bookings by date
+ bookings_by_date = {}
+ for booking in bookings:
+ if booking.booking_date not in bookings_by_date:
+ bookings_by_date[booking.booking_date] = []
+ bookings_by_date[booking.booking_date].append(booking)
+
+ # Build stats for each day
+ days_stats = []
+ day_names = ["Today", "Tomorrow"]
+
+ for i in range(days):
+ current_date = today + timedelta(days=i)
+ occupancy = occupancy_by_date.get(current_date)
+ day_bookings = bookings_by_date.get(current_date, [])
+
+ # Day name
+ if i < len(day_names):
+ day_name = day_names[i]
+ else:
+ day_name = current_date.strftime("%A") # Day of week
+
+ # Default values
+ arrival_count = 0
+ arrival_guests = 0
+ matched_arrivals = 0
+ unmatched_arrivals = 0
+ opportunity_guests = 0
+
+ if occupancy and occupancy.arrival_count:
+ arrival_count = occupancy.arrival_count or 0
+ arrival_details = occupancy.arrival_booking_details or []
+
+ # Calculate total guests from arrivals
+ arrival_guests = sum(detail.get("num_guests", 0) for detail in arrival_details)
+
+ # Get hotel booking numbers from Resos
+ hotel_refs = {b.hotel_booking_number for b in day_bookings if b.hotel_booking_number}
+
+ # Match arrivals with table bookings
+ matched = []
+ unmatched = []
+
+ for detail in arrival_details:
+ booking_ref = detail.get("booking_reference", "")
+ booking_id = detail.get("booking_id", "")
+
+ # Check if this arrival has a matching table booking
+ if booking_ref in hotel_refs or booking_id in hotel_refs:
+ matched.append(detail)
+ else:
+ unmatched.append(detail)
+
+ matched_arrivals = len(matched)
+ unmatched_arrivals = len(unmatched)
+ opportunity_guests = sum(detail.get("num_guests", 0) for detail in unmatched)
+
+ # Table booking stats
+ table_bookings = len(day_bookings)
+ table_covers = sum(b.people for b in day_bookings)
+
+ days_stats.append(ArrivalDayStats(
+ date=current_date,
+ day_name=day_name,
+ arrival_count=arrival_count,
+ arrival_guests=arrival_guests,
+ table_bookings=table_bookings,
+ table_covers=table_covers,
+ matched_arrivals=matched_arrivals,
+ unmatched_arrivals=unmatched_arrivals,
+ opportunity_guests=opportunity_guests
+ ))
+
+ return ArrivalDashboardResponse(days=days_stats, service_filter_name=service_filter_name)
diff --git a/backend/api/public.py b/backend/api/public.py
new file mode 100644
index 0000000..7f92f96
--- /dev/null
+++ b/backend/api/public.py
@@ -0,0 +1,118 @@
+"""
+Public API endpoints - NO AUTHENTICATION REQUIRED.
+
+These endpoints are designed for sharing with external parties (e.g., suppliers)
+via hash-based URLs that don't require login.
+"""
+import os
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import Response
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from fastapi import Depends
+
+from database import get_db
+from models.dispute import DisputeAttachment
+from models.settings import KitchenSettings
+from services.nextcloud_service import NextcloudService
+
+router = APIRouter()
+
+
+@router.get("/attachments/{public_hash}")
+async def get_public_attachment(
+ public_hash: str,
+ db: AsyncSession = Depends(get_db)
+):
+ """
+ View a dispute attachment publicly via its hash.
+
+ This endpoint does NOT require authentication, allowing suppliers
+ to view attached images/documents via shareable links in emails.
+ """
+ # Find attachment by public hash
+ result = await db.execute(
+ select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
+ )
+ attachment = result.scalar_one_or_none()
+
+ if not attachment:
+ raise HTTPException(status_code=404, detail="Attachment not found")
+
+ # Get file content
+ content = None
+
+ # Try local file first
+ if attachment.file_storage_location == "local" and attachment.file_path:
+ if os.path.exists(attachment.file_path):
+ with open(attachment.file_path, 'rb') as f:
+ content = f.read()
+
+ # Try Nextcloud if local not found
+ if content is None and attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
+ # Get kitchen settings for Nextcloud credentials
+ settings_result = await db.execute(
+ select(KitchenSettings).where(KitchenSettings.kitchen_id == attachment.kitchen_id)
+ )
+ settings = settings_result.scalar_one_or_none()
+
+ if settings and settings.nextcloud_enabled:
+ nc = NextcloudService(
+ settings.nextcloud_host,
+ settings.nextcloud_username,
+ settings.nextcloud_password,
+ ""
+ )
+ success, nc_content = await nc.download_file(attachment.nextcloud_path)
+ await nc.close()
+
+ if success:
+ content = nc_content
+
+ if content is None:
+ raise HTTPException(status_code=404, detail="File not found")
+
+ # Determine if browser should display inline or download
+ # Images and PDFs display inline, others download
+ inline_types = [
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
+ 'application/pdf'
+ ]
+
+ disposition = "inline" if attachment.file_type in inline_types else "attachment"
+
+ return Response(
+ content=content,
+ media_type=attachment.file_type,
+ headers={
+ "Content-Disposition": f'{disposition}; filename="{attachment.file_name}"',
+ "Cache-Control": "private, max-age=3600" # Cache for 1 hour
+ }
+ )
+
+
+@router.get("/attachments/{public_hash}/info")
+async def get_public_attachment_info(
+ public_hash: str,
+ db: AsyncSession = Depends(get_db)
+):
+ """
+ Get attachment metadata without downloading the file.
+ Useful for email previews or link unfurling.
+ """
+ result = await db.execute(
+ select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
+ )
+ attachment = result.scalar_one_or_none()
+
+ if not attachment:
+ raise HTTPException(status_code=404, detail="Attachment not found")
+
+ return {
+ "file_name": attachment.file_name,
+ "file_type": attachment.file_type,
+ "file_size_bytes": attachment.file_size_bytes,
+ "attachment_type": attachment.attachment_type,
+ "description": attachment.description,
+ "uploaded_at": attachment.uploaded_at.isoformat() if attachment.uploaded_at else None
+ }
diff --git a/backend/api/purchase_orders.py b/backend/api/purchase_orders.py
new file mode 100644
index 0000000..d6275e1
--- /dev/null
+++ b/backend/api/purchase_orders.py
@@ -0,0 +1,835 @@
+"""
+Purchase Order API endpoints — full CRUD, attachment, product search, budget view,
+preview (HTML), and email sending.
+"""
+import os
+import uuid
+import logging
+from datetime import date
+from decimal import Decimal
+from typing import Optional
+from html import escape as html_escape
+
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
+from fastapi.responses import HTMLResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, and_, or_, func, delete
+from sqlalchemy.orm import selectinload
+from pydantic import BaseModel, field_serializer
+
+from database import get_db
+from models.user import User, Kitchen
+from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
+from models.supplier import Supplier
+from models.settings import KitchenSettings
+from models.line_item import LineItem
+from models.invoice import Invoice
+from auth import get_current_user, require_cap, get_current_user_from_token
+from services.email_service import EmailService
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+UPLOAD_DIR = "data/po_attachments"
+
+# ── Pydantic schemas ──────────────────────────────────────────────────────────
+
+class LineItemIn(BaseModel):
+ product_id: Optional[int] = None
+ product_code: Optional[str] = None
+ description: str
+ unit: Optional[str] = None
+ unit_price: Decimal
+ quantity: Decimal
+ total: Decimal
+ line_number: int = 0
+ source: str = "manual"
+
+
+class PurchaseOrderCreate(BaseModel):
+ supplier_id: int
+ order_date: date
+ order_type: str # 'itemised' or 'single_value'
+ total_amount: Optional[Decimal] = None
+ order_reference: Optional[str] = None
+ notes: Optional[str] = None
+ status: str = "DRAFT"
+ line_items: list[LineItemIn] = []
+
+
+class PurchaseOrderUpdate(BaseModel):
+ supplier_id: Optional[int] = None
+ order_date: Optional[date] = None
+ order_type: Optional[str] = None
+ total_amount: Optional[Decimal] = None
+ order_reference: Optional[str] = None
+ notes: Optional[str] = None
+ status: Optional[str] = None
+ line_items: Optional[list[LineItemIn]] = None
+
+
+class StatusUpdate(BaseModel):
+ status: str
+
+
+class LineItemOut(BaseModel):
+ id: int
+ product_id: Optional[int]
+ product_code: Optional[str]
+ description: str
+ unit: Optional[str]
+ unit_price: Decimal
+ quantity: Decimal
+ total: Decimal
+ line_number: int
+ source: str
+
+ @field_serializer('unit_price', 'quantity', 'total')
+ def ser(self, v: Decimal) -> float:
+ return float(v)
+
+
+class PurchaseOrderOut(BaseModel):
+ id: int
+ kitchen_id: int
+ supplier_id: int
+ supplier_name: Optional[str] = None
+ order_date: date
+ order_type: str
+ status: str
+ total_amount: Optional[Decimal]
+ order_reference: Optional[str]
+ notes: Optional[str]
+ attachment_path: Optional[str]
+ attachment_original_name: Optional[str]
+ linked_invoice_id: Optional[int]
+ created_by: int
+ created_by_name: Optional[str] = None
+ created_at: str
+ updated_at: str
+ line_items: list[LineItemOut] = []
+
+ @field_serializer('total_amount')
+ def ser_amount(self, v: Optional[Decimal]) -> Optional[float]:
+ return float(v) if v is not None else None
+
+
+class BudgetPO(BaseModel):
+ id: int
+ order_type: str
+ status: str
+ total_amount: Optional[float]
+ order_reference: Optional[str]
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def po_to_out(po: PurchaseOrder) -> PurchaseOrderOut:
+ return PurchaseOrderOut(
+ id=po.id,
+ kitchen_id=po.kitchen_id,
+ supplier_id=po.supplier_id,
+ supplier_name=po.supplier.name if po.supplier else None,
+ order_date=po.order_date,
+ order_type=po.order_type,
+ status=po.status,
+ total_amount=po.total_amount,
+ order_reference=po.order_reference,
+ notes=po.notes,
+ attachment_path=po.attachment_path,
+ attachment_original_name=po.attachment_original_name,
+ linked_invoice_id=po.linked_invoice_id,
+ created_by=po.created_by,
+ created_by_name=po.created_by_user.name if po.created_by_user else None,
+ created_at=po.created_at.isoformat() if po.created_at else "",
+ updated_at=po.updated_at.isoformat() if po.updated_at else "",
+ line_items=[
+ LineItemOut(
+ id=li.id,
+ product_id=li.product_id,
+ product_code=li.product_code,
+ description=li.description,
+ unit=li.unit,
+ unit_price=li.unit_price,
+ quantity=li.quantity,
+ total=li.total,
+ line_number=li.line_number,
+ source=li.source,
+ )
+ for li in (po.line_items or [])
+ ],
+ )
+
+
+def _calc_itemised_total(items: list[LineItemIn]) -> Decimal:
+ return sum((i.total for i in items), Decimal("0"))
+
+
+async def _load_po(db: AsyncSession, po_id: int, kitchen_id: int) -> PurchaseOrder:
+ result = await db.execute(
+ select(PurchaseOrder)
+ .where(
+ PurchaseOrder.id == po_id,
+ PurchaseOrder.kitchen_id == kitchen_id,
+ )
+ .options(
+ selectinload(PurchaseOrder.line_items),
+ selectinload(PurchaseOrder.supplier),
+ selectinload(PurchaseOrder.created_by_user),
+ )
+ )
+ po = result.scalar_one_or_none()
+ if not po:
+ raise HTTPException(status_code=404, detail="Purchase order not found")
+ return po
+
+
+# ── CRUD Endpoints ────────────────────────────────────────────────────────────
+
+@router.post("/", response_model=PurchaseOrderOut)
+async def create_purchase_order(
+ data: PurchaseOrderCreate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ # Validate supplier belongs to this kitchen
+ sup = await db.execute(
+ select(Supplier).where(
+ Supplier.id == data.supplier_id,
+ Supplier.kitchen_id == current_user.kitchen_id,
+ )
+ )
+ if not sup.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Invalid supplier")
+
+ total = data.total_amount if data.order_type == "single_value" else _calc_itemised_total(data.line_items)
+
+ po = PurchaseOrder(
+ kitchen_id=current_user.kitchen_id,
+ supplier_id=data.supplier_id,
+ order_date=data.order_date,
+ order_type=data.order_type,
+ status=data.status if data.status in ("DRAFT", "PENDING") else "DRAFT",
+ total_amount=total,
+ order_reference=data.order_reference,
+ notes=data.notes,
+ created_by=current_user.id,
+ updated_by=current_user.id,
+ )
+ db.add(po)
+ await db.flush()
+
+ for idx, li in enumerate(data.line_items):
+ db.add(PurchaseOrderLineItem(
+ purchase_order_id=po.id,
+ kitchen_id=current_user.kitchen_id,
+ product_id=li.product_id,
+ product_code=li.product_code,
+ description=li.description,
+ unit=li.unit,
+ unit_price=li.unit_price,
+ quantity=li.quantity,
+ total=li.total,
+ line_number=li.line_number or idx,
+ source=li.source,
+ ))
+
+ await db.commit()
+ return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
+
+
+@router.get("/", response_model=list[PurchaseOrderOut])
+async def list_purchase_orders(
+ status: Optional[str] = None,
+ supplier_id: Optional[int] = None,
+ date_from: Optional[date] = None,
+ date_to: Optional[date] = None,
+ limit: int = 100,
+ offset: int = 0,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ q = (
+ select(PurchaseOrder)
+ .where(PurchaseOrder.kitchen_id == current_user.kitchen_id)
+ .options(
+ selectinload(PurchaseOrder.line_items),
+ selectinload(PurchaseOrder.supplier),
+ selectinload(PurchaseOrder.created_by_user),
+ )
+ .order_by(PurchaseOrder.order_date.desc(), PurchaseOrder.id.desc())
+ )
+
+ if status:
+ statuses = [s.strip().upper() for s in status.split(",")]
+ q = q.where(PurchaseOrder.status.in_(statuses))
+ if supplier_id:
+ q = q.where(PurchaseOrder.supplier_id == supplier_id)
+ if date_from:
+ q = q.where(PurchaseOrder.order_date >= date_from)
+ if date_to:
+ q = q.where(PurchaseOrder.order_date <= date_to)
+
+ q = q.offset(offset).limit(limit)
+ result = await db.execute(q)
+ return [po_to_out(po) for po in result.scalars().all()]
+
+
+@router.get("/products/search")
+async def search_products_for_po(
+ query: str,
+ supplier_id: Optional[int] = None,
+ limit: int = 20,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Search products from invoice line items, optionally filtered by supplier."""
+ q = (
+ select(
+ LineItem.description,
+ LineItem.product_code,
+ LineItem.unit,
+ LineItem.unit_price,
+ Supplier.name.label("supplier_name"),
+ Invoice.supplier_id.label("sup_id"),
+ )
+ .join(Invoice, LineItem.invoice_id == Invoice.id)
+ .outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
+ .where(
+ and_(
+ Invoice.kitchen_id == current_user.kitchen_id,
+ or_(
+ LineItem.description.ilike(f"%{query}%"),
+ LineItem.product_code.ilike(f"%{query}%"),
+ ),
+ )
+ )
+ .order_by(Invoice.invoice_date.desc())
+ )
+
+ if supplier_id:
+ q = q.where(Invoice.supplier_id == supplier_id)
+
+ q = q.limit(limit * 3)
+ result = await db.execute(q)
+ rows = result.all()
+
+ seen: set[str] = set()
+ products = []
+ for row in rows:
+ key = (row.description or "").lower()
+ if key and key not in seen:
+ seen.add(key)
+ products.append({
+ "id": 0,
+ "name": row.description,
+ "product_code": row.product_code,
+ "supplier_name": row.supplier_name,
+ "unit": row.unit,
+ "last_price": float(row.unit_price) if row.unit_price else None,
+ })
+ if len(products) >= limit:
+ break
+
+ return products
+
+
+@router.get("/by-date")
+async def get_pos_by_date(
+ week_start: date,
+ week_end: date,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """POs grouped by supplier_id → date for budget table integration."""
+ result = await db.execute(
+ select(PurchaseOrder)
+ .where(
+ PurchaseOrder.kitchen_id == current_user.kitchen_id,
+ PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
+ PurchaseOrder.order_date >= week_start,
+ PurchaseOrder.order_date <= week_end,
+ )
+ .options(selectinload(PurchaseOrder.line_items))
+ )
+ pos = result.scalars().all()
+
+ grouped: dict[int, dict[str, list]] = {}
+ for po in pos:
+ sid = po.supplier_id
+ ds = po.order_date.isoformat()
+ if sid not in grouped:
+ grouped[sid] = {}
+ if ds not in grouped[sid]:
+ grouped[sid][ds] = []
+ grouped[sid][ds].append(BudgetPO(
+ id=po.id,
+ order_type=po.order_type,
+ status=po.status,
+ total_amount=float(po.total_amount) if po.total_amount else None,
+ order_reference=po.order_reference,
+ ).model_dump())
+
+ return grouped
+
+
+@router.get("/{po_id}", response_model=PurchaseOrderOut)
+async def get_purchase_order(
+ po_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ return po_to_out(await _load_po(db, po_id, current_user.kitchen_id))
+
+
+@router.put("/{po_id}", response_model=PurchaseOrderOut)
+async def update_purchase_order(
+ po_id: int,
+ data: PurchaseOrderUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ po = await _load_po(db, po_id, current_user.kitchen_id)
+
+ if po.status in ("LINKED", "CLOSED", "CANCELLED"):
+ raise HTTPException(status_code=400, detail=f"Cannot edit PO with status {po.status}")
+
+ if data.supplier_id is not None:
+ po.supplier_id = data.supplier_id
+ if data.order_date is not None:
+ po.order_date = data.order_date
+ if data.order_type is not None:
+ po.order_type = data.order_type
+ if data.order_reference is not None:
+ po.order_reference = data.order_reference
+ if data.notes is not None:
+ po.notes = data.notes
+ if data.status is not None and data.status in ("DRAFT", "PENDING"):
+ po.status = data.status
+
+ # Replace line items if provided
+ if data.line_items is not None:
+ await db.execute(
+ delete(PurchaseOrderLineItem).where(
+ PurchaseOrderLineItem.purchase_order_id == po.id
+ )
+ )
+ for idx, li in enumerate(data.line_items):
+ db.add(PurchaseOrderLineItem(
+ purchase_order_id=po.id,
+ kitchen_id=current_user.kitchen_id,
+ product_id=li.product_id,
+ product_code=li.product_code,
+ description=li.description,
+ unit=li.unit,
+ unit_price=li.unit_price,
+ quantity=li.quantity,
+ total=li.total,
+ line_number=li.line_number or idx,
+ source=li.source,
+ ))
+
+ # Recalculate total
+ if po.order_type == "single_value":
+ if data.total_amount is not None:
+ po.total_amount = data.total_amount
+ else:
+ items = data.line_items if data.line_items is not None else []
+ po.total_amount = _calc_itemised_total(items) if items else po.total_amount
+
+ po.updated_by = current_user.id
+ await db.commit()
+ return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
+
+
+@router.delete("/{po_id}")
+async def delete_purchase_order(
+ po_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ po = await _load_po(db, po_id, current_user.kitchen_id)
+ allowed_statuses = ("DRAFT", "CANCELLED", "PENDING") if current_user.is_admin else ("DRAFT", "CANCELLED")
+ if po.status not in allowed_statuses:
+ raise HTTPException(status_code=400, detail="Only DRAFT or CANCELLED POs can be deleted")
+
+ await db.delete(po)
+ await db.commit()
+ return {"ok": True}
+
+
+@router.put("/{po_id}/status", response_model=PurchaseOrderOut)
+async def update_po_status(
+ po_id: int,
+ data: StatusUpdate,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ po = await _load_po(db, po_id, current_user.kitchen_id)
+ allowed = {
+ "DRAFT": ["PENDING", "CANCELLED"],
+ "PENDING": ["DRAFT", "CLOSED", "CANCELLED"],
+ "LINKED": ["CLOSED"],
+ "CLOSED": [],
+ "CANCELLED": ["DRAFT"],
+ }
+ if data.status not in allowed.get(po.status, []):
+ raise HTTPException(
+ status_code=400,
+ detail=f"Cannot change status from {po.status} to {data.status}",
+ )
+ po.status = data.status
+ po.updated_by = current_user.id
+ await db.commit()
+ return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
+
+
+# ── Attachment ────────────────────────────────────────────────────────────────
+
+@router.post("/{po_id}/attachment", response_model=PurchaseOrderOut)
+async def upload_attachment(
+ po_id: int,
+ file: UploadFile = File(...),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ po = await _load_po(db, po_id, current_user.kitchen_id)
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
+
+ ext = os.path.splitext(file.filename or "file")[1]
+ filename = f"{uuid.uuid4().hex}{ext}"
+ filepath = os.path.join(UPLOAD_DIR, filename)
+
+ contents = await file.read()
+ with open(filepath, "wb") as f:
+ f.write(contents)
+
+ po.attachment_path = filepath
+ po.attachment_original_name = file.filename
+ po.updated_by = current_user.id
+ await db.commit()
+ return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
+
+
+@router.delete("/{po_id}/attachment", response_model=PurchaseOrderOut)
+async def remove_attachment(
+ po_id: int,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ po = await _load_po(db, po_id, current_user.kitchen_id)
+ if po.attachment_path and os.path.exists(po.attachment_path):
+ os.remove(po.attachment_path)
+ po.attachment_path = None
+ po.attachment_original_name = None
+ po.updated_by = current_user.id
+ await db.commit()
+ return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
+
+
+# ── Preview & Email ──────────────────────────────────────────────────────────
+
+def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str = "£") -> str:
+ """Generate a clean HTML page for PO preview / email body."""
+ supplier = po.supplier
+ esc = html_escape
+
+ # Kitchen letterhead
+ kitchen_name = esc(kitchen.kitchen_display_name or "")
+ addr_parts = [
+ kitchen.kitchen_address_line1,
+ kitchen.kitchen_address_line2,
+ kitchen.kitchen_city,
+ kitchen.kitchen_postcode,
+ ]
+ addr_html = "
".join(esc(p) for p in addr_parts if p)
+ kitchen_phone = esc(kitchen.kitchen_phone or "")
+ kitchen_email = esc(kitchen.kitchen_email or "")
+
+ # Supplier details
+ supplier_name = esc(supplier.name) if supplier else "Unknown"
+ account_number = esc(supplier.account_number or "") if supplier else ""
+
+ # PO metadata
+ po_number = f"PO-{po.id}"
+ order_date = po.order_date.strftime("%d/%m/%Y") if po.order_date else ""
+ notes = esc(po.notes or "").replace("\n", "
") if po.notes else ""
+
+ # Line items table
+ items_html = ""
+ if po.order_type == "itemised" and po.line_items:
+ rows = ""
+ for li in sorted(po.line_items, key=lambda x: x.line_number):
+ rows += f"""
| Code | +Description | +Unit | +Price | +Qty | +Total | +
|---|
{' | '.join(parts)}
" + + kitchen_font_size = "font-size:16px;" if format == "kitchen" else "" + + return f""" + + + ++ {recipe_type.upper()} + {f'Yield: {output_qty:g}{output_unit}' if recipe_type == 'component' and batch_output_type == 'bulk' else (f'Batch: {batch} portions' if recipe_type == 'component' else '')} +
+ {time_info} + {f'{description}
' if description else ''} +| Ingredient | +Quantity | +Cost | +
|---|
Printed {date.today().strftime("%d/%m/%Y")}
'} + +""" + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +# Unit conversion factors to a common base (g for weight, ml for volume) +_UNIT_TO_BASE = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} + +# Which units are compatible (same measurement type) +_COMPATIBLE_UNITS = { + "g": ("g", "kg"), "kg": ("g", "kg"), + "ml": ("ml", "ltr"), "ltr": ("ml", "ltr"), + "portion": ("portion",), "each": ("each",), +} + + +def _convert_unit(value: float, from_unit: str, to_unit: str) -> float: + """Convert a value between compatible units. Returns original value if incompatible.""" + if from_unit == to_unit: + return value + from_base = _UNIT_TO_BASE.get(from_unit) + to_base = _UNIT_TO_BASE.get(to_unit) + if from_base is None or to_base is None: + return value + # Check compatibility + if to_unit not in _COMPATIBLE_UNITS.get(from_unit, ()): + return value + return value * from_base / to_base + + +def _get_compatible_units(unit: str) -> list[str]: + """Get list of compatible units for a given output unit.""" + return list(_COMPATIBLE_UNITS.get(unit, (unit,))) + + +def _get_output_qty(recipe) -> float: + """Unified output quantity: bulk uses yield_qty, portioned uses batch_portions.""" + if recipe.batch_output_type == "bulk" and recipe.batch_yield_qty: + return float(recipe.batch_yield_qty) + return recipe.batch_portions or 1 + +def _get_output_unit(recipe) -> str: + """Unified output unit label: bulk uses yield_unit, portioned uses 'portion'.""" + if recipe.batch_output_type == "bulk" and recipe.batch_yield_unit: + return recipe.batch_yield_unit + return "portion" + +async def _get_recipe(recipe_id: int, kitchen_id: int, db: AsyncSession) -> Recipe: + result = await db.execute( + select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == kitchen_id) + ) + recipe = result.scalar_one_or_none() + if not recipe: + raise HTTPException(404, "Recipe not found") + return recipe diff --git a/backend/api/reconciliation.py b/backend/api/reconciliation.py new file mode 100644 index 0000000..5948012 --- /dev/null +++ b/backend/api/reconciliation.py @@ -0,0 +1,1187 @@ +""" +Xero ↔ Flash Purchases Reconciliation + +Accepts a Xero "Account Transactions" XLSX export, parses it, +matches rows against Flash invoices for the same period, and +returns a four-section reconciliation report. + +Matching is rule-based first (exact → near-match), with an +optional LLM pass for remaining unmatched items. +""" +from datetime import date, timedelta +from decimal import Decimal, InvalidOperation +from typing import Optional +import io +import re +import logging +import json + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from pydantic import BaseModel + +from database import get_db +from models.user import User +from models.invoice import Invoice, InvoiceStatus +from models.supplier import Supplier +from auth import get_current_user, require_cap + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============ Pydantic Response Models ============ + +class ReconcileMatchedItem(BaseModel): + date: str + supplier: str + ref: str + amount: str # formatted £ + flash_id: int | None = None + match_source: str = "rule" # "rule" or "ai" + +class ReconcileDiscrepancyItem(BaseModel): + date: str + supplier: str + ref: str + flash_amount: str + xero_amount: str + difference: str + differs: list[str] # which fields differ: "amount", "date", "ref" + flash_id: int | None = None + flash_date: str | None = None + xero_date: str | None = None + flash_ref: str | None = None + xero_ref: str | None = None + flash_supplier: str | None = None + xero_description: str | None = None + match_source: str = "rule" # "rule" or "ai" + amount_insight: str | None = None # explanation for amount discrepancy + +class ReconcileUnmatchedFlash(BaseModel): + date: str + supplier: str + ref: str + net_stock: str + flash_id: int + +class ReconcileUnmatchedXero(BaseModel): + date: str + description: str + ref: str + net: str + is_expected_external: bool = False + +class ReconcileResponse(BaseModel): + period_start: str + period_end: str + flash_total: str + xero_total: str + difference: str + matched_count: int + discrepancy_count: int + flash_only_count: int + xero_only_count: int + non_stock_excluded_count: int + non_stock_excluded_total: str + matched: list[ReconcileMatchedItem] + discrepancies: list[ReconcileDiscrepancyItem] + flash_only: list[ReconcileUnmatchedFlash] + xero_only: list[ReconcileUnmatchedXero] + llm_matches_attempted: bool = False + + +# ============ Supplier alias map for Xero description → Flash supplier ============ + +# Xero descriptions often look like "Supplier Name - food" or "Supplier - food crn" +# This maps known Xero description variants to the Flash supplier name +SUPPLIER_ALIASES = { + "j hall & son (bakers) ltd": "Halls", + "j hall & son bakers ltd": "Halls", + "halls": "Halls", + "r & d walker ltd": "R&D Walker", + "r&d walker ltd": "R&D Walker", + "r&d walker": "R&D Walker", + "lambournes of stow-on-the-wold": "Lambournes", + "lambournes": "Lambournes", + "bramleys": "Bramleys", + "cotswold coffee": "Cotswold Coffee", + "brakes": "Brakes", + "direct seafoods": "Direct Seafoods", +} + +# Xero-only entries that are expected (not from Flash) — collapsible sub-section +EXPECTED_EXTERNAL_PATTERNS = [ + "tesco", + "revenue jv", + "journal", + "internal", + "petty", +] + + +# ============ Helpers ============ + +def normalise_ref(ref: str | None) -> str: + """Normalise a reference for comparison: uppercase, strip whitespace/leading #, collapse spaces.""" + if not ref: + return "" + r = ref.strip().upper() + r = r.lstrip("#") + r = re.sub(r"\s+", " ", r).strip() + return r + + +def extract_supplier_from_xero_desc(description: str) -> str: + """ + Extract supplier name from Xero description. + Xero format: "Supplier Name - food", "Supplier - food crn", etc. + """ + desc = description.strip() + # Strip trailing " - food", " - food crn", " - beverage", etc. + desc = re.sub(r"\s*-\s*(food|beverage|cleaning|sundry)(\s+crn)?\s*$", "", desc, flags=re.IGNORECASE) + return desc.strip() + + +def normalise_supplier(name: str) -> str: + """Normalise supplier name to lowercase for comparison.""" + return name.strip().lower() + + +def resolve_supplier(xero_desc: str) -> str: + """Resolve a Xero description to a canonical supplier name.""" + extracted = extract_supplier_from_xero_desc(xero_desc) + norm = normalise_supplier(extracted) + if norm in SUPPLIER_ALIASES: + return SUPPLIER_ALIASES[norm] + return extracted + + +def is_expected_external(xero_desc: str) -> bool: + """Check if a Xero entry is expected to be outside Flash.""" + desc_lower = xero_desc.lower() + return any(pat in desc_lower for pat in EXPECTED_EXTERNAL_PATTERNS) + + +def levenshtein(s1: str, s2: str) -> int: + """Compute Levenshtein distance between two strings.""" + if len(s1) < len(s2): + return levenshtein(s2, s1) + if len(s2) == 0: + return len(s1) + prev_row = list(range(len(s2) + 1)) + for i, c1 in enumerate(s1): + curr_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = prev_row[j + 1] + 1 + deletions = curr_row[j] + 1 + substitutions = prev_row[j] + (c1 != c2) + curr_row.append(min(insertions, deletions, substitutions)) + prev_row = curr_row + return prev_row[-1] + + +def alphanumeric_only(s: str) -> str: + """Strip non-alphanumeric characters for fuzzy ref comparison.""" + return re.sub(r"[^A-Z0-9]", "", s.upper()) + + +def amounts_match(a: Decimal, b: Decimal, tolerance: Decimal = Decimal("0.01")) -> bool: + return abs(a - b) <= tolerance + + +def fmt_money(d: Decimal) -> str: + """Format a decimal as £X,XXX.XX""" + return f"£{d:,.2f}" + + +def parse_xero_date(val) -> date | None: + """Parse a date from an openpyxl cell value.""" + from datetime import datetime + if isinstance(val, datetime): + return val.date() + if isinstance(val, date): + return val + if isinstance(val, str): + val = val.strip() + for fmt in ("%d %b %Y", "%d/%m/%Y", "%Y-%m-%d", "%d %B %Y"): + try: + return datetime.strptime(val, fmt).date() + except ValueError: + continue + return None + + +def parse_xero_amount(val) -> Decimal: + """Parse a numeric cell to Decimal, returning 0 for None/empty.""" + if val is None: + return Decimal("0") + if isinstance(val, (int, float)): + return Decimal(str(val)).quantize(Decimal("0.01")) + if isinstance(val, str): + val = val.strip().replace(",", "").replace("£", "") + if not val or val == "-": + return Decimal("0") + try: + return Decimal(val).quantize(Decimal("0.01")) + except InvalidOperation: + return Decimal("0") + return Decimal("0") + + +# ============ XLSX Parsing ============ + +def parse_xero_xlsx(file_bytes: bytes) -> tuple[date, date, list[dict]]: + """ + Parse a Xero Account Transactions XLSX export. + + Returns (period_start, period_end, rows) where each row is: + { + "date": date, + "description": str, + "ref": str, + "debit": Decimal, + "credit": Decimal, + "net": Decimal, # debit - credit + } + """ + from openpyxl import load_workbook + + wb = load_workbook(filename=io.BytesIO(file_bytes), read_only=True, data_only=True) + ws = wb.active + + # Extract period from header rows (typically row 3) + period_start = None + period_end = None + for row_idx in range(1, 6): + cell_val = ws.cell(row=row_idx, column=1).value + if cell_val and isinstance(cell_val, str) and "period" in cell_val.lower(): + # "For the period 1 March 2026 to 31 March 2026" + match = re.search( + r"(\d{1,2}\s+\w+\s+\d{4})\s+to\s+(\d{1,2}\s+\w+\s+\d{4})", + cell_val, re.IGNORECASE + ) + if match: + from datetime import datetime + for fmt in ("%d %B %Y", "%d %b %Y"): + try: + period_start = datetime.strptime(match.group(1), fmt).date() + period_end = datetime.strptime(match.group(2), fmt).date() + break + except ValueError: + continue + break + + if not period_start or not period_end: + raise ValueError("Could not extract report period from XLSX header rows. Expected 'For the period DD Month YYYY to DD Month YYYY' in rows 1-5.") + + # Find header row (look for "Date" in column A) + header_row = None + for row_idx in range(1, 10): + cell_val = ws.cell(row=row_idx, column=1).value + if cell_val and isinstance(cell_val, str) and cell_val.strip().lower() == "date": + header_row = row_idx + break + + if not header_row: + raise ValueError("Could not find column header row (expected 'Date' in column A within first 10 rows).") + + # Map column headers + col_map = {} + for col_idx in range(1, 20): + val = ws.cell(row=header_row, column=col_idx).value + if val and isinstance(val, str): + col_map[val.strip().lower()] = col_idx + + required = ["date", "description", "debit", "credit"] + for req in required: + if req not in col_map: + raise ValueError(f"Missing required column '{req}' in header row {header_row}. Found: {list(col_map.keys())}") + + ref_col = col_map.get("reference", col_map.get("ref")) + source_col = col_map.get("source") + + # Parse data rows + rows = [] + for row_idx in range(header_row + 1, ws.max_row + 1): + date_val = ws.cell(row=row_idx, column=col_map["date"]).value + parsed_date = parse_xero_date(date_val) + if not parsed_date: + continue # Skip non-data rows (section headers, totals, blanks) + + description = ws.cell(row=row_idx, column=col_map["description"]).value or "" + ref = "" + if ref_col: + ref = ws.cell(row=row_idx, column=ref_col).value or "" + ref = str(ref).strip() + + source = "" + if source_col: + source = str(ws.cell(row=row_idx, column=source_col).value or "").strip() + + debit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["debit"]).value) + credit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["credit"]).value) + net = debit - credit # Credits (credit notes) become negative + + is_credit_note = "credit note" in source.lower() or credit > 0 + + rows.append({ + "date": parsed_date, + "description": str(description).strip(), + "ref": ref, + "source": source, + "debit": debit, + "credit": credit, + "net": net, + "is_credit_note": is_credit_note, + }) + + wb.close() + return period_start, period_end, rows + + +def group_xero_by_ref(rows: list[dict]) -> list[dict]: + """ + Group Xero rows by reference (Xero sometimes splits an invoice across + stock/non-stock lines with the same ref). Sum the nets. + + Rows with empty refs are NOT grouped — each stays separate. + """ + grouped = {} + no_ref = [] + + for row in rows: + norm = normalise_ref(row["ref"]) + if not norm: + no_ref.append({ + "date": row["date"], + "description": row["description"], + "ref": row["ref"], + "net": row["net"], + "is_credit_note": row.get("is_credit_note", False), + "raw_rows": [row], + }) + else: + if norm not in grouped: + grouped[norm] = { + "date": row["date"], + "description": row["description"], + "ref": row["ref"], # keep original formatting from first row + "net": Decimal("0"), + "is_credit_note": row.get("is_credit_note", False), + "raw_rows": [], + } + grouped[norm]["net"] += row["net"] + grouped[norm]["raw_rows"].append(row) + # If any row is a credit note, mark the group + if row.get("is_credit_note"): + grouped[norm]["is_credit_note"] = True + + return list(grouped.values()) + no_ref + + +# ============ Flash Invoice Querying ============ + +async def get_flash_invoices( + db: AsyncSession, + kitchen_id: int, + period_start: date, + period_end: date, +) -> tuple[list[dict], int, Decimal]: + """ + Get Flash invoices for the period. Returns: + - stock_invoices: list of dicts for matching + - non_stock_count: count of excluded non-stock-only invoices + - non_stock_total: total of excluded non-stock amounts + + Each stock invoice dict has: + { + "id": int, + "date": date, + "supplier_name": str, + "ref": str (invoice_number), + "net_stock": Decimal, + "document_type": str, + } + """ + from models.line_item import LineItem + + result = await db.execute( + select(Invoice) + .where( + Invoice.kitchen_id == kitchen_id, + Invoice.status == InvoiceStatus.CONFIRMED, + ) + .options(selectinload(Invoice.line_items)) + .order_by(Invoice.invoice_date.desc().nullslast()) + ) + all_invoices = result.scalars().all() + + # Get supplier map + supplier_result = await db.execute( + select(Supplier).where(Supplier.kitchen_id == kitchen_id) + ) + suppliers_map = {s.id: s for s in supplier_result.scalars().all()} + + stock_invoices = [] + non_stock_count = 0 + non_stock_total = Decimal("0") + + for inv in all_invoices: + inv_date = inv.invoice_date or inv.created_at.date() + if not (period_start <= inv_date <= period_end): + continue + + # Calculate net_stock (stock items only) + net_stock = Decimal("0") + net_non_stock = Decimal("0") + if inv.line_items: + for item in inv.line_items: + item_net = item.amount or Decimal("0") + if item.is_non_stock: + net_non_stock += item_net + else: + net_stock += item_net + + # Credit notes: negate if positive + if inv.document_type == "credit_note": + if net_stock > 0: + net_stock = -net_stock + if net_non_stock > 0: + net_non_stock = -net_non_stock + + # Get supplier name + supplier_name = "" + if inv.supplier_id and inv.supplier_id in suppliers_map: + supplier_name = suppliers_map[inv.supplier_id].name + elif inv.vendor_name: + supplier_name = inv.vendor_name + else: + supplier_name = "Unknown" + + if net_stock == 0 and net_non_stock != 0: + # Entirely non-stock — exclude from match pool + non_stock_count += 1 + non_stock_total += net_non_stock + continue + + if net_stock == 0 and net_non_stock == 0: + # Zero-value invoice — nothing to reconcile + continue + + # Build line item summaries for insight generation + line_summaries = [] + if inv.line_items: + for item in inv.line_items: + line_summaries.append({ + "description": item.description or "", + "amount": str(item.amount or 0), + "is_non_stock": item.is_non_stock, + }) + + stock_invoices.append({ + "id": inv.id, + "date": inv_date, + "supplier_name": supplier_name, + "ref": inv.invoice_number or "", + "net_stock": net_stock.quantize(Decimal("0.01")), + "net_non_stock": net_non_stock.quantize(Decimal("0.01")), + "document_type": inv.document_type or "invoice", + "line_items": line_summaries, + }) + + # Track non-stock portion of mixed invoices separately + if net_non_stock != 0: + non_stock_count += 1 + non_stock_total += net_non_stock + + return stock_invoices, non_stock_count, non_stock_total.quantize(Decimal("0.01")) + + +# ============ Build supplier alias map from DB ============ + +async def build_supplier_alias_map(db: AsyncSession, kitchen_id: int) -> dict[str, str]: + """ + Build a normalised-name → canonical-name map from the Supplier table aliases. + Merges with the hardcoded SUPPLIER_ALIASES. + """ + alias_map = dict(SUPPLIER_ALIASES) # start with hardcoded + + result = await db.execute( + select(Supplier).where(Supplier.kitchen_id == kitchen_id) + ) + suppliers = result.scalars().all() + + for s in suppliers: + # Map canonical name + alias_map[normalise_supplier(s.name)] = s.name + # Map aliases + if s.aliases: + for alias in s.aliases: + alias_map[normalise_supplier(alias)] = s.name + + return alias_map + + +def resolve_supplier_with_map(xero_desc: str, alias_map: dict[str, str]) -> str: + """Resolve a Xero description to a canonical supplier name using dynamic alias map.""" + extracted = extract_supplier_from_xero_desc(xero_desc) + norm = normalise_supplier(extracted) + if norm in alias_map: + return alias_map[norm] + # Try partial match — check if any alias key is contained in the extracted name + for alias_key, canonical in alias_map.items(): + if alias_key in norm or norm in alias_key: + return canonical + return extracted + + +def suppliers_match(flash_supplier: str, xero_supplier: str) -> bool: + """Check if a Flash supplier and resolved Xero supplier match.""" + return normalise_supplier(flash_supplier) == normalise_supplier(xero_supplier) + + +# ============ Matching Engine ============ + +def run_matching( + flash_items: list[dict], + xero_items: list[dict], + alias_map: dict[str, str], +) -> tuple[list, list, list[dict], list[dict]]: + """ + Three-pass matching: + 1. Exact: same normalised ref + same date + amount within £0.01 + 2. Near-match: one field differs (flagged as discrepancy) + 3. Leftovers: unmatched on each side + + Returns (matched, discrepancies, flash_unmatched, xero_unmatched) + """ + matched = [] + discrepancies = [] + + flash_used = set() + xero_used = set() + + # Resolve Xero suppliers up front + for xi, xero in enumerate(xero_items): + xero["_resolved_supplier"] = resolve_supplier_with_map(xero["description"], alias_map) + xero["_norm_ref"] = normalise_ref(xero["ref"]) + xero["_idx"] = xi + + for fi, flash in enumerate(flash_items): + flash["_norm_ref"] = normalise_ref(flash["ref"]) + flash["_idx"] = fi + + # ---- Pass 1: Exact match ---- + for fi, flash in enumerate(flash_items): + if fi in flash_used: + continue + if not flash["_norm_ref"]: + continue # Can't exact-match without a ref + for xi, xero in enumerate(xero_items): + if xi in xero_used: + continue + if flash["_norm_ref"] == xero["_norm_ref"] and \ + flash["date"] == xero["date"] and \ + amounts_match(flash["net_stock"], xero["net"]): + matched.append({ + "date": flash["date"].isoformat(), + "supplier": flash["supplier_name"], + "ref": flash["ref"] or xero["ref"], + "amount": fmt_money(flash["net_stock"]), + "flash_id": flash["id"], + }) + flash_used.add(fi) + xero_used.add(xi) + break + + # ---- Pass 2: Near-match (discrepancies) ---- + for fi, flash in enumerate(flash_items): + if fi in flash_used: + continue + best_match = None + best_score = 0 # higher = more confident + + for xi, xero in enumerate(xero_items): + if xi in xero_used: + continue + + differs = [] + score = 0 + + f_ref = flash["_norm_ref"] + x_ref = xero["_norm_ref"] + f_supplier = flash["supplier_name"] + x_supplier = xero["_resolved_supplier"] + same_supplier = suppliers_match(f_supplier, x_supplier) + + # 2a: Same ref, same date, amount differs + if f_ref and x_ref and f_ref == x_ref and flash["date"] == xero["date"]: + if not amounts_match(flash["net_stock"], xero["net"]): + differs.append("amount") + score = 10 + + # 2b: Same ref, amount matches, date differs ≤ 3 days + elif f_ref and x_ref and f_ref == x_ref and \ + amounts_match(flash["net_stock"], xero["net"]): + date_diff = abs((flash["date"] - xero["date"]).days) + if date_diff <= 3: + differs.append("date") + score = 9 + + # 2c: Same supplier, amount matches (within £0.01), date within 3 days, refs differ + # This catches cases where refs are completely different + # (e.g. Cotswold Coffee 126621 vs 145323, R&D Walker Nº4 vs 106891) + elif same_supplier and amounts_match(flash["net_stock"], xero["net"], Decimal("0.01")): + date_diff = abs((flash["date"] - xero["date"]).days) + if date_diff == 0: + differs.append("ref") + score = 8 + elif date_diff <= 3: + differs.extend(["ref", "date"]) + score = 7 + + # 2d: Same supplier, same ref, both amount and date differ slightly + elif f_ref and x_ref and f_ref == x_ref and same_supplier: + date_diff = abs((flash["date"] - xero["date"]).days) + amt_diff = abs(flash["net_stock"] - xero["net"]) + if date_diff <= 3 and amt_diff <= Decimal("5.00"): + differs.extend(["amount", "date"]) + score = 5 + + # 2e: Credit note matching — same supplier, same amount, + # but refs differ completely (Xero uses CN prefix, Flash uses supplier ref) + elif same_supplier and amounts_match(flash["net_stock"], xero["net"]) and \ + (flash["document_type"] == "credit_note" or xero.get("is_credit_note", False)): + date_diff = abs((flash["date"] - xero["date"]).days) + if date_diff <= 5: + differs.append("ref") + if date_diff > 0: + differs.append("date") + score = 8 # High confidence — credit notes with matching supplier+amount + + # 2f: Same supplier, date within 3 days, amounts close but not exact + elif same_supplier: + date_diff = abs((flash["date"] - xero["date"]).days) + amt_diff = abs(flash["net_stock"] - xero["net"]) + if date_diff <= 3 and amt_diff <= Decimal("5.00") and amt_diff > Decimal("0.01"): + differs_list = ["amount"] + if date_diff > 0: + differs_list.append("date") + if f_ref != x_ref: + differs_list.append("ref") + differs.extend(differs_list) + score = 4 + + if score > best_score: + best_score = score + best_match = (xi, xero, differs) + + if best_match: + xi, xero, differs = best_match + f_net = flash["net_stock"] + x_net = xero["net"] + diff_val = f_net - x_net + amt_diff_abs = abs(float(diff_val)) + + # Safety check: reject any near-match where amount differs by more than £5 + # AND refs are different — these are almost certainly different invoices + f_ref_norm = normalise_ref(flash["ref"]) + x_ref_norm = normalise_ref(xero["ref"]) + refs_differ = f_ref_norm != x_ref_norm + + if refs_differ and amt_diff_abs > 5.0: + logger.warning( + f"REJECTED false discrepancy: Flash {flash['supplier_name']} " + f"{flash['ref']}={f_net} vs Xero {xero['ref']}={x_net} " + f"diff={amt_diff_abs:.2f} score={best_score}" + ) + continue # Skip — leave both as unmatched + + # Add "amount" to differs if amounts don't actually match + if amt_diff_abs > 0.01 and "amount" not in differs: + differs.append("amount") + + logger.info( + f"Discrepancy: Flash {flash['supplier_name']} {flash['ref']}={f_net} " + f"vs Xero {xero['ref']}={x_net} differs={differs} score={best_score}" + ) + + discrepancies.append({ + "date": flash["date"].isoformat(), + "supplier": flash["supplier_name"], + "ref": flash["ref"] or xero["ref"], + "flash_amount": fmt_money(f_net), + "xero_amount": fmt_money(x_net), + "difference": fmt_money(diff_val), + "differs": differs, + "flash_id": flash["id"], + "flash_date": flash["date"].isoformat(), + "xero_date": xero["date"].isoformat(), + "flash_ref": flash["ref"], + "xero_ref": xero["ref"], + "flash_supplier": flash["supplier_name"], + "xero_description": xero["description"], + }) + flash_used.add(fi) + xero_used.add(xi) + + # ---- Leftovers ---- + flash_unmatched = [f for fi, f in enumerate(flash_items) if fi not in flash_used] + xero_unmatched = [x for xi, x in enumerate(xero_items) if xi not in xero_used] + + return matched, discrepancies, flash_unmatched, xero_unmatched + + +# ============ Amount Discrepancy Insights ============ + +def check_non_stock_explains_diff(flash: dict, diff_val: Decimal) -> str | None: + """ + Check if the amount discrepancy is explained by non-stock items. + If Xero includes the full invoice (stock + non-stock) but Flash only shows stock, + the difference should equal the non-stock total. + """ + net_non_stock = flash.get("net_non_stock", Decimal("0")) + if net_non_stock == 0: + return None + + # diff_val = flash_stock - xero_net (negative when Xero is higher) + # If Xero has full invoice, diff = -net_non_stock + if amounts_match(abs(diff_val), abs(net_non_stock), Decimal("0.02")): + non_stock_items = [ + li for li in flash.get("line_items", []) if li.get("is_non_stock") + ] + item_names = ", ".join( + li["description"][:40] for li in non_stock_items if li.get("description") + ) + return ( + f"Non-stock items account for the difference " + f"({fmt_money(abs(net_non_stock))}). " + f"Xero likely includes full invoice total. " + f"Non-stock: {item_names}" if item_names else + f"Non-stock items account for the difference " + f"({fmt_money(abs(net_non_stock))}). " + f"Xero likely includes full invoice total." + ) + + # Check if non-stock is a partial explanation (diff is larger but non-stock is a chunk) + if abs(net_non_stock) > Decimal("1.00") and abs(diff_val) > abs(net_non_stock): + remainder = abs(diff_val) - abs(net_non_stock) + return ( + f"Non-stock items total {fmt_money(abs(net_non_stock))} " + f"but difference is {fmt_money(abs(diff_val))} — " + f"non-stock explains part, {fmt_money(remainder)} remains unexplained." + ) + + return None + + +def check_line_item_combinations(flash: dict, diff_val: Decimal) -> str | None: + """ + Check if any single line item or small combination matches the difference. + This catches cases where a specific item was excluded/included differently. + """ + line_items = flash.get("line_items", []) + if not line_items: + return None + + target = abs(diff_val) + + # Check single items + for li in line_items: + amt = abs(Decimal(li["amount"])) + if amounts_match(amt, target, Decimal("0.02")) and amt > Decimal("0.50"): + desc = li["description"][:50] if li["description"] else "unnamed item" + ns = " (non-stock)" if li.get("is_non_stock") else "" + return f'Single line item matches difference: "{desc}"{ns} = {fmt_money(amt)}' + + return None + + +async def generate_llm_insight( + db: AsyncSession, + kitchen_id: int, + flash: dict, + xero_net: Decimal, + diff_val: Decimal, +) -> str | None: + """Use LLM to analyse line items and suggest cause of amount variance.""" + from services.llm_service import call_llm + + line_items = flash.get("line_items", []) + if not line_items: + return None + + # Build concise line item list + li_summary = [] + for li in line_items: + li_summary.append({ + "description": li["description"][:60] if li["description"] else "—", + "amount": li["amount"], + "non_stock": li["is_non_stock"], + }) + + system_msg = ( + "You are a kitchen accounts assistant. Flash is a food stock/GP system that " + "tracks invoices — it separates stock (food) items from non-stock items " + "(chemicals, packaging, equipment, etc). Xero is the accounting system that " + "records the full invoice total posted to the food purchases account. " + "An amount discrepancy means Flash stock total differs from the Xero net. " + "Common causes: non-stock items not split out in Xero, line items missing, " + "rounding, or Xero posting error." + ) + + user_msg = ( + f"Invoice: {flash['supplier_name']} ref {flash['ref']} dated {flash['date']}\n" + f"Flash stock total: {fmt_money(flash['net_stock'])}\n" + f"Xero net: {fmt_money(xero_net)}\n" + f"Difference: {fmt_money(diff_val)} (Flash - Xero)\n\n" + f"Flash line items:\n{json.dumps(li_summary, indent=2)}\n\n" + f"Can you identify which line items or combination likely accounts for " + f"the {fmt_money(abs(diff_val))} difference? " + f"Reply in ONE short sentence (max 120 chars). " + f"If unclear, say 'Unable to determine cause'." + ) + + result = await call_llm( + db=db, + kitchen_id=kitchen_id, + feature="reconciliation_insight", + messages=[{"role": "user", "content": user_msg}], + system_message=system_msg, + ) + + if result["status"] not in ("success", "cached"): + return None + + text = result.get("result", "") + if isinstance(text, dict): + text = str(text) + text = text.strip().strip('"').strip("'") + if text and len(text) < 200: + return text + return text[:200] + "..." if text else None + + +async def generate_amount_insights( + db: AsyncSession, + kitchen_id: int, + discrepancies: list[dict], + flash_lookup: dict[int, dict], +) -> list[dict]: + """ + For each amount discrepancy, try to explain the variance: + 1. Code check: does non-stock total match the difference? + 2. Code check: does a single line item match the difference? + 3. LLM fallback: send line items for analysis + """ + for disc in discrepancies: + if "amount" not in disc.get("differs", []): + continue + + flash_id = disc.get("flash_id") + if not flash_id or flash_id not in flash_lookup: + continue + + flash = flash_lookup[flash_id] + + # Parse the difference back to Decimal + diff_str = disc["difference"].replace("£", "").replace(",", "") + try: + diff_val = Decimal(diff_str) + except InvalidOperation: + continue + + # 1. Non-stock check + insight = check_non_stock_explains_diff(flash, diff_val) + if insight: + disc["amount_insight"] = insight + continue + + # 2. Single line item check + insight = check_line_item_combinations(flash, diff_val) + if insight: + disc["amount_insight"] = insight + continue + + # 3. LLM fallback + xero_str = disc["xero_amount"].replace("£", "").replace(",", "") + try: + xero_net = Decimal(xero_str) + except InvalidOperation: + continue + + insight = await generate_llm_insight(db, kitchen_id, flash, xero_net, diff_val) + if insight: + disc["amount_insight"] = f"🤖 {insight}" + + return discrepancies + + +# ============ LLM Fallback Matching ============ + +async def llm_match_remaining( + db: AsyncSession, + kitchen_id: int, + flash_unmatched: list[dict], + xero_unmatched: list[dict], +) -> tuple[list, list, list[dict], list[dict]]: + """ + Use LLM to attempt matching remaining unmatched items. + Returns (new_discrepancies, new_exact, remaining_flash, remaining_xero) + """ + from services.llm_service import call_llm + + if not flash_unmatched or not xero_unmatched: + return [], [], flash_unmatched, xero_unmatched + + # Build concise representations + flash_summary = [] + for i, f in enumerate(flash_unmatched): + flash_summary.append({ + "idx": i, + "date": f["date"].isoformat(), + "supplier": f["supplier_name"], + "ref": f["ref"], + "amount": str(f["net_stock"]), + }) + + xero_summary = [] + for i, x in enumerate(xero_unmatched): + xero_summary.append({ + "idx": i, + "date": x["date"].isoformat(), + "description": x["description"], + "ref": x["ref"], + "amount": str(x["net"]), + }) + + system_msg = """You are a bookkeeping reconciliation assistant. You are given two lists of unmatched invoice entries — one from Flash (the kitchen invoice system) and one from Xero (the accounting system). + +Your job is to identify probable matches between the two lists. These are entries that likely represent the same real-world invoice but have data discrepancies (different reference numbers, slightly different amounts, date offsets, supplier name variants, etc). + +For each probable match, explain which fields differ and why you think they are the same invoice. + +IMPORTANT: Only suggest matches you are reasonably confident about. It is better to leave items unmatched than to create false matches. Consider supplier names, dates, amounts, and reference numbers holistically.""" + + user_msg = f"""Here are the unmatched Flash invoices: +{json.dumps(flash_summary, indent=2)} + +Here are the unmatched Xero entries: +{json.dumps(xero_summary, indent=2)} + +Return a JSON array of matches. Each match should be: +{{ + "flash_idx":{description.replace(chr(10), '
')}
A screenshot of the page is attached to this email.
+