From 907243c2cd2d38e4eaeddec8de6e898e87d27499 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sun, 12 Jul 2026 12:16:28 +0000 Subject: [PATCH] Restore api/migrations/models/services source files accidentally removed in gitignore commit Co-Authored-By: Claude Sonnet 4.6 --- backend/api/__init__.py | 3 + backend/api/backup.py | 374 +++ backend/api/budget.py | 1121 +++++++ backend/api/calendar_events.py | 236 ++ backend/api/cost_distributions.py | 863 ++++++ backend/api/cover_overrides.py | 648 ++++ backend/api/credit_notes.py | 327 +++ backend/api/disputes.py | 1125 +++++++ backend/api/event_orders.py | 694 +++++ backend/api/external.py | 497 ++++ backend/api/field_mappings.py | 205 ++ backend/api/food_flags.py | 2309 +++++++++++++++ backend/api/imap.py | 341 +++ backend/api/ingredients.py | 2013 +++++++++++++ backend/api/internal.py | 64 + backend/api/invoices.py | 4077 ++++++++++++++++++++++++++ backend/api/logbook.py | 745 +++++ backend/api/menus.py | 1308 +++++++++ backend/api/newbook.py | 1016 +++++++ backend/api/public.py | 118 + backend/api/purchase_orders.py | 835 ++++++ backend/api/recipes.py | 2318 +++++++++++++++ backend/api/reconciliation.py | 1187 ++++++++ backend/api/reports.py | 3421 +++++++++++++++++++++ backend/api/residents_table_chart.py | 422 +++ backend/api/resos.py | 809 +++++ backend/api/sambapos.py | 690 +++++ backend/api/search.py | 744 +++++ backend/api/settings.py | 798 +++++ backend/api/suppliers.py | 343 +++ backend/api/support.py | 204 ++ 31 files changed, 29855 insertions(+) create mode 100644 backend/api/__init__.py create mode 100644 backend/api/backup.py create mode 100644 backend/api/budget.py create mode 100644 backend/api/calendar_events.py create mode 100644 backend/api/cost_distributions.py create mode 100644 backend/api/cover_overrides.py create mode 100644 backend/api/credit_notes.py create mode 100644 backend/api/disputes.py create mode 100644 backend/api/event_orders.py create mode 100644 backend/api/external.py create mode 100644 backend/api/field_mappings.py create mode 100644 backend/api/food_flags.py create mode 100644 backend/api/imap.py create mode 100644 backend/api/ingredients.py create mode 100644 backend/api/internal.py create mode 100644 backend/api/invoices.py create mode 100644 backend/api/logbook.py create mode 100644 backend/api/menus.py create mode 100644 backend/api/newbook.py create mode 100644 backend/api/public.py create mode 100644 backend/api/purchase_orders.py create mode 100644 backend/api/recipes.py create mode 100644 backend/api/reconciliation.py create mode 100644 backend/api/reports.py create mode 100644 backend/api/residents_table_chart.py create mode 100644 backend/api/resos.py create mode 100644 backend/api/sambapos.py create mode 100644 backend/api/search.py create mode 100644 backend/api/settings.py create mode 100644 backend/api/suppliers.py create mode 100644 backend/api/support.py 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""" + {esc(li.product_code or "")} + {esc(li.description or "")} + {esc(li.unit or "")} + {currency}{li.unit_price:.2f} + {li.quantity:g} + {currency}{li.total:.2f} + """ + items_html = f""" + + + + + + + + + + + + {rows} +
CodeDescriptionUnitPriceQtyTotal
""" + elif po.order_type == "single_value": + items_html = f""" +
+ Order Value: {currency}{float(po.total_amount or 0):.2f} + {f'
Order Ref: {esc(po.order_reference)}' if po.order_reference else ''} +
""" + + total_amount = float(po.total_amount or 0) + + return f""" + + + +Purchase Order {po_number} + + + +
+ +
+
+

{kitchen_name}

+
{addr_html}
+ {f'
Tel: {kitchen_phone}
' if kitchen_phone else ''} + {f'
{kitchen_email}
' if kitchen_email else ''} +
+
+

PURCHASE ORDER

+
{po_number}
+
+
+ + +
+
+
Supplier
+
{supplier_name}
+ {f'
Account: {account_number}
' if account_number else ''} +
+
+
Date
+
{order_date}
+
Status: {po.status}
+
+
+ + + {items_html} + + +
+ Total: {currency}{total_amount:.2f} +
+ + + {f'
Notes:
{notes}
' if notes else ''} + + +
+ +
+
+ +""" + + +@router.get("/{po_id}/preview") +async def preview_purchase_order( + po_id: int, + token: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + """Return a print-friendly HTML preview of the purchase order (query-param auth).""" + if not token: + raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token") + current_user = await get_current_user_from_token(token, db) + if not current_user: + raise HTTPException(status_code=401, detail="Not authenticated") + po = await _load_po(db, po_id, current_user.kitchen_id) + + # Load kitchen settings for letterhead + settings_result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + kitchen = settings_result.scalar_one_or_none() + if not kitchen: + kitchen = KitchenSettings(kitchen_id=current_user.kitchen_id) + + currency = kitchen.currency_symbol or "£" + html = _build_po_html(po, kitchen, currency) + return HTMLResponse(content=html) + + +@router.post("/{po_id}/send-email") +async def send_po_email( + po_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Email the PO to the supplier's order_email address.""" + po = await _load_po(db, po_id, current_user.kitchen_id) + + # Validate supplier has an order email + if not po.supplier or not po.supplier.order_email: + raise HTTPException(status_code=400, detail="Supplier does not have an order email address configured") + + # Load kitchen settings for SMTP + letterhead + settings_result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + kitchen = settings_result.scalar_one_or_none() + if not kitchen or not kitchen.smtp_host or not kitchen.smtp_from_email: + raise HTTPException(status_code=400, detail="SMTP email is not configured in Settings") + + currency = kitchen.currency_symbol or "£" + html = _build_po_html(po, kitchen, currency) + + po_number = f"PO-{po.id}" + kitchen_name = kitchen.kitchen_display_name or "Kitchen" + subject = f"Purchase Order {po_number} from {kitchen_name}" + + email_service = EmailService(kitchen) + success = email_service.send_email( + to_email=po.supplier.order_email, + subject=subject, + html_body=html, + plain_body=f"Please find attached Purchase Order {po_number}. Total: {currency}{float(po.total_amount or 0):.2f}", + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to send email. Check SMTP settings.") + + # Update status to PENDING if currently DRAFT + if po.status == "DRAFT": + po.status = "PENDING" + po.updated_by = current_user.id + await db.commit() + + return {"ok": True, "message": f"PO emailed to {po.supplier.order_email}", "new_status": po.status} + + +# ── Invoice Matching ───────────────────────────────────────────────────────── + +class LinkRequest(BaseModel): + invoice_id: int + + +@router.get("/matching/for-invoice") +async def get_matching_pos( + invoice_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Find pending POs that match a given invoice (by supplier).""" + from services.po_matching import find_matching_pos + + # Load the invoice + inv_result = await db.execute( + select(Invoice).where( + Invoice.id == invoice_id, + Invoice.kitchen_id == current_user.kitchen_id, + ) + ) + invoice = inv_result.scalar_one_or_none() + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + if not invoice.supplier_id: + return {"matches": [], "linked_po": None} + + # Check if invoice already has a linked PO + linked_result = await db.execute( + select(PurchaseOrder) + .where( + PurchaseOrder.kitchen_id == current_user.kitchen_id, + PurchaseOrder.linked_invoice_id == invoice_id, + PurchaseOrder.status == "LINKED", + ) + .options( + selectinload(PurchaseOrder.supplier), + selectinload(PurchaseOrder.line_items), + ) + ) + linked_po = linked_result.scalar_one_or_none() + + if linked_po: + return { + "matches": [], + "linked_po": { + "po_id": linked_po.id, + "order_date": linked_po.order_date.isoformat() if linked_po.order_date else None, + "total_amount": float(linked_po.total_amount) if linked_po.total_amount else None, + "order_reference": linked_po.order_reference, + "status": linked_po.status, + "order_type": linked_po.order_type, + }, + } + + # Find matching POs + matches = await find_matching_pos( + db, + kitchen_id=current_user.kitchen_id, + supplier_id=invoice.supplier_id, + invoice_date=invoice.invoice_date, + invoice_total=invoice.total, + ) + + return {"matches": matches, "linked_po": None} + + +@router.post("/{po_id}/link", response_model=PurchaseOrderOut) +async def link_po_to_invoice( + po_id: int, + data: LinkRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Link a PO to an invoice. Sets PO status to LINKED.""" + from services.po_matching import link_po_to_invoice as do_link + + po = await do_link(db, po_id, data.invoice_id, current_user.kitchen_id, current_user.id) + if not po: + raise HTTPException(status_code=404, detail="PO or invoice not found") + + return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) + + +@router.post("/{po_id}/unlink", response_model=PurchaseOrderOut) +async def unlink_po_from_invoice( + po_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Unlink a PO from its invoice. Returns PO to PENDING status.""" + from services.po_matching import unlink_po as do_unlink + + po = await do_unlink(db, po_id, current_user.kitchen_id, current_user.id) + if not po: + raise HTTPException(status_code=404, detail="PO not found") + + return po_to_out(await _load_po(db, po.id, current_user.kitchen_id)) diff --git a/backend/api/recipes.py b/backend/api/recipes.py new file mode 100644 index 0000000..7cb4b42 --- /dev/null +++ b/backend/api/recipes.py @@ -0,0 +1,2318 @@ +""" +Recipe API — CRUD, costing, sub-recipe cycle check, scaling, menu sections, +cost snapshots, print HTML (full + kitchen card). +""" +import os +import uuid +import logging +from datetime import date, datetime, timedelta +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, func, text, 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.recipe import ( + Recipe, MenuSection, RecipeIngredient, RecipeSubRecipe, + RecipeStep, RecipeImage, RecipeChangeLog, RecipeCostSnapshot, +) +from models.ingredient import Ingredient, IngredientSource +from models.food_flag import RecipeFlag, FoodFlagCategory, FoodFlag +from models.ingredient import IngredientFlag, IngredientFlagNone +from models.settings import KitchenSettings +from auth import get_current_user, require_cap, get_current_user_from_token +from api.ingredients import convert_to_standard, UNIT_CONVERSIONS +from models.menu import Menu, MenuItem + +logger = logging.getLogger(__name__) + +router = APIRouter() + +DATA_DIR = "/app/data" + + +# ── Pydantic schemas ───────────────────────────────────────────────────────── + +class MenuSectionCreate(BaseModel): + name: str + sort_order: int = 0 + section_type: str = "recipe" # "recipe" | "dish" + +class MenuSectionUpdate(BaseModel): + name: Optional[str] = None + sort_order: Optional[int] = None + +class MenuSectionResponse(BaseModel): + id: int + name: str + sort_order: int + section_type: str = "recipe" + recipe_count: int = 0 + +class RecipeCreate(BaseModel): + name: str + recipe_type: str = "component" # "component" | "dish" + menu_section_id: Optional[int] = None + description: Optional[str] = None + batch_portions: int = 1 + batch_output_type: str = "portions" # "portions" | "bulk" + batch_yield_qty: Optional[float] = None + batch_yield_unit: Optional[str] = None # g, kg, ml, ltr + prep_time_minutes: Optional[int] = None + cook_time_minutes: Optional[int] = None + notes: Optional[str] = None + +class RecipeUpdate(BaseModel): + name: Optional[str] = None + recipe_type: Optional[str] = None + menu_section_id: Optional[int] = None + description: Optional[str] = None + batch_portions: Optional[int] = None + batch_output_type: Optional[str] = None + batch_yield_qty: Optional[float] = None + batch_yield_unit: Optional[str] = None + prep_time_minutes: Optional[int] = None + cook_time_minutes: Optional[int] = None + notes: Optional[str] = None + is_archived: Optional[bool] = None + kds_menu_item_name: Optional[str] = None + sambapos_portion_name: Optional[str] = None + gross_sell_price: Optional[float] = None + +class IngredientAdd(BaseModel): + ingredient_id: int + quantity: float + unit: Optional[str] = None # override display unit (e.g. kg when ingredient std is g) + yield_percent: float = 100.0 + notes: Optional[str] = None + sort_order: int = 0 + +class IngredientUpdateSchema(BaseModel): + quantity: Optional[float] = None + unit: Optional[str] = None + yield_percent: Optional[float] = None + notes: Optional[str] = None + sort_order: Optional[int] = None + +class SubRecipeAdd(BaseModel): + child_recipe_id: int + portions_needed: float + portions_needed_unit: Optional[str] = None # override unit (e.g. ml when child yields ltr) + notes: Optional[str] = None + sort_order: int = 0 + +class SubRecipeUpdateSchema(BaseModel): + portions_needed: Optional[float] = None + portions_needed_unit: Optional[str] = None + notes: Optional[str] = None + sort_order: Optional[int] = None + +class StepCreate(BaseModel): + title: Optional[str] = None + instruction: str + step_number: int = 0 + duration_minutes: Optional[int] = None + notes: Optional[str] = None + +class StepUpdate(BaseModel): + title: Optional[str] = None + instruction: Optional[str] = None + step_number: Optional[int] = None + duration_minutes: Optional[int] = None + notes: Optional[str] = None + +class StepReorder(BaseModel): + step_ids: list[int] + +class IngredientReorder(BaseModel): + ingredient_ids: list[int] + +class SubRecipeReorder(BaseModel): + sub_recipe_ids: list[int] + +class RecipeListItem(BaseModel): + id: int + name: str + recipe_type: str + menu_section_id: Optional[int] = None + menu_section_name: Optional[str] = None + batch_portions: int = 1 + batch_output_type: str = "portions" + batch_yield_qty: Optional[float] = None + batch_yield_unit: Optional[str] = None + output_unit: str = "portion" + cost_per_portion: Optional[float] = None + total_cost: Optional[float] = None + is_archived: bool = False + prep_time_minutes: Optional[int] = None + cook_time_minutes: Optional[int] = None + flag_summary: list[dict] = [] + image_count: int = 0 + gross_sell_price: Optional[float] = None + kds_menu_item_name: Optional[str] = None + sambapos_portion_name: Optional[str] = None + created_at: str = "" + updated_at: str = "" + + +# ── Menu Section endpoints ─────────────────────────────────────────────────── + +@router.get("/menu-sections") +async def list_menu_sections( + section_type: Optional[str] = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + count_sub = ( + select(Recipe.menu_section_id, func.count(Recipe.id).label("cnt")) + .where(Recipe.kitchen_id == user.kitchen_id, Recipe.is_archived == False) + .group_by(Recipe.menu_section_id) + .subquery() + ) + query = ( + select(MenuSection, func.coalesce(count_sub.c.cnt, 0).label("recipe_count")) + .outerjoin(count_sub, MenuSection.id == count_sub.c.menu_section_id) + .where(MenuSection.kitchen_id == user.kitchen_id) + ) + if section_type: + query = query.where(MenuSection.section_type == section_type) + query = query.order_by(MenuSection.sort_order, MenuSection.name) + result = await db.execute(query) + return [ + MenuSectionResponse(id=s.id, name=s.name, sort_order=s.sort_order, section_type=s.section_type, recipe_count=cnt) + for s, cnt in result.all() + ] + + +@router.post("/menu-sections") +async def create_menu_section( + data: MenuSectionCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + sec = MenuSection(kitchen_id=user.kitchen_id, name=data.name, sort_order=data.sort_order, section_type=data.section_type) + db.add(sec) + await db.commit() + await db.refresh(sec) + return MenuSectionResponse(id=sec.id, name=sec.name, sort_order=sec.sort_order) + + +@router.patch("/menu-sections/{section_id}") +async def update_menu_section( + section_id: int, + data: MenuSectionUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id) + ) + sec = result.scalar_one_or_none() + if not sec: + raise HTTPException(404, "Section not found") + if data.name is not None: + sec.name = data.name + if data.sort_order is not None: + sec.sort_order = data.sort_order + await db.commit() + return {"ok": True} + + +@router.delete("/menu-sections/{section_id}") +async def delete_menu_section( + section_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MenuSection).where(MenuSection.id == section_id, MenuSection.kitchen_id == user.kitchen_id) + ) + sec = result.scalar_one_or_none() + if not sec: + raise HTTPException(404, "Section not found") + # Null out recipes in this section + from sqlalchemy import update + await db.execute( + update(Recipe).where(Recipe.menu_section_id == section_id).values(menu_section_id=None) + ) + await db.delete(sec) + await db.commit() + return {"ok": True} + + +# ── Default recipe sections and dish courses ──────────────────────────────── + +DEFAULT_RECIPE_SECTIONS = [ + ("Meats & Protein", 0), + ("Sauces & Jus", 1), + ("Starch & Vegetables", 2), + ("Sides & Accompaniments", 3), + ("Pastry & Dessert", 4), + ("Garnish & Toppings", 5), + ("Marinades & Glazes", 6), + ("Stews & Casseroles", 7), +] + +DEFAULT_DISH_COURSES = [ + ("Starter", 0), + ("Main", 1), + ("Dessert", 2), + ("Side", 3), + ("Specials", 4), +] + + +@router.post("/menu-sections/seed-defaults") +async def seed_default_menu_sections( + section_type: str = Query("recipe"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if section_type not in ("recipe", "dish"): + raise HTTPException(400, "section_type must be 'recipe' or 'dish'") + + defaults = DEFAULT_RECIPE_SECTIONS if section_type == "recipe" else DEFAULT_DISH_COURSES + kid = user.kitchen_id + created = 0 + for name, sort_order in defaults: + exists = await db.execute( + select(MenuSection).where( + MenuSection.kitchen_id == kid, + MenuSection.name == name, + MenuSection.section_type == section_type, + ) + ) + if exists.scalar_one_or_none(): + continue + db.add(MenuSection(kitchen_id=kid, name=name, section_type=section_type, sort_order=sort_order)) + created += 1 + await db.commit() + return {"ok": True, "created": created} + + +# ── Dashboard Stats ────────────────────────────────────────────────────────── + +@router.get("/dashboard-stats") +async def recipe_dashboard_stats( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Lightweight stats for the main dashboard widget.""" + kid = user.kitchen_id + + total = (await db.execute( + select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) + )).scalar() or 0 + + dishes = (await db.execute( + select(func.count()).select_from(Recipe).where(Recipe.kitchen_id == kid, Recipe.recipe_type == "dish", Recipe.is_archived == False) + )).scalar() or 0 + + component = total - dishes + + # Ingredients with no sources + unmapped = (await db.execute( + select(func.count()).select_from(Ingredient).where( + Ingredient.kitchen_id == kid, + Ingredient.is_archived == False, + ~Ingredient.id.in_( + select(IngredientSource.ingredient_id).where(IngredientSource.kitchen_id == kid) + ), + ) + )).scalar() or 0 + + # Recipes without any cost snapshots + without_costing = (await db.execute( + select(func.count()).select_from(Recipe).where( + Recipe.kitchen_id == kid, + Recipe.is_archived == False, + ~Recipe.id.in_( + select(RecipeCostSnapshot.recipe_id).distinct() + ), + ) + )).scalar() or 0 + + # Dishes missing allergen assessment + # Find required flag categories for this kitchen + req_cats = (await db.execute( + select(FoodFlagCategory.id).where( + FoodFlagCategory.kitchen_id == kid, + FoodFlagCategory.required == True, + ) + )).scalars().all() + + dishes_missing_allergens = 0 + dishes_missing_list: list[dict] = [] + + if req_cats: + # Get all dish recipes (non-archived) + dish_rows = (await db.execute( + select(Recipe.id, Recipe.name).where( + Recipe.kitchen_id == kid, + Recipe.recipe_type == "dish", + Recipe.is_archived == False, + ) + )).all() + + for dish_id, dish_name in dish_rows: + # Get all ingredient IDs used by this dish (direct + via sub-recipes) + direct_ings = (await db.execute( + select(RecipeIngredient.ingredient_id).where( + RecipeIngredient.recipe_id == dish_id + ) + )).scalars().all() + + sub_recipe_ids = (await db.execute( + select(RecipeSubRecipe.child_recipe_id).where( + RecipeSubRecipe.parent_recipe_id == dish_id + ) + )).scalars().all() + + sub_ings: list[int] = [] + for sr_id in sub_recipe_ids: + sr_ings = (await db.execute( + select(RecipeIngredient.ingredient_id).where( + RecipeIngredient.recipe_id == sr_id + ) + )).scalars().all() + sub_ings.extend(sr_ings) + + all_ing_ids = set(direct_ings) | set(sub_ings) + if not all_ing_ids: + continue + + # Check each ingredient against required categories + has_unassessed = False + for ing_id in all_ing_ids: + for cat_id in req_cats: + # Check if ingredient has any flag in this category + has_flag = (await db.execute( + select(IngredientFlag.id).where( + IngredientFlag.ingredient_id == ing_id, + IngredientFlag.food_flag_id.in_( + select(FoodFlag.id).where(FoodFlag.category_id == cat_id) + ), + ).limit(1) + )).scalar_one_or_none() + + if not has_flag: + # Check if ingredient has "none" for this category + has_none = (await db.execute( + select(IngredientFlagNone.id).where( + IngredientFlagNone.ingredient_id == ing_id, + IngredientFlagNone.category_id == cat_id, + ).limit(1) + )).scalar_one_or_none() + + if not has_none: + has_unassessed = True + break + if has_unassessed: + break + + if has_unassessed: + dishes_missing_allergens += 1 + dishes_missing_list.append({"id": dish_id, "name": dish_name}) + + # Recipes affected by ingredient price changes in last 14 days + cutoff = datetime.utcnow() - timedelta(days=14) + price_change_logs = (await db.execute( + select(RecipeChangeLog.recipe_id).where( + RecipeChangeLog.created_at >= cutoff, + RecipeChangeLog.user_id == None, + (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), + RecipeChangeLog.recipe_id.in_( + select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) + ), + ).distinct() + )).scalars().all() + + # Menu items needing republishing + from api.menus import _compute_staleness + menu_result = await db.execute( + select(Menu).options(selectinload(Menu.items)).where( + Menu.kitchen_id == kid, Menu.is_active == True + ) + ) + active_menus = menu_result.scalars().all() + stale_menu_items = 0 + stale_menu_names: list[str] = [] + for menu in active_menus: + if menu.items: + staleness = await _compute_staleness(list(menu.items), db) + menu_stale = sum(1 for s in staleness.values() if s.get("is_stale")) + if menu_stale > 0: + stale_menu_items += menu_stale + stale_menu_names.append(menu.name) + + return { + "total_recipes": total, + "dish_count": dishes, + "component_recipes": component, + "unmapped_ingredients": unmapped, + "recipes_without_costing": without_costing, + "dishes_missing_allergens": dishes_missing_allergens, + "dishes_missing_allergens_list": dishes_missing_list, + "recipes_with_price_changes": len(price_change_logs), + "stale_menu_items": stale_menu_items, + "stale_menu_names": stale_menu_names, + } + + +@router.get("/price-impact") +async def price_impact_report( + days: int = Query(14, ge=1, le=365), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Recipes affected by ingredient price changes within the given period.""" + kid = user.kitchen_id + cutoff = datetime.utcnow() - timedelta(days=days) + + # Find price-change log entries within the period + log_result = await db.execute( + select(RecipeChangeLog).where( + RecipeChangeLog.created_at >= cutoff, + RecipeChangeLog.user_id == None, + (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), + RecipeChangeLog.recipe_id.in_( + select(Recipe.id).where(Recipe.kitchen_id == kid, Recipe.is_archived == False) + ), + ).order_by(RecipeChangeLog.created_at.desc()) + ) + logs = log_result.scalars().all() + + if not logs: + return {"days": days, "recipes": []} + + import re + price_change_re = re.compile( + r"^(.+?) price changed: £([\d.]+)(/(\w+))? → £([\d.]+)(/(\w+))?$" + ) + price_set_re = re.compile( + r"^(.+?) price set: £([\d.]+)(/(\w+))?$" + ) + + # Batch-load invoice numbers for logs that have source_invoice_id + invoice_ids = {log.source_invoice_id for log in logs if log.source_invoice_id} + invoice_map: dict[int, str] = {} + if invoice_ids: + from models.invoice import Invoice + inv_result = await db.execute( + select(Invoice.id, Invoice.invoice_number).where(Invoice.id.in_(invoice_ids)) + ) + invoice_map = {row[0]: row[1] for row in inv_result.fetchall()} + + # Group log entries by recipe, parsing structured data from summary + recipe_changes: dict[int, list[dict]] = {} + for log in logs: + entry: dict = { + "summary": log.change_summary, + "date": str(log.created_at.date()) if log.created_at else None, + "ingredient_name": None, + "old_price": None, + "new_price": None, + "unit": None, + "source_invoice_id": log.source_invoice_id, + "source_invoice_number": invoice_map.get(log.source_invoice_id) if log.source_invoice_id else None, + } + m = price_change_re.match(log.change_summary) + if m: + entry["ingredient_name"] = m.group(1) + entry["old_price"] = float(m.group(2)) + entry["new_price"] = float(m.group(5)) + entry["unit"] = m.group(4) or m.group(7) + else: + m2 = price_set_re.match(log.change_summary) + if m2: + entry["ingredient_name"] = m2.group(1) + entry["new_price"] = float(m2.group(2)) + entry["unit"] = m2.group(4) + recipe_changes.setdefault(log.recipe_id, []).append(entry) + + # Get recipe info + cost snapshots for affected recipes + recipe_ids = list(recipe_changes.keys()) + recipe_result = await db.execute( + select(Recipe) + .options( + selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient), + selectinload(Recipe.sub_recipes) + .selectinload(RecipeSubRecipe.child_recipe) + .options( + selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient), + selectinload(Recipe.sub_recipes) + .selectinload(RecipeSubRecipe.child_recipe) + .selectinload(Recipe.ingredients) + .selectinload(RecipeIngredient.ingredient), + ), + ) + .where(Recipe.id.in_(recipe_ids)) + ) + recipes = {r.id: r for r in recipe_result.scalars().all()} + + def _build_ing_usage(recipe, scale: float = 1.0, depth: int = 0) -> dict[str, float]: + """Recursively build ingredient name -> qty_in_std_unit map, expanding sub-recipes.""" + usage: dict[str, float] = {} + for ri in recipe.ingredients: + if ri.ingredient and ri.quantity: + display_unit = ri.unit or ri.ingredient.standard_unit + qty_std = _convert_unit(float(ri.quantity), display_unit, ri.ingredient.standard_unit) + yld = float(ri.yield_percent) if ri.yield_percent else 100.0 + qty_effective = (qty_std / (yld / 100) if yld > 0 else qty_std) * scale + key = ri.ingredient.name.lower() + usage[key] = usage.get(key, 0) + qty_effective + if depth < 3: + try: + for sr in recipe.sub_recipes: + child = sr.child_recipe + if not child: + continue + child_output = _get_output_qty(child) + if not child_output: + continue + needed = float(sr.portions_needed) if sr.portions_needed else 0 + if not needed: + continue + # Convert portions_needed unit to child output unit if needed (bulk sub-recipes) + child_output_unit = (_get_output_unit(child)).lower().strip() + needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip() + if needed_unit != child_output_unit and child.batch_output_type == "bulk": + converted = _convert_unit(needed, needed_unit, child_output_unit) + if converted is not None: + needed = converted + sub_scale = scale * needed / child_output + child_usage = _build_ing_usage(child, sub_scale, depth + 1) + for k, v in child_usage.items(): + usage[k] = usage.get(k, 0) + v + except Exception: + pass # lazy load guard + return usage + + items = [] + for rid in recipe_ids: + r = recipes.get(rid) + if not r: + continue + + output_qty = _get_output_qty(r) + + # Build ingredient name -> usage map for cost impact calculation (including sub-recipes) + ing_usage = _build_ing_usage(r) + + # Calculate per-change cost impact + for change in recipe_changes[rid]: + impact = None + if change["old_price"] is not None and change["new_price"] is not None and change["ingredient_name"]: + qty = ing_usage.get(change["ingredient_name"].lower()) + if qty is not None: + price_diff = change["new_price"] - change["old_price"] + # Impact per output unit (portion/kg/etc) + impact = round(price_diff * qty / output_qty, 4) if output_qty else None + change["cost_impact"] = impact + + # Latest snapshot (current cost) + latest_snap = (await db.execute( + select(RecipeCostSnapshot).where( + RecipeCostSnapshot.recipe_id == rid, + ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1) + )).scalar_one_or_none() + + # Snapshot just before the period (previous cost) + prev_snap = (await db.execute( + select(RecipeCostSnapshot).where( + RecipeCostSnapshot.recipe_id == rid, + RecipeCostSnapshot.snapshot_date < cutoff.date(), + ).order_by(RecipeCostSnapshot.snapshot_date.desc()).limit(1) + )).scalar_one_or_none() + + current_cost = float(latest_snap.cost_per_portion) if latest_snap else None + previous_cost = float(prev_snap.cost_per_portion) if prev_snap else None + cost_change = None + cost_change_pct = None + if current_cost is not None and previous_cost is not None and previous_cost > 0: + cost_change = round(current_cost - previous_cost, 4) + cost_change_pct = round((cost_change / previous_cost) * 100, 1) + + items.append({ + "recipe_id": rid, + "recipe_name": r.name, + "recipe_type": r.recipe_type, + "output_unit": _get_output_unit(r), + "current_cost_per_unit": current_cost, + "previous_cost_per_unit": previous_cost, + "cost_change": cost_change, + "cost_change_pct": cost_change_pct, + "ingredient_changes": recipe_changes[rid], + }) + + # Sort by absolute cost change descending (biggest movers first) + items.sort(key=lambda x: abs(x["cost_change"] or 0), reverse=True) + + return {"days": days, "recipes": items} + + +# ── Recipe List & CRUD ─────────────────────────────────────────────────────── + +@router.get("") +async def list_recipes( + recipe_type: Optional[str] = Query(None), + menu_section_id: Optional[int] = Query(None), + search: Optional[str] = Query(None), + archived: bool = Query(False), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + query = ( + select(Recipe) + .options(selectinload(Recipe.menu_section), selectinload(Recipe.images)) + .where(Recipe.kitchen_id == user.kitchen_id) + ) + if not archived: + query = query.where(Recipe.is_archived == False) + if recipe_type: + query = query.where(Recipe.recipe_type == recipe_type) + if menu_section_id: + query = query.where(Recipe.menu_section_id == menu_section_id) + if search: + query = query.where(Recipe.name.ilike(f"%{search}%")) + + result = await db.execute(query.order_by(Recipe.name)) + recipes = result.scalars().all() + + items = [] + for r in recipes: + # Get latest cost snapshot + snap_result = await db.execute( + select(RecipeCostSnapshot) + .where(RecipeCostSnapshot.recipe_id == r.id) + .order_by(RecipeCostSnapshot.snapshot_date.desc()) + .limit(1) + ) + snap = snap_result.scalar_one_or_none() + + # Get flag summary (lightweight) + from api.food_flags import compute_recipe_flags + flags = await compute_recipe_flags(r.id, user.kitchen_id, db) + flag_summary = [ + {"name": f.flag_name, "code": f.flag_code, "icon": f.flag_icon, + "category": f.category_name, "propagation": f.propagation_type, + "active": f.is_active, "excludable": f.excludable_on_request} + for f in flags if f.is_active + ] + + items.append(RecipeListItem( + id=r.id, + name=r.name, + recipe_type=r.recipe_type, + menu_section_id=r.menu_section_id, + menu_section_name=r.menu_section.name if r.menu_section else None, + batch_portions=r.batch_portions, + batch_output_type=r.batch_output_type or "portions", + batch_yield_qty=float(r.batch_yield_qty) if r.batch_yield_qty else None, + batch_yield_unit=r.batch_yield_unit, + output_unit=_get_output_unit(r), + cost_per_portion=float(snap.cost_per_portion) if snap else None, + total_cost=float(snap.total_cost) if snap else None, + is_archived=r.is_archived, + prep_time_minutes=r.prep_time_minutes, + cook_time_minutes=r.cook_time_minutes, + flag_summary=flag_summary, + image_count=len(r.images) if r.images else 0, + gross_sell_price=float(r.gross_sell_price) if r.gross_sell_price else None, + kds_menu_item_name=r.kds_menu_item_name, + sambapos_portion_name=r.sambapos_portion_name, + created_at=str(r.created_at) if r.created_at else "", + updated_at=str(r.updated_at) if r.updated_at else "", + )) + + return items + + +@router.post("") +async def create_recipe( + data: RecipeCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + if data.recipe_type not in ("component", "dish"): + raise HTTPException(400, "recipe_type must be 'component' or 'dish'") + + # Validate bulk output + if data.batch_output_type == "bulk": + if not data.batch_yield_qty or data.batch_yield_qty <= 0: + raise HTTPException(400, "Bulk recipes require a positive batch_yield_qty") + if data.batch_yield_unit not in ("g", "kg", "ml", "ltr"): + raise HTTPException(400, "batch_yield_unit must be g, kg, ml, or ltr") + + recipe = Recipe( + kitchen_id=user.kitchen_id, + name=data.name.strip(), + recipe_type=data.recipe_type, + menu_section_id=data.menu_section_id, + description=data.description, + batch_portions=1 if data.batch_output_type == "bulk" else (data.batch_portions if data.recipe_type == "component" else 1), + batch_output_type=data.batch_output_type if data.recipe_type == "component" else "portions", + batch_yield_qty=Decimal(str(data.batch_yield_qty)) if data.batch_output_type == "bulk" and data.batch_yield_qty else None, + batch_yield_unit=data.batch_yield_unit if data.batch_output_type == "bulk" else None, + prep_time_minutes=data.prep_time_minutes, + cook_time_minutes=data.cook_time_minutes, + notes=data.notes, + created_by=user.id, + ) + db.add(recipe) + await db.commit() + await db.refresh(recipe) + return {"id": recipe.id, "name": recipe.name} + + +@router.get("/{recipe_id}") +async def get_recipe( + recipe_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Recipe) + .options( + selectinload(Recipe.menu_section), + selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources), + selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe), + selectinload(Recipe.steps), + selectinload(Recipe.images), + ) + .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) + ) + recipe = result.scalar_one_or_none() + if not recipe: + raise HTTPException(404, "Recipe not found") + + # Build detailed response + ingredients = [] + for ri in sorted(recipe.ingredients, key=lambda x: x.sort_order): + ing = ri.ingredient + if not ing: + continue + display_unit = ri.unit or ing.standard_unit + # Get effective price (yield from recipe ingredient, not raw ingredient) + yld = float(ri.yield_percent) if ri.yield_percent else 100.0 + eff_price = None + if ing.sources: + priced = [s for s in ing.sources if s.price_per_std_unit] + if priced: + latest = max(priced, key=lambda s: s.latest_invoice_date or date.min) + raw = float(latest.price_per_std_unit) + eff_price = raw / (yld / 100) if yld > 0 else raw + is_manual_price = False + if eff_price is None and ing.manual_price: + eff_price = float(ing.manual_price) / (yld / 100) if yld > 0 else float(ing.manual_price) + is_manual_price = True + + # Convert quantity to standard unit for cost calculation + qty_in_std = _convert_unit(float(ri.quantity), display_unit, ing.standard_unit) + cost = qty_in_std * eff_price if eff_price and ri.quantity else None + + ingredients.append({ + "id": ri.id, + "ingredient_id": ing.id, + "ingredient_name": ing.name, + "quantity": float(ri.quantity), + "unit": display_unit, + "standard_unit": ing.standard_unit, + "compatible_units": _get_compatible_units(ing.standard_unit), + "yield_percent": yld, + "effective_price": round(eff_price, 6) if eff_price else None, + "cost": round(cost, 4) if cost else None, + "is_manual_price": is_manual_price if not (hasattr(ing, 'is_free') and ing.is_free) else False, + "has_no_price": (eff_price is None) if not (hasattr(ing, 'is_free') and ing.is_free) else False, + "notes": ri.notes, + "sort_order": ri.sort_order, + }) + + sub_recipes = [] + for sr in sorted(recipe.sub_recipes, key=lambda x: x.sort_order): + child = sr.child_recipe + if not child: + continue + # Calculate child cost from scratch (not from snapshots, which may be stale/missing) + child_cost_data = await _calc_recipe_cost(child.id, db) + child_total = child_cost_data.get("total_cost_recent", 0) or 0 + child_output_qty = _get_output_qty(child) + child_cost_per_portion = child_total / child_output_qty if child_total and child_output_qty else None + child_output_unit = _get_output_unit(child) + needed_unit = sr.portions_needed_unit or child_output_unit + # Convert to child output unit for costing + needed_in_output_unit = _convert_unit(float(sr.portions_needed), needed_unit, child_output_unit) + cost_contribution = None + if child_cost_per_portion and sr.portions_needed: + cost_contribution = needed_in_output_unit * child_cost_per_portion + + # Check if child recipe has any manual-priced or no-price ingredients + child_has_manual = any(ci.get("is_manual_price") for ci in child_cost_data.get("ingredients", [])) + child_has_no_price = any(ci.get("has_no_price") for ci in child_cost_data.get("ingredients", [])) + # Also check nested sub-recipe child ingredients recursively + def _check_subs(subs): + m, n = False, False + for s in (subs or []): + for ci in s.get("child_ingredients", []): + if ci.get("is_manual_price"): m = True + if ci.get("has_no_price"): n = True + sm, sn = _check_subs(s.get("child_sub_recipes", [])) + m = m or sm + n = n or sn + return m, n + sub_m, sub_n = _check_subs(child_cost_data.get("sub_recipes", [])) + child_has_manual = child_has_manual or sub_m + child_has_no_price = child_has_no_price or sub_n + + sub_recipes.append({ + "id": sr.id, + "child_recipe_id": child.id, + "child_recipe_name": child.name, + "child_recipe_type": child.recipe_type, + "batch_portions": child.batch_portions, + "batch_output_type": child.batch_output_type or "portions", + "batch_yield_qty": float(child.batch_yield_qty) if child.batch_yield_qty else None, + "batch_yield_unit": child.batch_yield_unit, + "output_qty": _get_output_qty(child), + "output_unit": child_output_unit, + "portions_needed": float(sr.portions_needed), + "portions_needed_unit": needed_unit, + "compatible_units": _get_compatible_units(child_output_unit), + "cost_per_portion": child_cost_per_portion, + "cost_contribution": round(cost_contribution, 4) if cost_contribution else None, + "has_manual_price_ingredients": child_has_manual, + "has_no_price_ingredients": child_has_no_price, + "notes": sr.notes, + "sort_order": sr.sort_order, + }) + + steps = [ + { + "id": s.id, "step_number": s.step_number, "title": s.title, + "instruction": s.instruction, "image_path": s.image_path, + "duration_minutes": s.duration_minutes, "notes": s.notes, + } + for s in sorted(recipe.steps, key=lambda x: x.step_number) + ] + + images = [ + { + "id": img.id, "image_path": img.image_path, "caption": img.caption, + "image_type": img.image_type, "sort_order": img.sort_order, + } + for img in sorted(recipe.images, key=lambda x: x.sort_order) + ] + + return { + "id": recipe.id, + "name": recipe.name, + "recipe_type": recipe.recipe_type, + "menu_section_id": recipe.menu_section_id, + "menu_section_name": recipe.menu_section.name if recipe.menu_section else None, + "description": recipe.description, + "batch_portions": recipe.batch_portions, + "batch_output_type": recipe.batch_output_type or "portions", + "batch_yield_qty": float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None, + "batch_yield_unit": recipe.batch_yield_unit, + "output_qty": _get_output_qty(recipe), + "output_unit": _get_output_unit(recipe), + "prep_time_minutes": recipe.prep_time_minutes, + "cook_time_minutes": recipe.cook_time_minutes, + "notes": recipe.notes, + "is_archived": recipe.is_archived, + "kds_menu_item_name": recipe.kds_menu_item_name, + "sambapos_portion_name": recipe.sambapos_portion_name, + "gross_sell_price": float(recipe.gross_sell_price) if recipe.gross_sell_price else None, + "ingredients": ingredients, + "sub_recipes": sub_recipes, + "steps": steps, + "images": images, + "created_at": str(recipe.created_at) if recipe.created_at else "", + "updated_at": str(recipe.updated_at) if recipe.updated_at else "", + } + + +@router.patch("/{recipe_id}") +async def update_recipe( + recipe_id: int, + data: RecipeUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) + ) + recipe = result.scalar_one_or_none() + if not recipe: + raise HTTPException(404, "Recipe not found") + + changes = [] + if data.name is not None and data.name != recipe.name: + changes.append(f"Name changed from '{recipe.name}' to '{data.name}'") + recipe.name = data.name.strip() + if data.recipe_type is not None and data.recipe_type != recipe.recipe_type: + changes.append(f"Type changed from '{recipe.recipe_type}' to '{data.recipe_type}'") + recipe.recipe_type = data.recipe_type + if data.menu_section_id is not None: + recipe.menu_section_id = data.menu_section_id + if data.description is not None: + recipe.description = data.description + if data.batch_portions is not None and data.batch_portions != recipe.batch_portions: + changes.append(f"Batch portions changed from {recipe.batch_portions} to {data.batch_portions}") + recipe.batch_portions = data.batch_portions + if data.batch_output_type is not None and data.batch_output_type != recipe.batch_output_type: + changes.append(f"Output type changed from '{recipe.batch_output_type}' to '{data.batch_output_type}'") + recipe.batch_output_type = data.batch_output_type + if data.batch_output_type == "bulk": + recipe.batch_portions = 1 + elif data.batch_output_type == "portions": + recipe.batch_yield_qty = None + recipe.batch_yield_unit = None + if data.batch_yield_qty is not None: + old_val = float(recipe.batch_yield_qty) if recipe.batch_yield_qty else None + if old_val != data.batch_yield_qty: + changes.append(f"Yield qty changed from {old_val} to {data.batch_yield_qty}") + recipe.batch_yield_qty = Decimal(str(data.batch_yield_qty)) if data.batch_yield_qty > 0 else None + if data.batch_yield_unit is not None and data.batch_yield_unit != recipe.batch_yield_unit: + changes.append(f"Yield unit changed from '{recipe.batch_yield_unit}' to '{data.batch_yield_unit}'") + recipe.batch_yield_unit = data.batch_yield_unit + if data.prep_time_minutes is not None: + recipe.prep_time_minutes = data.prep_time_minutes + if data.cook_time_minutes is not None: + recipe.cook_time_minutes = data.cook_time_minutes + if data.notes is not None: + recipe.notes = data.notes + if data.is_archived is not None: + recipe.is_archived = data.is_archived + if data.kds_menu_item_name is not None: + recipe.kds_menu_item_name = data.kds_menu_item_name + if data.sambapos_portion_name is not None: + recipe.sambapos_portion_name = data.sambapos_portion_name if data.sambapos_portion_name else None + if data.gross_sell_price is not None: + recipe.gross_sell_price = data.gross_sell_price if data.gross_sell_price > 0 else None + + if changes: + db.add(RecipeChangeLog( + recipe_id=recipe_id, + change_summary="; ".join(changes), + user_id=user.id, + )) + + await db.commit() + if changes: + await _snapshot_recipe_and_parents(recipe_id, db, f"recipe_updated: {'; '.join(changes)}") + return {"ok": True} + + +@router.delete("/{recipe_id}") +async def archive_recipe( + recipe_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) + ) + recipe = result.scalar_one_or_none() + if not recipe: + raise HTTPException(404, "Recipe not found") + recipe.is_archived = True + await db.commit() + return {"ok": True} + + +@router.post("/{recipe_id}/duplicate") +async def duplicate_recipe( + recipe_id: int, + new_name: Optional[str] = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Clone recipe with deep copy of ingredients, steps, images, flags. Sub-recipes are linked.""" + result = await db.execute( + select(Recipe) + .options( + selectinload(Recipe.ingredients), + selectinload(Recipe.sub_recipes), + selectinload(Recipe.steps), + selectinload(Recipe.images), + ) + .where(Recipe.id == recipe_id, Recipe.kitchen_id == user.kitchen_id) + ) + original = result.scalar_one_or_none() + if not original: + raise HTTPException(404, "Recipe not found") + + name = new_name or f"{original.name} (Copy)" + clone = Recipe( + kitchen_id=user.kitchen_id, + name=name, + recipe_type=original.recipe_type, + menu_section_id=original.menu_section_id, + description=original.description, + batch_portions=original.batch_portions, + batch_output_type=original.batch_output_type, + batch_yield_qty=original.batch_yield_qty, + batch_yield_unit=original.batch_yield_unit, + prep_time_minutes=original.prep_time_minutes, + cook_time_minutes=original.cook_time_minutes, + notes=original.notes, + created_by=user.id, + ) + db.add(clone) + await db.flush() + + # Copy ingredients + for ri in original.ingredients: + db.add(RecipeIngredient( + recipe_id=clone.id, + ingredient_id=ri.ingredient_id, + quantity=ri.quantity, + unit=ri.unit, + notes=ri.notes, + sort_order=ri.sort_order, + )) + + # Link sub-recipes (not deep copy) + for sr in original.sub_recipes: + db.add(RecipeSubRecipe( + parent_recipe_id=clone.id, + child_recipe_id=sr.child_recipe_id, + portions_needed=sr.portions_needed, + portions_needed_unit=sr.portions_needed_unit, + notes=sr.notes, + sort_order=sr.sort_order, + )) + + # Copy steps + for step in original.steps: + db.add(RecipeStep( + recipe_id=clone.id, + step_number=step.step_number, + title=step.title, + instruction=step.instruction, + duration_minutes=step.duration_minutes, + notes=step.notes, + )) + + # Copy images (copy file references, images are shared) + for img in original.images: + db.add(RecipeImage( + recipe_id=clone.id, + image_path=img.image_path, + caption=img.caption, + image_type=img.image_type, + sort_order=img.sort_order, + uploaded_by=user.id, + )) + + db.add(RecipeChangeLog( + recipe_id=clone.id, + change_summary=f"Duplicated from '{original.name}' (ID: {original.id})", + user_id=user.id, + )) + + await db.commit() + return {"id": clone.id, "name": clone.name} + + +# ── Recipe Ingredients ─────────────────────────────────────────────────────── + +@router.post("/{recipe_id}/ingredients") +async def add_recipe_ingredient( + recipe_id: int, + data: IngredientAdd, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + ing = await db.execute( + select(Ingredient).where(Ingredient.id == data.ingredient_id, Ingredient.kitchen_id == user.kitchen_id) + ) + ingredient = ing.scalar_one_or_none() + if not ingredient: + raise HTTPException(404, "Ingredient not found") + + # Store unit override if different from standard_unit + unit_override = None + display_unit = ingredient.standard_unit + if data.unit and data.unit != ingredient.standard_unit: + # Validate it's a compatible unit + if data.unit not in _get_compatible_units(ingredient.standard_unit): + raise HTTPException(400, f"Unit '{data.unit}' is not compatible with ingredient's standard unit '{ingredient.standard_unit}'") + unit_override = data.unit + display_unit = data.unit + + ri = RecipeIngredient( + recipe_id=recipe_id, + ingredient_id=data.ingredient_id, + quantity=Decimal(str(data.quantity)), + unit=unit_override, + yield_percent=Decimal(str(data.yield_percent)), + notes=data.notes, + sort_order=data.sort_order, + ) + db.add(ri) + + db.add(RecipeChangeLog( + recipe_id=recipe_id, + change_summary=f"Added {ingredient.name} ({data.quantity}{display_unit})", + user_id=user.id, + )) + + await db.commit() + await db.refresh(ri) + await _snapshot_recipe_and_parents(recipe_id, db, f"ingredient_added: {ingredient.name}") + return {"id": ri.id} + + +@router.patch("/recipe-ingredients/{ri_id}") +async def update_recipe_ingredient( + ri_id: int, + data: IngredientUpdateSchema, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ri_result = await db.execute( + select(RecipeIngredient) + .options(selectinload(RecipeIngredient.ingredient)) + .where(RecipeIngredient.id == ri_id) + ) + ri = ri_result.scalar_one_or_none() + if not ri: + raise HTTPException(404, "Recipe ingredient not found") + + changes = [] + if data.unit is not None: + new_unit = data.unit if data.unit != ri.ingredient.standard_unit else None + if new_unit != ri.unit: + old_display = ri.unit or ri.ingredient.standard_unit + new_display = data.unit or ri.ingredient.standard_unit + if old_display != new_display: + changes.append(f"{ri.ingredient.name} unit changed from {old_display} to {new_display}") + ri.unit = new_unit + if data.quantity is not None and float(ri.quantity) != data.quantity: + old_qty = float(ri.quantity) + display_unit = ri.unit or ri.ingredient.standard_unit + changes.append(f"{ri.ingredient.name} quantity changed from {old_qty} to {data.quantity}{display_unit}") + ri.quantity = Decimal(str(data.quantity)) + if data.yield_percent is not None and float(ri.yield_percent) != data.yield_percent: + old_yld = float(ri.yield_percent) + changes.append(f"{ri.ingredient.name} yield changed from {old_yld}% to {data.yield_percent}%") + ri.yield_percent = Decimal(str(data.yield_percent)) + if data.notes is not None: + ri.notes = data.notes + if data.sort_order is not None: + ri.sort_order = data.sort_order + + if changes: + db.add(RecipeChangeLog( + recipe_id=ri.recipe_id, + change_summary="; ".join(changes), + user_id=user.id, + )) + + await db.commit() + if changes: + await _snapshot_recipe_and_parents(ri.recipe_id, db, f"ingredient_updated: {'; '.join(changes)}") + return {"ok": True} + + +@router.delete("/recipe-ingredients/{ri_id}") +async def remove_recipe_ingredient( + ri_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + ri_result = await db.execute( + select(RecipeIngredient) + .options(selectinload(RecipeIngredient.ingredient)) + .where(RecipeIngredient.id == ri_id) + ) + ri = ri_result.scalar_one_or_none() + if not ri: + raise HTTPException(404, "Recipe ingredient not found") + + recipe_id_for_snap = ri.recipe_id + ing_name = ri.ingredient.name if ri.ingredient else 'unknown' + db.add(RecipeChangeLog( + recipe_id=recipe_id_for_snap, + change_summary=f"Removed {ing_name}", + user_id=user.id, + )) + await db.delete(ri) + await db.commit() + await _snapshot_recipe_and_parents(recipe_id_for_snap, db, f"ingredient_removed: {ing_name}") + return {"ok": True} + + +# ── Sub-recipes ────────────────────────────────────────────────────────────── + +@router.post("/{recipe_id}/sub-recipes") +async def add_sub_recipe( + recipe_id: int, + data: SubRecipeAdd, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + + if data.child_recipe_id == recipe_id: + raise HTTPException(400, "Cannot add recipe as its own sub-recipe") + + # Circular dependency check (max 5 levels) + cycle_check = await db.execute( + text(""" + WITH RECURSIVE ancestors AS ( + SELECT parent_recipe_id, child_recipe_id, 1 AS depth + FROM recipe_sub_recipes WHERE child_recipe_id = :parent_id + UNION ALL + SELECT rsr.parent_recipe_id, rsr.child_recipe_id, a.depth + 1 + FROM recipe_sub_recipes rsr JOIN ancestors a ON rsr.child_recipe_id = a.parent_recipe_id + WHERE a.depth < 5 + ) + SELECT 1 FROM ancestors WHERE parent_recipe_id = :child_id LIMIT 1 + """), + {"parent_id": recipe_id, "child_id": data.child_recipe_id}, + ) + if cycle_check.fetchone(): + raise HTTPException(400, "Adding this sub-recipe would create a circular dependency") + + sr = RecipeSubRecipe( + parent_recipe_id=recipe_id, + child_recipe_id=data.child_recipe_id, + portions_needed=Decimal(str(data.portions_needed)), + portions_needed_unit=data.portions_needed_unit, + notes=data.notes, + sort_order=data.sort_order, + ) + db.add(sr) + + child = await db.execute(select(Recipe).where(Recipe.id == data.child_recipe_id)) + child_recipe = child.scalar_one_or_none() + unit_label = data.portions_needed_unit or _get_output_unit(child_recipe) if child_recipe else "portions" + db.add(RecipeChangeLog( + recipe_id=recipe_id, + change_summary=f"Added sub-recipe '{child_recipe.name if child_recipe else '?'}' ({data.portions_needed} {unit_label})", + user_id=user.id, + )) + + await db.commit() + await db.refresh(sr) + await _snapshot_recipe_and_parents(recipe_id, db, f"sub_recipe_added: {child_recipe.name if child_recipe else '?'}") + return {"id": sr.id} + + +@router.patch("/recipe-sub-recipes/{sr_id}") +async def update_sub_recipe( + sr_id: int, + data: SubRecipeUpdateSchema, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + sr_result = await db.execute( + select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id) + ) + sr = sr_result.scalar_one_or_none() + if not sr: + raise HTTPException(404, "Sub-recipe not found") + changed = False + if data.portions_needed is not None: + sr.portions_needed = Decimal(str(data.portions_needed)) + changed = True + if data.portions_needed_unit is not None: + sr.portions_needed_unit = data.portions_needed_unit or None + changed = True + if data.notes is not None: + sr.notes = data.notes + if data.sort_order is not None: + sr.sort_order = data.sort_order + await db.commit() + if changed: + await _snapshot_recipe_and_parents(sr.parent_recipe_id, db, "sub_recipe_qty_updated") + return {"ok": True} + + +@router.delete("/recipe-sub-recipes/{sr_id}") +async def remove_sub_recipe( + sr_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + sr_result = await db.execute( + select(RecipeSubRecipe).where(RecipeSubRecipe.id == sr_id) + ) + sr = sr_result.scalar_one_or_none() + if not sr: + raise HTTPException(404, "Sub-recipe not found") + parent_id = sr.parent_recipe_id + await db.delete(sr) + await db.commit() + await _snapshot_recipe_and_parents(parent_id, db, "sub_recipe_removed") + return {"ok": True} + + +# ── Steps ──────────────────────────────────────────────────────────────────── + +@router.post("/{recipe_id}/steps") +async def add_step( + recipe_id: int, + data: StepCreate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await _get_recipe(recipe_id, user.kitchen_id, db) + step = RecipeStep( + recipe_id=recipe_id, + step_number=data.step_number, + title=data.title, + instruction=data.instruction, + duration_minutes=data.duration_minutes, + notes=data.notes, + ) + db.add(step) + await db.commit() + await db.refresh(step) + return {"id": step.id} + + +@router.patch("/recipe-steps/{step_id}") +async def update_step( + step_id: int, + data: StepUpdate, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id)) + step = result.scalar_one_or_none() + if not step: + raise HTTPException(404, "Step not found") + if data.title is not None: + step.title = data.title or None # empty string -> null + if data.instruction is not None: + step.instruction = data.instruction + if data.step_number is not None: + step.step_number = data.step_number + if data.duration_minutes is not None: + step.duration_minutes = data.duration_minutes + if data.notes is not None: + step.notes = data.notes + await db.commit() + return {"ok": True} + + +@router.delete("/recipe-steps/{step_id}") +async def delete_step( + step_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(RecipeStep).where(RecipeStep.id == step_id)) + step = result.scalar_one_or_none() + if not step: + raise HTTPException(404, "Step not found") + await db.delete(step) + await db.commit() + return {"ok": True} + + +@router.patch("/{recipe_id}/ingredients/reorder") +async def reorder_ingredients( + recipe_id: int, + data: IngredientReorder, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Batch reorder recipe ingredients.""" + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + for i, ri_id in enumerate(data.ingredient_ids): + result = await db.execute( + select(RecipeIngredient).where( + RecipeIngredient.id == ri_id, + RecipeIngredient.recipe_id == recipe_id, + ) + ) + ri = result.scalar_one_or_none() + if ri: + ri.sort_order = i + await db.commit() + return {"ok": True} + + +@router.patch("/{recipe_id}/sub-recipes/reorder") +async def reorder_sub_recipes( + recipe_id: int, + data: SubRecipeReorder, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Batch reorder sub-recipes.""" + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + for i, sr_id in enumerate(data.sub_recipe_ids): + result = await db.execute( + select(RecipeSubRecipe).where( + RecipeSubRecipe.id == sr_id, + RecipeSubRecipe.parent_recipe_id == recipe_id, + ) + ) + sr = result.scalar_one_or_none() + if sr: + sr.sort_order = i + await db.commit() + return {"ok": True} + + +@router.patch("/{recipe_id}/steps/reorder") +async def reorder_steps( + recipe_id: int, + data: StepReorder, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + for i, step_id in enumerate(data.step_ids): + result = await db.execute( + select(RecipeStep).where(RecipeStep.id == step_id, RecipeStep.recipe_id == recipe_id) + ) + step = result.scalar_one_or_none() + if step: + step.step_number = i + 1 + await db.commit() + return {"ok": True} + + +# ── Images ─────────────────────────────────────────────────────────────────── + +@router.post("/{recipe_id}/images") +async def upload_image( + recipe_id: int, + file: UploadFile = File(...), + caption: Optional[str] = Query(None), + image_type: str = Query("general"), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + + # Create directory + img_dir = os.path.join(DATA_DIR, str(user.kitchen_id), "recipes") + 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) + + img = RecipeImage( + recipe_id=recipe_id, + image_path=filepath, + caption=caption, + image_type=image_type, + uploaded_by=user.id, + ) + db.add(img) + await db.commit() + await db.refresh(img) + return {"id": img.id, "image_path": img.image_path} + + +@router.get("/{recipe_id}/images/{image_id}") +async def serve_image( + recipe_id: int, + image_id: int, + token: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), +): + # img tags can't send Authorization header, so auth via query param + if not token: + raise HTTPException(401, "Not authenticated") + user = await get_current_user_from_token(token, db) + if not user: + raise HTTPException(401, "Not authenticated") + await _get_recipe(recipe_id, user.kitchen_id, db) + result = await db.execute( + select(RecipeImage).where(RecipeImage.id == image_id, RecipeImage.recipe_id == recipe_id) + ) + img = result.scalar_one_or_none() + if not img or not os.path.exists(img.image_path): + raise HTTPException(404, "Image not found") + from fastapi.responses import FileResponse + return FileResponse(img.image_path) + + +@router.delete("/recipe-images/{image_id}") +async def delete_image( + image_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(RecipeImage).where(RecipeImage.id == image_id)) + img = result.scalar_one_or_none() + if not img: + raise HTTPException(404, "Image not found") + if os.path.exists(img.image_path): + os.remove(img.image_path) + await db.delete(img) + await db.commit() + return {"ok": True} + + +# ── Costing ────────────────────────────────────────────────────────────────── + +def _scale_child_sub_recipes(sub_recipes: list, scale: float) -> list: + """Recursively scale nested sub-recipe data for hierarchical display.""" + result = [] + for sr in sub_recipes: + result.append({ + "child_recipe_id": sr["child_recipe_id"], + "child_recipe_name": sr["child_recipe_name"], + "batch_output_type": sr.get("batch_output_type", "portions"), + "output_qty": sr.get("output_qty", 1), + "output_unit": sr.get("output_unit", "portion"), + "portions_needed": round(sr["portions_needed"] * scale, 4), + "cost_contribution": round(sr["cost_contribution"] * scale, 4) if sr.get("cost_contribution") else None, + "child_ingredients": [ + { + "ingredient_id": ci["ingredient_id"], + "ingredient_name": ci["ingredient_name"], + "quantity": round(ci["quantity"] * scale, 4), + "unit": ci["unit"], + "yield_percent": ci.get("yield_percent", 100.0), + "cost_recent": round(ci["cost_recent"] * scale, 4) if ci.get("cost_recent") else None, + "cost_min": round(ci["cost_min"] * scale, 4) if ci.get("cost_min") else None, + "cost_max": round(ci["cost_max"] * scale, 4) if ci.get("cost_max") else None, + "is_manual_price": ci.get("is_manual_price", False), + "has_no_price": ci.get("has_no_price", False), + } + for ci in sr.get("child_ingredients", []) + ], + "child_sub_recipes": _scale_child_sub_recipes(sr.get("child_sub_recipes", []), scale), + }) + return result + + +async def _calc_recipe_cost(recipe_id: int, db: AsyncSession, scale_to: Optional[float] = None) -> dict: + """Calculate full cost breakdown for a recipe.""" + recipe_result = await db.execute( + select(Recipe) + .options( + selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient).selectinload(Ingredient.sources), + selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe), + ) + .where(Recipe.id == recipe_id) + ) + recipe = recipe_result.scalar_one_or_none() + if not recipe: + return {} + + output_qty = _get_output_qty(recipe) + output_unit = _get_output_unit(recipe) + scale_factor = (scale_to / output_qty) if scale_to else 1.0 + + ingredient_costs = [] + total_ing_cost = Decimal(0) + total_ing_cost_min = Decimal(0) + total_ing_cost_max = Decimal(0) + + for ri in recipe.ingredients: + ing = ri.ingredient + if not ing: + continue + + display_unit = ri.unit or ing.standard_unit + qty_display = float(ri.quantity) * scale_factor + # Convert to standard unit for cost calculation + qty_std = _convert_unit(qty_display, display_unit, ing.standard_unit) + yld = float(ri.yield_percent) if ri.yield_percent else 100.0 + + # Get all source prices + source_prices = [] + for src in (ing.sources or []): + if src.price_per_std_unit: + source_prices.append({ + "supplier_id": src.supplier_id, + "price_per_std_unit": float(src.price_per_std_unit), + "latest_invoice_date": str(src.latest_invoice_date) if src.latest_invoice_date else None, + }) + + # Effective prices + recent_price = None + min_price = None + max_price = None + if source_prices: + prices = [sp["price_per_std_unit"] for sp in source_prices] + min_price = min(prices) + max_price = max(prices) + recent_price = source_prices[0]["price_per_std_unit"] + # Find most recent by date + dated = [(sp.get("latest_invoice_date", ""), sp["price_per_std_unit"]) for sp in source_prices] + dated.sort(reverse=True) + if dated: + recent_price = dated[0][1] + elif ing.manual_price: + recent_price = min_price = max_price = float(ing.manual_price) + + ing_is_free = getattr(ing, 'is_free', False) + is_manual_price = (not source_prices and ing.manual_price is not None) if not ing_is_free else False + has_no_price = (recent_price is None) if not ing_is_free else False + + # Apply yield adjustment + if recent_price and yld > 0: + recent_eff = recent_price / (yld / 100) + else: + recent_eff = recent_price + if min_price and yld > 0: + min_eff = min_price / (yld / 100) + else: + min_eff = min_price + if max_price and yld > 0: + max_eff = max_price / (yld / 100) + else: + max_eff = max_price + + # Cost is calculated in standard units + cost_recent = round(qty_std * recent_eff, 4) if recent_eff else None + cost_min = round(qty_std * min_eff, 4) if min_eff else None + cost_max = round(qty_std * max_eff, 4) if max_eff else None + + if cost_recent: + total_ing_cost += Decimal(str(cost_recent)) + if cost_min: + total_ing_cost_min += Decimal(str(cost_min)) + if cost_max: + total_ing_cost_max += Decimal(str(cost_max)) + + ingredient_costs.append({ + "ingredient_id": ing.id, + "ingredient_name": ing.name, + "quantity": qty_display, + "unit": display_unit, + "yield_percent": yld, + "recent_price": round(recent_eff, 6) if recent_eff else None, + "min_price": round(min_eff, 6) if min_eff else None, + "max_price": round(max_eff, 6) if max_eff else None, + "cost_recent": cost_recent, + "cost_min": cost_min, + "cost_max": cost_max, + "sources": source_prices, + "is_manual_price": is_manual_price, + "has_no_price": has_no_price, + }) + + # Sub-recipe costs + sub_recipe_costs = [] + total_sub_cost = Decimal(0) + total_sub_cost_min = Decimal(0) + total_sub_cost_max = Decimal(0) + + for sr in recipe.sub_recipes: + child = sr.child_recipe + if not child: + continue + child_cost_data = await _calc_recipe_cost(child.id, db) + child_total = Decimal(str(child_cost_data.get("total_cost_recent", 0) or 0)) + child_total_min = Decimal(str(child_cost_data.get("total_cost_min", 0) or 0)) + child_total_max = Decimal(str(child_cost_data.get("total_cost_max", 0) or 0)) + child_output_qty = _get_output_qty(child) + child_output_unit = _get_output_unit(child) + # Convert portions_needed to child output unit if different unit was used + needed_unit = sr.portions_needed_unit or child_output_unit + portions_needed_raw = float(sr.portions_needed) * scale_factor + portions_needed = _convert_unit(portions_needed_raw, needed_unit, child_output_unit) + scale_ratio = portions_needed / child_output_qty if child_output_qty else 0 + cost_contribution = float(child_total) * scale_ratio if child_total else None + cost_contribution_min = float(child_total_min) * scale_ratio if child_total_min else None + cost_contribution_max = float(child_total_max) * scale_ratio if child_total_max else None + + if cost_contribution: + total_sub_cost += Decimal(str(cost_contribution)) + if cost_contribution_min: + total_sub_cost_min += Decimal(str(cost_contribution_min)) + if cost_contribution_max: + total_sub_cost_max += Decimal(str(cost_contribution_max)) + + # Scale child ingredients by portions_needed / child_output_qty + # Include both direct ingredients AND ingredients from the child's own sub-recipes + child_scale = portions_needed / child_output_qty if child_output_qty else 1 + child_ingredients = [] + for ci in child_cost_data.get("ingredients", []): + child_ingredients.append({ + "ingredient_id": ci["ingredient_id"], + "ingredient_name": ci["ingredient_name"], + "quantity": round(ci["quantity"] * child_scale, 4), + "unit": ci["unit"], + "yield_percent": ci["yield_percent"], + "cost_recent": round(ci["cost_recent"] * child_scale, 4) if ci.get("cost_recent") else None, + "cost_min": round(ci["cost_min"] * child_scale, 4) if ci.get("cost_min") else None, + "cost_max": round(ci["cost_max"] * child_scale, 4) if ci.get("cost_max") else None, + "is_manual_price": ci.get("is_manual_price", False), + "has_no_price": ci.get("has_no_price", False), + }) + # Build hierarchical child_sub_recipes from the child's own sub-recipes + child_sub_recipes = _scale_child_sub_recipes( + child_cost_data.get("sub_recipes", []), child_scale + ) + + sub_recipe_costs.append({ + "child_recipe_id": child.id, + "child_recipe_name": child.name, + "batch_portions": child.batch_portions, + "batch_output_type": child.batch_output_type or "portions", + "output_qty": child_output_qty, + "output_unit": child_output_unit, + "portions_needed": portions_needed, + "cost_per_portion": float(child_total) / child_output_qty if child_total and child_output_qty else None, + "cost_contribution": round(cost_contribution, 4) if cost_contribution else None, + "child_ingredients": child_ingredients, + "child_sub_recipes": child_sub_recipes, + }) + + total_cost = float(total_ing_cost + total_sub_cost) + total_cost_min = float(total_ing_cost_min + total_sub_cost_min) + total_cost_max = float(total_ing_cost_max + total_sub_cost_max) + effective_output = scale_to if scale_to else output_qty + cost_per_portion = total_cost / effective_output if effective_output and total_cost else None + + # GP calculator for dishes (gross prices incl. 20% VAT) + gp_comparison = None + if recipe.recipe_type == "dish" and cost_per_portion: + vat_rate = 1.20 + gp_comparison = [ + {"gp_target": pct, "suggested_price": round(cost_per_portion / (1 - pct / 100) * vat_rate, 2)} + for pct in [60, 65, 70, 75, 80] + ] + + return { + "recipe_id": recipe.id, + "batch_portions": recipe.batch_portions or 1, + "batch_output_type": recipe.batch_output_type or "portions", + "output_qty": effective_output, + "output_unit": output_unit, + "ingredients": ingredient_costs, + "sub_recipes": sub_recipe_costs, + "total_cost_recent": round(total_cost, 4) if total_cost else None, + "total_cost_min": round(total_cost_min, 4) if total_cost_min else None, + "total_cost_max": round(total_cost_max, 4) if total_cost_max else None, + "cost_per_portion": round(cost_per_portion, 4) if cost_per_portion else None, + "gp_comparison": gp_comparison, + } + + +@router.get("/{recipe_id}/costing") +async def get_recipe_costing( + recipe_id: int, + scale_to: Optional[float] = Query(None), + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + return await _calc_recipe_cost(recipe_id, db, scale_to) + + +@router.get("/{recipe_id}/cost-trend") +async def get_cost_trend( + recipe_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await _get_recipe(recipe_id, user.kitchen_id, db) + result = await db.execute( + select(RecipeCostSnapshot) + .where(RecipeCostSnapshot.recipe_id == recipe_id) + .order_by(RecipeCostSnapshot.snapshot_date) + ) + snapshots = result.scalars().all() + + # Get change log entries to show what caused cost changes + log_result = await db.execute( + select(RecipeChangeLog) + .where(RecipeChangeLog.recipe_id == recipe_id) + .order_by(RecipeChangeLog.created_at) + ) + logs = log_result.scalars().all() + + # Group changes by date for annotation + changes_by_date: dict[str, list[str]] = {} + for log in logs: + if log.created_at: + log_date = str(log.created_at.date()) if hasattr(log.created_at, 'date') else str(log.created_at)[:10] + changes_by_date.setdefault(log_date, []).append(log.change_summary) + + return { + "snapshots": [ + { + "id": s.id, + "created_at": str(s.snapshot_date), + "cost_per_portion": float(s.cost_per_portion), + "total_cost": float(s.total_cost), + "trigger": s.trigger_source or "", + "changes": changes_by_date.get(str(s.snapshot_date), []), + } + for s in snapshots + ], + } + + +async def snapshot_recipe_cost(recipe_id: int, db: AsyncSession, trigger_source: str = "manual_recalc"): + """Calculate and store/upsert a cost snapshot for today.""" + cost_data = await _calc_recipe_cost(recipe_id, db) + total_cost = cost_data.get("total_cost_recent") + cost_per_portion = cost_data.get("cost_per_portion") + if total_cost is None or cost_per_portion is None: + return + + today = date.today() + existing = await db.execute( + select(RecipeCostSnapshot).where( + RecipeCostSnapshot.recipe_id == recipe_id, + RecipeCostSnapshot.snapshot_date == today, + ) + ) + snap = existing.scalar_one_or_none() + if snap: + snap.cost_per_portion = Decimal(str(cost_per_portion)) + snap.total_cost = Decimal(str(total_cost)) + snap.trigger_source = trigger_source + else: + db.add(RecipeCostSnapshot( + recipe_id=recipe_id, + cost_per_portion=Decimal(str(cost_per_portion)), + total_cost=Decimal(str(total_cost)), + snapshot_date=today, + trigger_source=trigger_source, + )) + + +async def _snapshot_recipe_and_parents(recipe_id: int, db: AsyncSession, trigger_source: str = ""): + """Snapshot a recipe and any parent recipes that use it as a sub-recipe. + Also bumps updated_at so menu staleness detection picks up content changes.""" + # Bump updated_at on the recipe itself (ingredient/sub-recipe changes don't trigger onupdate) + recipe_result = await db.execute(select(Recipe).where(Recipe.id == recipe_id)) + recipe = recipe_result.scalar_one_or_none() + if recipe: + recipe.updated_at = datetime.utcnow() + + await snapshot_recipe_cost(recipe_id, db, trigger_source) + parent_result = await db.execute( + select(RecipeSubRecipe.parent_recipe_id).where(RecipeSubRecipe.child_recipe_id == recipe_id) + ) + for (parent_id,) in parent_result.fetchall(): + # Bump parent updated_at too + p_result = await db.execute(select(Recipe).where(Recipe.id == parent_id)) + parent = p_result.scalar_one_or_none() + if parent: + parent.updated_at = datetime.utcnow() + await snapshot_recipe_cost(parent_id, db, trigger_source) + await db.commit() + + +async def snapshot_recipes_using_ingredient( + ingredient_id: int, + db: AsyncSession, + trigger_source: str = "", + price_info: dict | None = None, + invoice_id: int | None = None, +): + """Find all recipes using this ingredient and snapshot their costs. + + If price_info is provided ({"name", "unit", "old_price", "new_price"}), + a RecipeChangeLog entry is created for each affected recipe. + """ + # Direct usage + 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 find recipes that use sub-recipes containing this ingredient (1 level) + 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) + + # Build change log message if price info available + change_msg = None + if price_info: + name = price_info["name"] + unit = price_info.get("unit", "") + old_p = price_info.get("old_price") + new_p = price_info["new_price"] + unit_label = f"/{unit}" if unit else "" + # Only log if price actually changed + if old_p is not None and abs(new_p - old_p) > 0.000001: + change_msg = f"{name} price changed: £{old_p:.4f}{unit_label} → £{new_p:.4f}{unit_label}" + elif old_p is None: + change_msg = f"{name} price set: £{new_p:.4f}{unit_label}" + + for rid in recipe_ids: + # Bump updated_at so menu staleness detection picks up ingredient flag changes + r_result = await db.execute(select(Recipe).where(Recipe.id == rid)) + r = r_result.scalar_one_or_none() + if r: + r.updated_at = datetime.utcnow() + await snapshot_recipe_cost(rid, db, trigger_source) + if change_msg: + db.add(RecipeChangeLog( + recipe_id=rid, + change_summary=change_msg, + user_id=None, + source_invoice_id=invoice_id, + )) + if recipe_ids: + await db.commit() + + +# ── Recipe Change Log ──────────────────────────────────────────────────────── + +@router.get("/{recipe_id}/change-log") +async def get_change_log( + recipe_id: int, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + await _get_recipe(recipe_id, user.kitchen_id, db) + result = await db.execute( + select(RecipeChangeLog) + .options(selectinload(RecipeChangeLog.user)) + .where(RecipeChangeLog.recipe_id == recipe_id) + .order_by(RecipeChangeLog.created_at.desc()) + ) + logs = result.scalars().all() + return [ + { + "id": l.id, + "change_summary": l.change_summary, + "username": l.user.name if l.user else "", + "created_at": str(l.created_at) if l.created_at else "", + } + for l in logs + ] + + +@router.post("/cleanup-false-price-changes") +async def cleanup_false_price_changes( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Remove recipe change log entries where old price equals new price (false changes).""" + if not user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + + import re + + # Get all "price changed" log entries for this kitchen's recipes + result = await db.execute( + select(RecipeChangeLog) + .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id) + .where( + Recipe.kitchen_id == user.kitchen_id, + RecipeChangeLog.change_summary.like("%price changed%"), + ) + ) + logs = result.scalars().all() + + # Pattern: "Ingredient name price changed: £0.0086/g → £0.0086/g" + pattern = re.compile(r"price changed: £([\d.]+)(/\w+)? → £([\d.]+)(/\w+)?") + deleted = 0 + for log in logs: + match = pattern.search(log.change_summary) + if match: + old_price = float(match.group(1)) + new_price = float(match.group(3)) + if abs(old_price - new_price) < 0.000001: + await db.delete(log) + deleted += 1 + + await db.commit() + return {"message": f"Removed {deleted} false price change entries", "deleted": deleted} + + +@router.post("/backfill-invoice-references") +async def backfill_invoice_references( + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Backfill missing source_invoice_id on recipe change log entries + by matching to cost snapshots whose trigger_source contains 'invoice #'.""" + if not user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + + import re + + # Get change log entries missing source_invoice_id for this kitchen + result = await db.execute( + select(RecipeChangeLog) + .join(Recipe, RecipeChangeLog.recipe_id == Recipe.id) + .where( + Recipe.kitchen_id == user.kitchen_id, + RecipeChangeLog.source_invoice_id == None, + (RecipeChangeLog.change_summary.like("%price changed%") | RecipeChangeLog.change_summary.like("%price set%")), + ) + ) + logs = result.scalars().all() + + if not logs: + return {"message": "No entries missing invoice references", "updated": 0} + + # For each log, find a cost snapshot for same recipe created within 5 seconds + invoice_re = re.compile(r"invoice #(\d+)") + updated = 0 + for log in logs: + if not log.created_at: + continue + # Look for a cost snapshot close in time with an invoice trigger_source + snap_result = await db.execute( + select(RecipeCostSnapshot.trigger_source).where( + RecipeCostSnapshot.recipe_id == log.recipe_id, + RecipeCostSnapshot.created_at.between( + log.created_at - timedelta(seconds=5), + log.created_at + timedelta(seconds=5), + ), + RecipeCostSnapshot.trigger_source.like("%invoice #%"), + ).limit(1) + ) + row = snap_result.scalar_one_or_none() + if row: + m = invoice_re.search(row) + if m: + log.source_invoice_id = int(m.group(1)) + updated += 1 + + await db.commit() + return {"message": f"Linked {updated} of {len(logs)} entries to their triggering invoice", "updated": updated, "total": len(logs)} + + +# ── Print / Recipe Card HTML ──────────────────────────────────────────────── + +@router.get("/{recipe_id}/print") +async def print_recipe( + recipe_id: int, + format: str = Query("full"), # "full" | "kitchen" + token: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), +): + # window.open() can't send Authorization header, so auth via query param + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + user = await get_current_user_from_token(token, db) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + recipe = await _get_recipe(recipe_id, user.kitchen_id, db) + full_data = await get_recipe(recipe_id, user, db) + cost_data = await _calc_recipe_cost(recipe_id, db) + + # Get flags + from api.food_flags import compute_recipe_flags + flags = await compute_recipe_flags(recipe_id, user.kitchen_id, db) + + html = _build_recipe_html(full_data, cost_data, flags, format, recipe_id=recipe_id, token=token) + return HTMLResponse(content=html) + + +def _build_recipe_html(recipe_data: dict, cost_data: dict, flags, format: str = "full", recipe_id: int = 0, token: str = "") -> str: + """Generate print-optimised HTML for a recipe.""" + esc = html_escape + name = esc(recipe_data.get("name", "")) + recipe_type = recipe_data.get("recipe_type", "dish") + batch = recipe_data.get("batch_portions", 1) + batch_output_type = recipe_data.get("batch_output_type", "portions") + output_qty = recipe_data.get("output_qty", batch) + output_unit = recipe_data.get("output_unit", "portion") + description = esc(recipe_data.get("description", "") or "") + prep_time = recipe_data.get("prep_time_minutes") + cook_time = recipe_data.get("cook_time_minutes") + + # Flag badges + flag_html = "" + for f in flags: + if f.is_active: + color = "#dc3545" if f.propagation_type == "contains" else "#28a745" + badge_style = f"display:inline-block;padding:2px 8px;margin:2px;border-radius:12px;background:{color};color:white;font-size:12px;" + if f.excludable_on_request: + badge_style += "border:2px dashed white;" + flag_html += f'{esc(f.flag_code or f.flag_name)}' + + # Ingredients table + ing_rows = "" + for ing in recipe_data.get("ingredients", []): + cost_str = f"£{ing['cost']:.2f}" if ing.get("cost") else "-" + ing_rows += f""" + {esc(ing['ingredient_name'])} + {ing['quantity']:g}{esc(ing['unit'])} + {cost_str} + """ + + # Sub-recipes — match cost_data child_ingredients by child_recipe_id + cost_sub_recipes = {sr["child_recipe_id"]: sr for sr in cost_data.get("sub_recipes", [])} + sub_rows = "" + for sr in recipe_data.get("sub_recipes", []): + cost_sr = cost_sub_recipes.get(sr["child_recipe_id"], {}) + cost_str = f"£{sr['cost_contribution']:.2f}" if sr.get("cost_contribution") else "-" + sr_unit = sr.get("output_unit", "portion") + sr_unit_label = f"{sr['portions_needed']:g} {sr_unit}{'s' if sr['portions_needed'] != 1 and sr_unit == 'portion' else ''}" + sub_rows += f""" + ▸ {esc(sr['child_recipe_name'])} ({sr_unit_label}) + + {cost_str} + """ + for ci in cost_sr.get("child_ingredients", []): + ci_cost = f"£{ci['cost_recent']:.2f}" if ci.get("cost_recent") else "-" + sub_rows += f""" + ↳ {esc(ci['ingredient_name'])} + {ci['quantity']:g}{esc(ci['unit'])} + {ci_cost} + """ + + # Steps + steps_html = "" + for step in recipe_data.get("steps", []): + dur = f" ({step['duration_minutes']} min)" if step.get("duration_minutes") else "" + title = esc(step['title']) if step.get('title') else None + instr = esc(step['instruction']) + if title: + steps_html += f"
  • {title}{dur}
    {instr}
  • " + else: + steps_html += f"
  • {instr}{f' {dur}' if dur else ''}
  • " + + # Cost summary + cost_per_portion = cost_data.get("cost_per_portion") + total_cost = cost_data.get("total_cost_recent") + cost_summary = "" + if format == "full" and cost_per_portion: + cost_unit_label = f"Cost per {output_unit}" if output_unit != "portion" else "Cost per portion" + cost_summary = f""" +
    + {cost_unit_label}: £{cost_per_portion:.4f} | + Total cost: £{total_cost:.2f} +
    """ + + # Images (plating photos for kitchen card) + images_html = "" + if format == "kitchen": + plating_images = [img for img in recipe_data.get("images", []) if img.get("image_type") == "plating"] + if not plating_images: + plating_images = recipe_data.get("images", [])[:1] + for img in plating_images: + img_url = f"/api/recipes/{recipe_id}/images/{img['id']}?token={token}" + images_html += f'' + + time_info = "" + if prep_time or cook_time: + parts = [] + if prep_time: + parts.append(f"Prep: {prep_time} min") + if cook_time: + parts.append(f"Cook: {cook_time} min") + time_info = f"

    {' | '.join(parts)}

    " + + kitchen_font_size = "font-size:16px;" if format == "kitchen" else "" + + return f""" + + + + {name} + + + +

    {name}

    +

    + {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 ''} +
    {flag_html}
    + {images_html} + +

    Ingredients

    + + + + + + + {ing_rows}{sub_rows} +
    IngredientQuantityCost
    + + {cost_summary} + + {'

    Method

      ' + steps_html + '
    ' if steps_html else ''} + + {f'

    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": , + "xero_idx": , + "confidence": "high" or "medium", + "differs": ["field1", "field2"], + "reasoning": "brief explanation" +}} + +Only include matches with high or medium confidence. Return an empty array [] if no good matches found.""" + + result = await call_llm( + db=db, + kitchen_id=kitchen_id, + feature="reconciliation_matching", + messages=[{"role": "user", "content": user_msg}], + system_message=system_msg, + ) + + if result["status"] not in ("success", "cached"): + logger.info(f"LLM reconciliation matching unavailable: {result['status']}") + return [], [], flash_unmatched, xero_unmatched + + # Parse LLM response + new_discrepancies = [] + llm_text = result.get("result", "") + if isinstance(llm_text, dict): + llm_text = json.dumps(llm_text) + if not llm_text: + return [], [], flash_unmatched, xero_unmatched + + try: + # Extract JSON from response (may be wrapped in markdown code block) + json_match = re.search(r"\[.*\]", str(llm_text), re.DOTALL) + if not json_match: + return [], [], flash_unmatched, xero_unmatched + matches = json.loads(json_match.group()) + except (json.JSONDecodeError, AttributeError): + logger.warning("Failed to parse LLM reconciliation response") + return [], [], flash_unmatched, xero_unmatched + + flash_matched = set() + xero_matched = set() + + for m in matches: + fi = m.get("flash_idx") + xi = m.get("xero_idx") + confidence = m.get("confidence", "medium") + + if fi is None or xi is None: + continue + if fi >= len(flash_unmatched) or xi >= len(xero_unmatched): + continue + if fi in flash_matched or xi in xero_matched: + continue + + flash = flash_unmatched[fi] + xero = xero_unmatched[xi] + + # Safety check: reject LLM matches where refs differ AND amount diff > £5 + f_ref_norm = normalise_ref(flash["ref"]) + x_ref_norm = normalise_ref(xero["ref"]) + amt_diff_abs = abs(float(flash["net_stock"] - xero["net"])) + if f_ref_norm != x_ref_norm and amt_diff_abs > 5.0: + logger.warning( + f"REJECTED LLM false match: Flash {flash['supplier_name']} " + f"{flash['ref']}={flash['net_stock']} vs Xero {xero['ref']}={xero['net']} " + f"diff={amt_diff_abs:.2f}" + ) + continue + + differs = m.get("differs", []) + if not differs: + differs = ["unknown"] + + diff_val = flash["net_stock"] - xero["net"] + new_discrepancies.append({ + "date": flash["date"].isoformat(), + "supplier": flash["supplier_name"], + "ref": flash["ref"] or xero["ref"], + "flash_amount": fmt_money(flash["net_stock"]), + "xero_amount": fmt_money(xero["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"], + "match_source": "ai", + }) + flash_matched.add(fi) + xero_matched.add(xi) + + remaining_flash = [f for i, f in enumerate(flash_unmatched) if i not in flash_matched] + remaining_xero = [x for i, x in enumerate(xero_unmatched) if i not in xero_matched] + + return new_discrepancies, [], remaining_flash, remaining_xero + + +# ============ Main Endpoint ============ + +@router.post("/purchases/reconcile", response_model=ReconcileResponse) +async def reconcile_purchases( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Upload a Xero Account Transactions XLSX and reconcile against Flash invoices. + """ + # Validate file type + if not file.filename or not file.filename.lower().endswith(".xlsx"): + raise HTTPException(status_code=400, detail="Please upload an XLSX file") + + # Read file into memory (no persistence) + file_bytes = await file.read() + if len(file_bytes) > 10 * 1024 * 1024: # 10MB limit + raise HTTPException(status_code=400, detail="File too large (max 10MB)") + + # Parse XLSX + try: + period_start, period_end, xero_rows = parse_xero_xlsx(file_bytes) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"XLSX parsing failed: {e}") + raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {str(e)}") + + # Group Xero rows by reference + xero_items = group_xero_by_ref(xero_rows) + + # Get Flash invoices for the period + flash_items, non_stock_count, non_stock_total = await get_flash_invoices( + db, current_user.kitchen_id, period_start, period_end + ) + + # Build supplier alias map from DB + alias_map = await build_supplier_alias_map(db, current_user.kitchen_id) + + # Run rule-based matching + matched, discrepancies, flash_unmatched, xero_unmatched = run_matching( + flash_items, xero_items, alias_map + ) + + # LLM fallback for remaining unmatched + llm_attempted = False + if flash_unmatched and xero_unmatched: + llm_disc, llm_exact, flash_unmatched, xero_unmatched = await llm_match_remaining( + db, current_user.kitchen_id, flash_unmatched, xero_unmatched + ) + if llm_disc or llm_exact: + llm_attempted = True + discrepancies.extend(llm_disc) + matched.extend(llm_exact) + + # Generate insights for amount discrepancies + flash_lookup = {f["id"]: f for f in flash_items} + discrepancies = await generate_amount_insights( + db, current_user.kitchen_id, discrepancies, flash_lookup + ) + + # Calculate totals + flash_total = sum(f["net_stock"] for f in flash_items) + xero_total = sum(x["net"] for x in xero_items) + difference = flash_total - xero_total + + # Build response + matched_response = [ReconcileMatchedItem(**m) for m in matched] + + discrepancy_response = [ReconcileDiscrepancyItem(**d) for d in discrepancies] + + flash_only_response = [ + ReconcileUnmatchedFlash( + date=f["date"].isoformat(), + supplier=f["supplier_name"], + ref=f["ref"], + net_stock=fmt_money(f["net_stock"]), + flash_id=f["id"], + ) + for f in flash_unmatched + ] + + xero_only_response = [ + ReconcileUnmatchedXero( + date=x["date"].isoformat(), + description=x["description"], + ref=x["ref"], + net=fmt_money(x["net"]), + is_expected_external=is_expected_external(x["description"]), + ) + for x in xero_unmatched + ] + + return ReconcileResponse( + period_start=period_start.isoformat(), + period_end=period_end.isoformat(), + flash_total=fmt_money(flash_total), + xero_total=fmt_money(xero_total), + difference=fmt_money(difference), + matched_count=len(matched_response), + discrepancy_count=len(discrepancy_response), + flash_only_count=len(flash_only_response), + xero_only_count=len(xero_only_response), + non_stock_excluded_count=non_stock_count, + non_stock_excluded_total=fmt_money(non_stock_total), + matched=matched_response, + discrepancies=discrepancy_response, + flash_only=flash_only_response, + xero_only=xero_only_response, + llm_matches_attempted=llm_attempted, + ) diff --git a/backend/api/reports.py b/backend/api/reports.py new file mode 100644 index 0000000..2aa0a00 --- /dev/null +++ b/backend/api/reports.py @@ -0,0 +1,3421 @@ +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 select, func, case +from sqlalchemy.orm import selectinload +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +from database import get_db +from models.user import User +from models.invoice import Invoice, InvoiceStatus +from models.gp import RevenueEntry, GPPeriod +from models.newbook import NewbookDailyRevenue, NewbookGLAccount, NewbookDailyOccupancy +from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus +from auth import get_current_user, require_cap + +router = APIRouter() + + +class RevenueEntryCreate(BaseModel): + date: date + amount: Decimal + category: str = "total" + notes: Optional[str] = None + + +class RevenueEntryResponse(BaseModel): + id: int + date: date + amount: Decimal + category: str + notes: Optional[str] + + class Config: + from_attributes = True + + +class GPReportRequest(BaseModel): + start_date: date + end_date: date + + +class GPReportResponse(BaseModel): + start_date: date + end_date: date + total_revenue: Decimal + total_costs: Decimal + gp_amount: Decimal + gp_percentage: Decimal + category_breakdown: dict + # Revenue breakdown + newbook_revenue: Optional[Decimal] = None + manual_revenue: Optional[Decimal] = None + # Allowances (credits that improve GP if applied) + wastage_total: Optional[Decimal] = None # Wastage logged in logbook + disputes_total: Optional[Decimal] = None # Open disputes on invoices in this period + allowances_total: Optional[Decimal] = None # wastage + disputes combined + gp_with_allowances: Optional[Decimal] = None # GP% if allowances applied + + +class DashboardResponse(BaseModel): + current_period: GPReportResponse | None + previous_period: GPReportResponse | None + forecast_period: GPReportResponse | None # Placeholder for this week's forecast + rolling_30_days: GPReportResponse | None # Last 30 days rolling (from yesterday) + recent_invoices: int + pending_review: int + + +class PurchaseInvoice(BaseModel): + id: int + invoice_number: str | None + total: Decimal | None + supplier_match_type: str | None # "exact", "fuzzy", or None (unmatched) + + class Config: + from_attributes = True + + +class SupplierRow(BaseModel): + supplier_id: int | None # None for unmatched invoices + supplier_name: str # Supplier name or vendor_name for unmatched + is_unmatched: bool + invoices_by_date: dict[str, list[PurchaseInvoice]] # date string -> invoices + total: Decimal + percentage: Decimal + + +class WeeklyPurchasesResponse(BaseModel): + week_start: date + week_end: date + dates: list[date] # 7 days + suppliers: list[SupplierRow] + daily_totals: dict[str, Decimal] # date string -> total + week_total: Decimal + + +# Monthly Purchases Calendar models +class MonthlyPurchaseInvoice(BaseModel): + """Invoice with full detail for monthly view""" + id: int + invoice_number: str | None + invoice_date: date | None + total: Decimal | None # Gross total (inc. VAT) + net_total: Decimal | None # Net total (exc. VAT) + net_stock: Decimal | None # Net stock items only + gross_stock: Decimal | None # Gross stock items only (net_stock + stock VAT) + supplier_match_type: str | None + + class Config: + from_attributes = True + + +class MonthlySupplierRow(BaseModel): + """Supplier row for monthly purchases - consistent order across weeks""" + supplier_id: int | None + supplier_name: str + is_unmatched: bool + invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] # date string -> invoices + total_net_stock: Decimal # Sum of net_stock for all invoices + percentage: Decimal + + +class WeekData(BaseModel): + """Data for one week in the monthly view""" + week_start: date + week_end: date + dates: list[date] # 7 days (Mon-Sun) + suppliers: list[MonthlySupplierRow] # Same order as month-level suppliers + daily_totals: dict[str, Decimal] # date string -> net_stock total + week_total: Decimal # Net stock total for week + daily_invoice_totals: dict[str, Decimal] | None = None # date string -> full invoice net_total + week_invoice_total: Decimal | None = None # Full invoice net_total for week + + +class MonthlyPurchasesResponse(BaseModel): + """Response for monthly purchases calendar view""" + year: int + month: int + month_name: str + weeks: list[WeekData] # All weeks in the month + all_suppliers: list[str] # Ordered list of all supplier names for consistent display + daily_totals: dict[str, Decimal] # All days in month -> net_stock total + month_total: Decimal # Net stock total for entire month + + +class DateRangePurchasesResponse(BaseModel): + """Response for date range purchases view""" + from_date: date + to_date: date + period_label: str # Human-readable label like "Dec 18 - Jan 17, 2026" + weeks: list[WeekData] # All weeks in the range + all_suppliers: list[str] # Ordered list of all supplier names for consistent display + daily_totals: dict[str, Decimal] # All days in range -> net_stock total + period_total: Decimal # Net stock total for entire period + daily_invoice_totals: dict[str, Decimal] | None = None # All days -> full invoice net_total + period_invoice_total: Decimal | None = None # Full invoice net_total for entire period + + +@router.post("/revenue", response_model=RevenueEntryResponse) +async def add_revenue( + request: RevenueEntryCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Add a revenue entry for GP calculation""" + entry = RevenueEntry( + kitchen_id=current_user.kitchen_id, + date=request.date, + amount=request.amount, + category=request.category, + notes=request.notes + ) + db.add(entry) + await db.commit() + await db.refresh(entry) + + return RevenueEntryResponse( + id=entry.id, + date=entry.date, + amount=entry.amount, + category=entry.category, + notes=entry.notes + ) + + +@router.get("/revenue", response_model=list[RevenueEntryResponse]) +async def list_revenue( + start_date: Optional[date] = None, + end_date: Optional[date] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """List revenue entries for a date range""" + query = select(RevenueEntry).where( + RevenueEntry.kitchen_id == current_user.kitchen_id + ) + + if start_date: + query = query.where(RevenueEntry.date >= start_date) + if end_date: + query = query.where(RevenueEntry.date <= end_date) + + query = query.order_by(RevenueEntry.date.desc()) + result = await db.execute(query) + entries = result.scalars().all() + + return [ + RevenueEntryResponse( + id=e.id, + date=e.date, + amount=e.amount, + category=e.category, + notes=e.notes + ) + for e in entries + ] + + +@router.post("/gp", response_model=GPReportResponse) +async def calculate_gp( + request: GPReportRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Calculate GP for a specific date range""" + from models.line_item import LineItem + from sqlalchemy import or_, and_ + + # Get manual revenue entries for period + manual_revenue_result = await db.execute( + select(func.sum(RevenueEntry.amount)) + .where( + RevenueEntry.kitchen_id == current_user.kitchen_id, + RevenueEntry.date >= request.start_date, + RevenueEntry.date <= request.end_date + ) + ) + manual_revenue = manual_revenue_result.scalar() or Decimal("0.00") + + # Get Newbook revenue for tracked GL accounts + newbook_revenue_result = await db.execute( + select(func.sum(NewbookDailyRevenue.amount_net)) + .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) + .where( + NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, + NewbookDailyRevenue.date >= request.start_date, + NewbookDailyRevenue.date <= request.end_date, + NewbookGLAccount.is_tracked == True + ) + ) + newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00") + + # Total revenue combines manual entries and Newbook data + total_revenue = manual_revenue + newbook_revenue + + # Get total costs from confirmed invoices - stock items only + # Credit notes are treated as negative purchases + # Use subquery to sum per invoice first, then conditionally negate if credit note has positive total + # This matches the flash report's calc_stock_values logic + invoice_stock_subq = ( + select( + Invoice.id.label('inv_id'), + Invoice.document_type.label('doc_type'), + Invoice.category.label('category'), + func.sum(LineItem.amount).label('stock_total') + ) + .join(Invoice, LineItem.invoice_id == Invoice.id) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + Invoice.invoice_date >= request.start_date, + Invoice.invoice_date <= request.end_date, + 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.document_type, Invoice.category) + .subquery() + ) + + costs_result = await db.execute( + select(func.sum( + case( + # Credit note with positive total - negate it + (and_(invoice_stock_subq.c.doc_type == 'credit_note', + invoice_stock_subq.c.stock_total > 0), + -invoice_stock_subq.c.stock_total), + # Otherwise use as-is (includes credit notes with already-negative totals) + else_=invoice_stock_subq.c.stock_total + ) + )) + .select_from(invoice_stock_subq) + ) + total_costs = costs_result.scalar() or Decimal("0.00") + + # Add cost distribution adjustments (net zero overall, but shifts cost between dates) + cd_adjustment_result = await db.execute( + select(func.sum(CostDistributionEntry.amount)) + .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) + .where( + CostDistributionEntry.kitchen_id == current_user.kitchen_id, + CostDistributionEntry.entry_date >= request.start_date, + CostDistributionEntry.entry_date <= request.end_date, + CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), + ) + ) + total_costs += cd_adjustment_result.scalar() or Decimal("0.00") + + # Calculate GP + gp_amount = total_revenue - total_costs + gp_percentage = (gp_amount / total_revenue * 100) if total_revenue > 0 else Decimal("0.00") + + # Category breakdown for costs (using same subquery approach) + category_result = await db.execute( + select( + invoice_stock_subq.c.category, + 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) + .group_by(invoice_stock_subq.c.category) + ) + category_breakdown = { + cat or "uncategorized": float(amount) + for cat, amount in category_result.all() + } + + return GPReportResponse( + start_date=request.start_date, + end_date=request.end_date, + total_revenue=total_revenue, + total_costs=total_costs, + gp_amount=gp_amount, + gp_percentage=round(gp_percentage, 2), + category_breakdown=category_breakdown, + newbook_revenue=newbook_revenue if newbook_revenue > 0 else None, + manual_revenue=manual_revenue if manual_revenue > 0 else None + ) + + +@router.get("/dashboard", response_model=DashboardResponse) +async def get_dashboard( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get dashboard summary with current and previous period GP""" + today = date.today() + + # Current week (Monday to today) + current_start = today - timedelta(days=today.weekday()) + current_end = today + + # Previous week + prev_start = current_start - timedelta(days=7) + prev_end = current_start - timedelta(days=1) + + async def calc_period_gp(start: date, end: date) -> GPReportResponse | None: + from models.line_item import LineItem + from models.logbook import LogbookEntry, EntryType + from models.dispute import InvoiceDispute, DisputeStatus + from sqlalchemy import or_ + + # Manual revenue entries + manual_rev_result = await db.execute( + select(func.sum(RevenueEntry.amount)) + .where( + RevenueEntry.kitchen_id == current_user.kitchen_id, + RevenueEntry.date >= start, + RevenueEntry.date <= end + ) + ) + manual_revenue = manual_rev_result.scalar() or Decimal("0.00") + + # Newbook revenue for tracked GL accounts + newbook_rev_result = await db.execute( + select(func.sum(NewbookDailyRevenue.amount_net)) + .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) + .where( + NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, + NewbookDailyRevenue.date >= start, + NewbookDailyRevenue.date <= end, + NewbookGLAccount.is_tracked == True + ) + ) + newbook_revenue = newbook_rev_result.scalar() or Decimal("0.00") + + # Total revenue + revenue = manual_revenue + newbook_revenue + + # Costs - stock items only (exclude non-stock) + # Credit notes (document_type='credit_note') are treated as negative purchases + # Use subquery to sum per invoice first, then conditionally negate if credit note has positive total + # This matches the flash report's calc_stock_values logic + from sqlalchemy import and_ + invoice_stock_subq = ( + select( + Invoice.id.label('inv_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 == current_user.kitchen_id, + Invoice.invoice_date >= start, + Invoice.invoice_date <= 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.document_type) + .subquery() + ) + cost_result = await db.execute( + select(func.sum( + case( + # Credit note with positive total - negate it + (and_(invoice_stock_subq.c.doc_type == 'credit_note', + invoice_stock_subq.c.stock_total > 0), + -invoice_stock_subq.c.stock_total), + # Otherwise use as-is (includes credit notes with already-negative totals) + else_=invoice_stock_subq.c.stock_total + ) + )) + .select_from(invoice_stock_subq) + ) + costs = cost_result.scalar() or Decimal("0.00") + + # Add cost distribution adjustments + cd_adj_result = await db.execute( + select(func.sum(CostDistributionEntry.amount)) + .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) + .where( + CostDistributionEntry.kitchen_id == current_user.kitchen_id, + CostDistributionEntry.entry_date >= start, + CostDistributionEntry.entry_date <= end, + CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), + ) + ) + costs += cd_adj_result.scalar() or Decimal("0.00") + + # Wastage total from logbook + wastage_result = await db.execute( + select(func.sum(LogbookEntry.total_cost)) + .where( + LogbookEntry.kitchen_id == current_user.kitchen_id, + LogbookEntry.entry_date >= start, + LogbookEntry.entry_date <= end, + LogbookEntry.entry_type == EntryType.WASTAGE, + LogbookEntry.is_deleted == False + ) + ) + wastage_total = wastage_result.scalar() or Decimal("0.00") + + # Open disputes total - based on invoice date, not dispute creation date + # These are potential credits that would reduce costs if resolved + open_statuses = [ + DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, + DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, + DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED + ] + disputes_result = await db.execute( + select(func.sum(InvoiceDispute.difference_amount)) + .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) + .where( + InvoiceDispute.kitchen_id == current_user.kitchen_id, + Invoice.invoice_date >= start, + Invoice.invoice_date <= end, + InvoiceDispute.status.in_(open_statuses) + ) + ) + disputes_total = disputes_result.scalar() or Decimal("0.00") + + if revenue == 0 and costs == 0: + return None + + gp_amount = revenue - costs + gp_pct = (gp_amount / revenue * 100) if revenue > 0 else Decimal("0.00") + + # Calculate GP with allowances (wastage + disputes as credits) + # Allowances = money that could be recovered/saved + # - Wastage: if not wasted, wouldn't have purchased + # - Disputes: credits expected from suppliers + allowances_total = wastage_total + disputes_total + gp_with_allowances = revenue - costs + allowances_total + gp_with_allowances_pct = (gp_with_allowances / revenue * 100) if revenue > 0 else Decimal("0.00") + + # Only show allowances section if there are any + has_allowances = allowances_total > 0 + + return GPReportResponse( + start_date=start, + end_date=end, + total_revenue=revenue, + total_costs=costs, + gp_amount=gp_amount, + gp_percentage=round(gp_pct, 2), + category_breakdown={}, + newbook_revenue=newbook_revenue if newbook_revenue > 0 else None, + manual_revenue=manual_revenue if manual_revenue > 0 else None, + wastage_total=wastage_total if wastage_total > 0 else None, + disputes_total=disputes_total if disputes_total > 0 else None, + allowances_total=allowances_total if has_allowances else None, + gp_with_allowances=round(gp_with_allowances_pct, 2) if has_allowances else None + ) + + # Recent invoices count (last 7 days) + recent_result = await db.execute( + select(func.count(Invoice.id)) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + Invoice.created_at >= today - timedelta(days=7) + ) + ) + recent_invoices = recent_result.scalar() or 0 + + # Pending confirmation count (all non-confirmed: pending, processed, reviewed) + from sqlalchemy import or_ + pending_result = await db.execute( + select(func.count(Invoice.id)) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + or_( + Invoice.status == InvoiceStatus.PENDING, + Invoice.status == InvoiceStatus.PROCESSED, + Invoice.status == InvoiceStatus.REVIEWED + ) + ) + ) + pending_review = pending_result.scalar() or 0 + + # Rolling 30 days (from yesterday back 29 days) + yesterday = today - timedelta(days=1) + rolling_30_start = yesterday - timedelta(days=29) + rolling_30_end = yesterday + + return DashboardResponse( + current_period=await calc_period_gp(current_start, current_end), + previous_period=await calc_period_gp(prev_start, prev_end), + forecast_period=None, # Placeholder - forecast not implemented yet + rolling_30_days=await calc_period_gp(rolling_30_start, rolling_30_end), + recent_invoices=recent_invoices, + pending_review=pending_review + ) + + +@router.get("/purchases/weekly", response_model=WeeklyPurchasesResponse) +async def get_weekly_purchases( + 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 purchases organized by supplier and date for table view""" + from models.supplier import Supplier + from collections import defaultdict + + today = date.today() + # Calculate week start (Monday) with offset + week_start = today - timedelta(days=today.weekday()) + timedelta(weeks=week_offset) + week_end = week_start + timedelta(days=6) + dates = [week_start + timedelta(days=i) for i in range(7)] + + # Get all invoices for the week (all statuses, matched or not) + # Fetch all recent invoices and filter in Python for reliable date handling + result = await db.execute( + select(Invoice) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + ) + .order_by(Invoice.invoice_date.desc().nullslast()) + ) + all_invoices = result.scalars().all() + + # Filter to invoices in the week range, using invoice_date or created_at as fallback + invoices = [] + for inv in all_invoices: + inv_date = inv.invoice_date or inv.created_at.date() + if week_start <= inv_date <= week_end: + invoices.append(inv) + + # Get all suppliers for name lookup + supplier_result = await db.execute( + select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id) + ) + suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} + + # Organize invoices by supplier + supplier_invoices: dict[tuple, list] = defaultdict(list) # (supplier_id, name, is_unmatched) -> invoices + + for inv in invoices: + if inv.supplier_id: + key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False) + else: + # Unmatched - use vendor_name or "Unknown Supplier" + vendor = inv.vendor_name or "Unknown Supplier" + key = (None, vendor, True) + supplier_invoices[key].append(inv) + + # Helper to get effective total (negative for credit notes) + def get_effective_total(inv: Invoice) -> Decimal: + total = inv.total or Decimal("0") + if inv.document_type == 'credit_note': + return -total + return total + + # Calculate week total + week_total = sum(get_effective_total(inv) for inv in invoices) + + # Build supplier rows + supplier_rows = [] + for (supplier_id, supplier_name, is_unmatched), invs in sorted( + supplier_invoices.items(), key=lambda x: (x[0][2], x[0][1].lower()) # Matched first, then alphabetical + ): + invoices_by_date: dict[str, list[PurchaseInvoice]] = defaultdict(list) + row_total = Decimal("0") + + for inv in invs: + # Use invoice_date if available, otherwise use created_at date + inv_date = inv.invoice_date or inv.created_at.date() + date_str = inv_date.isoformat() + invoices_by_date[date_str].append(PurchaseInvoice( + id=inv.id, + invoice_number=inv.invoice_number, + total=get_effective_total(inv), # Negative for credit notes + supplier_match_type=inv.supplier_match_type + )) + row_total += get_effective_total(inv) + + percentage = (row_total / week_total * 100) if week_total > 0 else Decimal("0") + + supplier_rows.append(SupplierRow( + supplier_id=supplier_id, + supplier_name=supplier_name, + is_unmatched=is_unmatched, + invoices_by_date=dict(invoices_by_date), + total=row_total, + percentage=round(percentage, 1) + )) + + # Calculate daily totals + daily_totals = {} + for d in dates: + date_str = d.isoformat() + daily_totals[date_str] = sum( + get_effective_total(inv) + for inv in invoices + if (inv.invoice_date or inv.created_at.date()).isoformat() == date_str + ) + + return WeeklyPurchasesResponse( + week_start=week_start, + week_end=week_end, + dates=dates, + suppliers=supplier_rows, + daily_totals=daily_totals, + week_total=week_total + ) + + +@router.get("/purchases/monthly", response_model=MonthlyPurchasesResponse) +async def get_monthly_purchases( + year: int | None = None, + month: int | None = None, # 1-12 + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get monthly purchases organized by week, supplier, and date for calendar view""" + from models.supplier import Supplier + from models.line_item import LineItem + from collections import defaultdict + from calendar import monthrange, month_name as calendar_month_name + + # Default to current month + today = date.today() + year = year or today.year + month = month or today.month + + # Get first and last day of month + _, days_in_month = monthrange(year, month) + month_start = date(year, month, 1) + month_end = date(year, month, days_in_month) + + # Get all invoices for the month with their line items + result = await db.execute( + select(Invoice) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + ) + .options(selectinload(Invoice.line_items)) + .order_by(Invoice.invoice_date.desc().nullslast()) + ) + all_invoices = result.scalars().all() + + # Filter to invoices in the month range + invoices = [] + for inv in all_invoices: + inv_date = inv.invoice_date or inv.created_at.date() + if month_start <= inv_date <= month_end: + invoices.append(inv) + + # Get all suppliers for name lookup + supplier_result = await db.execute( + select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id) + ) + suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} + + # Helper to calculate stock values for an invoice + def calc_stock_values(inv: Invoice) -> tuple[Decimal, Decimal]: + """Returns (net_stock, gross_stock) for an invoice. + Credit notes (document_type='credit_note') return negative values. + """ + net_stock = Decimal("0") + if inv.line_items: + for item in inv.line_items: + if not (item.is_non_stock or False): + item_net = item.amount or Decimal("0") + net_stock += item_net + + # Calculate gross_stock by applying invoice's VAT ratio to net_stock + # (since line items often don't have individual tax_amount) + if net_stock > 0 and inv.net_total and inv.total and inv.net_total > 0: + vat_ratio = inv.total / inv.net_total + gross_stock = (net_stock * vat_ratio).quantize(Decimal("0.01")) + else: + gross_stock = net_stock + + # Credit notes are negative purchases - but only negate if values are positive + # Some suppliers already use negative values on credit note line items + if inv.document_type == 'credit_note': + if net_stock > 0: + net_stock = -net_stock + if gross_stock > 0: + gross_stock = -gross_stock + + return net_stock, gross_stock + + # Build invoice data with stock values + # Also negate total and net_total for credit notes so frontend sums work correctly + # Only negate if values are positive (some suppliers already use negative values) + invoice_data = {} + for inv in invoices: + net_stock, gross_stock = calc_stock_values(inv) + inv_date = inv.invoice_date or inv.created_at.date() + is_credit = inv.document_type == 'credit_note' + invoice_data[inv.id] = { + "inv": inv, + "date": inv_date, + "net_stock": net_stock, + "gross_stock": gross_stock, + "total": -inv.total if is_credit and inv.total and inv.total > 0 else inv.total, + "net_total": -inv.net_total if is_credit and inv.net_total and inv.net_total > 0 else inv.net_total, + } + + # Organize by supplier + supplier_invoices: dict[tuple, list] = defaultdict(list) # (supplier_id, name, is_unmatched) -> invoice ids + for inv_id, data in invoice_data.items(): + inv = data["inv"] + if inv.supplier_id: + key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False) + else: + vendor = inv.vendor_name or "Unknown Supplier" + key = (None, vendor, True) + supplier_invoices[key].append(inv_id) + + # Get ordered list of all suppliers (matched first, then alphabetical) + all_supplier_keys = sorted( + supplier_invoices.keys(), + key=lambda x: (x[2], x[1].lower()) # is_unmatched, then name + ) + all_suppliers = [name for (_, name, _) in all_supplier_keys] + + # Calculate month total + month_total = sum(data["net_stock"] for data in invoice_data.values()) + + # Build weeks - find all weeks that overlap with the month + weeks_data = [] + + # Find first Monday on or before month start + first_monday = month_start - timedelta(days=month_start.weekday()) + + current_week_start = first_monday + while current_week_start <= month_end: + week_end = current_week_start + timedelta(days=6) + week_dates = [current_week_start + timedelta(days=i) for i in range(7)] + + # Build supplier rows for this week (maintain consistent order) + # First pass: collect data and calculate week_total + week_total = Decimal("0") + week_daily_totals: dict[str, Decimal] = defaultdict(Decimal) + supplier_data_list: list[tuple] = [] # (supplier_key, invoices_by_date, supplier_week_total) + + for supplier_key in all_supplier_keys: + supplier_id, supplier_name, is_unmatched = supplier_key + inv_ids = supplier_invoices.get(supplier_key, []) + + invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] = defaultdict(list) + supplier_week_total = Decimal("0") + + for inv_id in inv_ids: + data = invoice_data[inv_id] + inv = data["inv"] + inv_date = data["date"] + + # Only include if in this week + if current_week_start <= inv_date <= week_end: + date_str = inv_date.isoformat() + invoices_by_date[date_str].append(MonthlyPurchaseInvoice( + id=inv.id, + invoice_number=inv.invoice_number, + invoice_date=inv.invoice_date, + total=data["total"], + net_total=data["net_total"], + net_stock=data["net_stock"], + gross_stock=data["gross_stock"], + supplier_match_type=inv.supplier_match_type + )) + supplier_week_total += data["net_stock"] + week_daily_totals[date_str] += data["net_stock"] + + week_total += supplier_week_total + supplier_data_list.append((supplier_key, dict(invoices_by_date), supplier_week_total)) + + # Second pass: calculate percentages using week_total + week_supplier_rows = [] + for supplier_key, invoices_by_date, supplier_week_total in supplier_data_list: + supplier_id, supplier_name, is_unmatched = supplier_key + percentage = (supplier_week_total / week_total * 100) if week_total > 0 else Decimal("0") + + week_supplier_rows.append(MonthlySupplierRow( + supplier_id=supplier_id, + supplier_name=supplier_name, + is_unmatched=is_unmatched, + invoices_by_date=invoices_by_date, + total_net_stock=supplier_week_total, + percentage=round(percentage, 1) + )) + + weeks_data.append(WeekData( + week_start=current_week_start, + week_end=week_end, + dates=week_dates, + suppliers=week_supplier_rows, + daily_totals=dict(week_daily_totals), + week_total=week_total + )) + + current_week_start += timedelta(days=7) + + # Calculate daily totals for entire month + monthly_daily_totals: dict[str, Decimal] = defaultdict(Decimal) + for data in invoice_data.values(): + date_str = data["date"].isoformat() + monthly_daily_totals[date_str] += data["net_stock"] + + return MonthlyPurchasesResponse( + year=year, + month=month, + month_name=calendar_month_name[month], + weeks=weeks_data, + all_suppliers=all_suppliers, + daily_totals=dict(monthly_daily_totals), + month_total=month_total + ) + + +@router.get("/purchases/range", response_model=DateRangePurchasesResponse) +async def get_purchases_by_range( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get purchases organized by week for a custom date range""" + from models.supplier import Supplier + from models.line_item import LineItem + from collections import defaultdict + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Build period label + if from_date.year == to_date.year: + if from_date.month == to_date.month: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" + + # Get all confirmed invoices for the range with their line items + result = await db.execute( + select(Invoice) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + Invoice.status == InvoiceStatus.CONFIRMED, + ) + .options(selectinload(Invoice.line_items)) + .order_by(Invoice.invoice_date.desc().nullslast()) + ) + all_invoices = result.scalars().all() + + # Filter to invoices in the date range + invoices = [] + for inv in all_invoices: + inv_date = inv.invoice_date or inv.created_at.date() + if from_date <= inv_date <= to_date: + invoices.append(inv) + + # Get all suppliers for name lookup + supplier_result = await db.execute( + select(Supplier).where(Supplier.kitchen_id == current_user.kitchen_id) + ) + suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()} + + # Helper to calculate stock values for an invoice + def calc_stock_values(inv: Invoice) -> tuple[Decimal, Decimal]: + """Returns (net_stock, gross_stock) for an invoice. + Credit notes (document_type='credit_note') return negative values. + """ + net_stock = Decimal("0") + if inv.line_items: + for item in inv.line_items: + if not (item.is_non_stock or False): + item_net = item.amount or Decimal("0") + net_stock += item_net + + # Calculate gross_stock by applying invoice's VAT ratio to net_stock + if net_stock > 0 and inv.net_total and inv.total and inv.net_total > 0: + vat_ratio = inv.total / inv.net_total + gross_stock = (net_stock * vat_ratio).quantize(Decimal("0.01")) + else: + gross_stock = net_stock + + # Credit notes are negative purchases - but only negate if values are positive + # Some suppliers already use negative values on credit note line items + if inv.document_type == 'credit_note': + if net_stock > 0: + net_stock = -net_stock + if gross_stock > 0: + gross_stock = -gross_stock + + return net_stock, gross_stock + + # Build invoice data with stock values + # Also negate total and net_total for credit notes so frontend sums work correctly + # Only negate if values are positive (some suppliers already use negative values) + invoice_data = {} + for inv in invoices: + net_stock, gross_stock = calc_stock_values(inv) + inv_date = inv.invoice_date or inv.created_at.date() + is_credit = inv.document_type == 'credit_note' + invoice_data[inv.id] = { + "inv": inv, + "date": inv_date, + "net_stock": net_stock, + "gross_stock": gross_stock, + "total": -inv.total if is_credit and inv.total and inv.total > 0 else inv.total, + "net_total": -inv.net_total if is_credit and inv.net_total and inv.net_total > 0 else inv.net_total, + } + + # Organize by supplier + supplier_invoices: dict[tuple, list] = defaultdict(list) + for inv_id, data in invoice_data.items(): + inv = data["inv"] + if inv.supplier_id: + key = (inv.supplier_id, suppliers_map.get(inv.supplier_id, "Unknown"), False) + else: + vendor = inv.vendor_name or "Unknown Supplier" + key = (None, vendor, True) + supplier_invoices[key].append(inv_id) + + # Get ordered list of all suppliers (matched first, then alphabetical) + all_supplier_keys = sorted( + supplier_invoices.keys(), + key=lambda x: (x[2], x[1].lower()) + ) + all_suppliers = [name for (_, name, _) in all_supplier_keys] + + # Calculate period totals (stock and invoice) + # Use stored net_total (already negated for credit notes), fall back to stored total + period_total = sum(data["net_stock"] for data in invoice_data.values()) + period_invoice_total = sum( + (data["net_total"] or data["total"] or Decimal("0")) for data in invoice_data.values() + ) + + # Build weeks - find all weeks that overlap with the date range + weeks_data = [] + + # Find first Monday on or before from_date + first_monday = from_date - timedelta(days=from_date.weekday()) + + current_week_start = first_monday + while current_week_start <= to_date: + week_end = current_week_start + timedelta(days=6) + week_dates = [current_week_start + timedelta(days=i) for i in range(7)] + + # Build supplier rows for this week + week_total = Decimal("0") + week_invoice_total = Decimal("0") + week_daily_totals: dict[str, Decimal] = defaultdict(Decimal) + week_daily_invoice_totals: dict[str, Decimal] = defaultdict(Decimal) + supplier_data_list: list[tuple] = [] + + for supplier_key in all_supplier_keys: + supplier_id, supplier_name, is_unmatched = supplier_key + inv_ids = supplier_invoices.get(supplier_key, []) + + invoices_by_date: dict[str, list[MonthlyPurchaseInvoice]] = defaultdict(list) + supplier_week_total = Decimal("0") + + for inv_id in inv_ids: + data = invoice_data[inv_id] + inv = data["inv"] + inv_date = data["date"] + + # Only include if in this week + if current_week_start <= inv_date <= week_end: + date_str = inv_date.isoformat() + invoices_by_date[date_str].append(MonthlyPurchaseInvoice( + id=inv.id, + invoice_number=inv.invoice_number, + invoice_date=inv.invoice_date, + total=data["total"], + net_total=data["net_total"], + net_stock=data["net_stock"], + gross_stock=data["gross_stock"], + supplier_match_type=inv.supplier_match_type + )) + supplier_week_total += data["net_stock"] + week_daily_totals[date_str] += data["net_stock"] + # Use stored net_total (already negated for credit notes), fall back to stored total + inv_net = data["net_total"] or data["total"] or Decimal("0") + week_invoice_total += inv_net + week_daily_invoice_totals[date_str] += inv_net + + week_total += supplier_week_total + supplier_data_list.append((supplier_key, dict(invoices_by_date), supplier_week_total)) + + # Calculate percentages + week_supplier_rows = [] + for supplier_key, invoices_by_date, supplier_week_total in supplier_data_list: + supplier_id, supplier_name, is_unmatched = supplier_key + percentage = (supplier_week_total / week_total * 100) if week_total > 0 else Decimal("0") + + week_supplier_rows.append(MonthlySupplierRow( + supplier_id=supplier_id, + supplier_name=supplier_name, + is_unmatched=is_unmatched, + invoices_by_date=invoices_by_date, + total_net_stock=supplier_week_total, + percentage=round(percentage, 1) + )) + + weeks_data.append(WeekData( + week_start=current_week_start, + week_end=week_end, + dates=week_dates, + suppliers=week_supplier_rows, + daily_totals=dict(week_daily_totals), + week_total=week_total, + daily_invoice_totals=dict(week_daily_invoice_totals), + week_invoice_total=week_invoice_total + )) + + current_week_start += timedelta(days=7) + + # Calculate daily totals for entire period + period_daily_totals: dict[str, Decimal] = defaultdict(Decimal) + period_daily_invoice_totals: dict[str, Decimal] = defaultdict(Decimal) + for data in invoice_data.values(): + date_str = data["date"].isoformat() + period_daily_totals[date_str] += data["net_stock"] + # Use stored net_total (already negated for credit notes), fall back to stored total + period_daily_invoice_totals[date_str] += data["net_total"] or data["total"] or Decimal("0") + + return DateRangePurchasesResponse( + from_date=from_date, + to_date=to_date, + period_label=period_label, + weeks=weeks_data, + all_suppliers=all_suppliers, + daily_totals=dict(period_daily_totals), + period_total=period_total, + daily_invoice_totals=dict(period_daily_invoice_totals), + period_invoice_total=period_invoice_total + ) + + +class MonthlyGPResponse(BaseModel): + """Response for monthly GP calculation""" + year: int + month: int + month_name: str + net_food_sales: Decimal # Newbook revenue + manual entries + net_food_purchases: Decimal # Confirmed invoices net_total sum + gross_profit: Decimal # Sales - Purchases + gross_profit_percent: Decimal # (GP / Sales) * 100 + + +class SupplierBreakdown(BaseModel): + """Supplier purchase breakdown for period""" + supplier_id: int | None + supplier_name: str + net_purchases: Decimal + percentage: Decimal + + +class GLAccountBreakdown(BaseModel): + """GL account revenue breakdown for period""" + gl_account_id: int + gl_account_name: str + net_revenue: Decimal + percentage: Decimal + + +class DateRangeGPResponse(BaseModel): + """Response for date range GP calculation""" + from_date: date + to_date: date + period_label: str # Human-readable label like "Dec 18 - Jan 17, 2026" + net_food_sales: Decimal # Newbook revenue + manual entries + net_food_purchases: Decimal # Confirmed invoices net_total sum + gross_profit: Decimal # Sales - Purchases + gross_profit_percent: Decimal # (GP / Sales) * 100 + supplier_breakdown: list[SupplierBreakdown] = [] + gl_account_breakdown: list[GLAccountBreakdown] = [] + # Allowances breakdown - logbook entry types + wastage_total: Optional[Decimal] = None # Wastage entries + transfer_total: Optional[Decimal] = None # Transfer entries + staff_food_total: Optional[Decimal] = None # Staff food entries + manual_adjustment_total: Optional[Decimal] = None # Manual adjustment entries + # Cost distribution breakdown + cd_deductions_total: Optional[Decimal] = None # Source offsets (negative amounts removing cost from invoice dates) + cd_reallocations_total: Optional[Decimal] = None # Distribution entries (positive amounts adding cost to target dates) + # Open disputes on invoices in this period + disputes_total: Optional[Decimal] = None + + +@router.get("/gp/range", response_model=DateRangeGPResponse) +async def get_gp_by_range( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get GP calculation for a custom date range (inclusive)""" + from models.line_item import LineItem + from models.logbook import LogbookEntry, EntryType + from sqlalchemy import or_ + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Build period label + if from_date.year == to_date.year: + if from_date.month == to_date.month: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" + + # Get Newbook revenue for tracked GL accounts + newbook_revenue_result = await db.execute( + select(func.sum(NewbookDailyRevenue.amount_net)) + .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) + .where( + NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, + NewbookDailyRevenue.date >= from_date, + NewbookDailyRevenue.date <= to_date, + NewbookGLAccount.is_tracked == True + ) + ) + newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00") + + # Get manual revenue entries for period + manual_revenue_result = await db.execute( + select(func.sum(RevenueEntry.amount)) + .where( + RevenueEntry.kitchen_id == current_user.kitchen_id, + RevenueEntry.date >= from_date, + RevenueEntry.date <= to_date + ) + ) + manual_revenue = manual_revenue_result.scalar() or Decimal("0.00") + + # Total net food sales + net_food_sales = newbook_revenue + manual_revenue + + # Get net purchases from confirmed invoices - stock items only (exclude non-stock) + # Credit notes (document_type='credit_note') are treated as negative purchases + # Use subquery to sum per invoice first, then conditionally negate if credit note has positive total + from sqlalchemy import and_ + invoice_stock_subq = ( + select( + Invoice.id.label('inv_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 == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + 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.document_type) + .subquery() + ) + purchases_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) + ) + net_food_purchases = purchases_result.scalar() or Decimal("0.00") + + # Add cost distribution adjustments — split into deductions (source offsets) and reallocations + cd_deductions_result = await db.execute( + select(func.sum(CostDistributionEntry.amount)) + .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) + .where( + CostDistributionEntry.kitchen_id == current_user.kitchen_id, + CostDistributionEntry.entry_date >= from_date, + CostDistributionEntry.entry_date <= to_date, + CostDistributionEntry.is_source_offset == True, + CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), + ) + ) + cd_deductions = cd_deductions_result.scalar() or Decimal("0.00") # Will be negative + + cd_reallocations_result = await db.execute( + select(func.sum(CostDistributionEntry.amount)) + .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) + .where( + CostDistributionEntry.kitchen_id == current_user.kitchen_id, + CostDistributionEntry.entry_date >= from_date, + CostDistributionEntry.entry_date <= to_date, + CostDistributionEntry.is_source_offset == False, + CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), + ) + ) + cd_reallocations = cd_reallocations_result.scalar() or Decimal("0.00") # Will be positive + + # net_food_purchases stays as raw invoice total — CD adjustments are + # returned separately so the frontend can toggle them on/off. + # GP is calculated from raw purchases; the frontend applies CD + allowances + # to compute the "Adjusted GP %". + + # Get logbook entry totals by type + # Helper function to query a specific entry type + async def get_entry_type_total(entry_type: EntryType) -> Decimal: + result = await db.execute( + select(func.sum(LogbookEntry.total_cost)) + .where( + LogbookEntry.kitchen_id == current_user.kitchen_id, + LogbookEntry.entry_date >= from_date, + LogbookEntry.entry_date <= to_date, + LogbookEntry.entry_type == entry_type, + LogbookEntry.is_deleted == False + ) + ) + return result.scalar() or Decimal("0.00") + + wastage_total = await get_entry_type_total(EntryType.WASTAGE) + transfer_total = await get_entry_type_total(EntryType.TRANSFER) + staff_food_total = await get_entry_type_total(EntryType.STAFF_FOOD) + manual_adjustment_total = await get_entry_type_total(EntryType.MANUAL_ADJUSTMENT) + + # Open disputes total - based on invoice date, not dispute creation date + from models.dispute import InvoiceDispute, DisputeStatus + open_statuses = [ + DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, + DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, + DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED + ] + disputes_result = await db.execute( + select(func.sum(InvoiceDispute.difference_amount)) + .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) + .where( + InvoiceDispute.kitchen_id == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + InvoiceDispute.status.in_(open_statuses) + ) + ) + disputes_total = disputes_result.scalar() or Decimal("0.00") + + # Calculate GP + gross_profit = net_food_sales - net_food_purchases + gross_profit_percent = (gross_profit / net_food_sales * 100) if net_food_sales > 0 else Decimal("0.00") + + # Get supplier breakdown for purchases (credit notes as negative) + # Use subquery to sum per invoice, then aggregate by supplier + from models.supplier import Supplier + invoice_by_supplier_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 == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + 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() + ) + supplier_result = await db.execute( + select( + invoice_by_supplier_subq.c.supplier_id, + Supplier.name, + func.sum( + case( + (and_(invoice_by_supplier_subq.c.doc_type == 'credit_note', + invoice_by_supplier_subq.c.stock_total > 0), + -invoice_by_supplier_subq.c.stock_total), + else_=invoice_by_supplier_subq.c.stock_total + ) + ) + ) + .select_from(invoice_by_supplier_subq) + .outerjoin(Supplier, invoice_by_supplier_subq.c.supplier_id == Supplier.id) + .group_by(invoice_by_supplier_subq.c.supplier_id, Supplier.name) + .order_by(func.sum( + case( + (and_(invoice_by_supplier_subq.c.doc_type == 'credit_note', + invoice_by_supplier_subq.c.stock_total > 0), + -invoice_by_supplier_subq.c.stock_total), + else_=invoice_by_supplier_subq.c.stock_total + ) + ).desc()) + ) + supplier_rows = supplier_result.all() + supplier_breakdown = [] + for supplier_id, supplier_name, total in supplier_rows: + if total and total != 0: # Include negative totals (net credit notes) + pct = (total / net_food_purchases * 100) if net_food_purchases > 0 else Decimal("0") + supplier_breakdown.append(SupplierBreakdown( + supplier_id=supplier_id, + supplier_name=supplier_name or "Unmatched", + net_purchases=total, + percentage=round(pct, 1) + )) + + # Get GL account breakdown for revenue + gl_result = await db.execute( + select(NewbookGLAccount.id, NewbookGLAccount.gl_name, func.sum(NewbookDailyRevenue.amount_net)) + .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) + .where( + NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, + NewbookDailyRevenue.date >= from_date, + NewbookDailyRevenue.date <= to_date, + NewbookGLAccount.is_tracked == True + ) + .group_by(NewbookGLAccount.id, NewbookGLAccount.gl_name) + .order_by(func.sum(NewbookDailyRevenue.amount_net).desc()) + ) + gl_rows = gl_result.all() + gl_breakdown = [] + for gl_id, gl_name, total in gl_rows: + if total: # Include all non-zero values (including negative discounts) + pct = (total / newbook_revenue * 100) if newbook_revenue > 0 else Decimal("0") + gl_breakdown.append(GLAccountBreakdown( + gl_account_id=gl_id, + gl_account_name=gl_name or "Unknown", + net_revenue=total, + percentage=round(pct, 1) + )) + + return DateRangeGPResponse( + from_date=from_date, + to_date=to_date, + period_label=period_label, + net_food_sales=net_food_sales, + net_food_purchases=net_food_purchases, + gross_profit=gross_profit, + gross_profit_percent=round(gross_profit_percent, 1), + supplier_breakdown=supplier_breakdown, + gl_account_breakdown=gl_breakdown, + wastage_total=wastage_total if wastage_total > 0 else None, + transfer_total=transfer_total if transfer_total > 0 else None, + staff_food_total=staff_food_total if staff_food_total > 0 else None, + manual_adjustment_total=manual_adjustment_total if manual_adjustment_total > 0 else None, + disputes_total=disputes_total if disputes_total > 0 else None, + cd_deductions_total=cd_deductions if cd_deductions != 0 else None, + cd_reallocations_total=cd_reallocations if cd_reallocations != 0 else None, + ) + + +class DailyDataPoint(BaseModel): + """Single day's data for charting""" + date: date + net_sales: Decimal + net_purchases: Decimal + occupancy: int | None = None # Night total occupancy (from Newbook when available) + lunch_covers: int | None = None # Placeholder for resos integration + dinner_covers: int | None = None # Placeholder for resos integration + total_covers: int | None = None # Placeholder for resos integration + + +class DailyGPChartResponse(BaseModel): + """Response for daily GP chart data""" + from_date: date + to_date: date + data: list[DailyDataPoint] + + +@router.get("/gp/daily", response_model=DailyGPChartResponse) +async def get_daily_gp_data( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get daily net sales and purchases for charting""" + from models.line_item import LineItem + from models.resos import ResosDailyStats, ResosBooking + from sqlalchemy import or_, and_ + from datetime import timedelta + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Get daily Newbook revenue (grouped by date) + newbook_daily = await db.execute( + select(NewbookDailyRevenue.date, func.sum(NewbookDailyRevenue.amount_net)) + .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) + .where( + NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, + NewbookDailyRevenue.date >= from_date, + NewbookDailyRevenue.date <= to_date, + NewbookGLAccount.is_tracked == True + ) + .group_by(NewbookDailyRevenue.date) + ) + newbook_by_date = {row[0]: row[1] or Decimal("0") for row in newbook_daily.all()} + + # Get daily manual revenue entries (grouped by date) + manual_daily = await db.execute( + select(RevenueEntry.date, func.sum(RevenueEntry.amount)) + .where( + RevenueEntry.kitchen_id == current_user.kitchen_id, + RevenueEntry.date >= from_date, + RevenueEntry.date <= to_date + ) + .group_by(RevenueEntry.date) + ) + manual_by_date = {row[0]: row[1] or Decimal("0") for row in manual_daily.all()} + + # Get daily purchases from confirmed invoices (grouped by invoice_date) + # Credit notes (document_type='credit_note') are treated as negative purchases + # Use subquery to sum per invoice, then aggregate by date + invoice_daily_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 == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + 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() + ) + purchases_daily = await db.execute( + select( + invoice_daily_subq.c.inv_date, + func.sum( + case( + (and_(invoice_daily_subq.c.doc_type == 'credit_note', + invoice_daily_subq.c.stock_total > 0), + -invoice_daily_subq.c.stock_total), + else_=invoice_daily_subq.c.stock_total + ) + ) + ) + .select_from(invoice_daily_subq) + .group_by(invoice_daily_subq.c.inv_date) + ) + purchases_by_date = {row[0]: row[1] or Decimal("0") for row in purchases_daily.all()} + + # Add cost distribution adjustments grouped by date + cd_daily = await db.execute( + select( + CostDistributionEntry.entry_date, + func.sum(CostDistributionEntry.amount) + ) + .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) + .where( + CostDistributionEntry.kitchen_id == current_user.kitchen_id, + CostDistributionEntry.entry_date >= from_date, + CostDistributionEntry.entry_date <= to_date, + CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), + ) + .group_by(CostDistributionEntry.entry_date) + ) + for row in cd_daily.all(): + cd_date, cd_amount = row[0], row[1] or Decimal("0") + purchases_by_date[cd_date] = purchases_by_date.get(cd_date, Decimal("0")) + cd_amount + + # Get Resos booking data (covers by service period) + resos_daily = await db.execute( + select(ResosDailyStats) + .where( + and_( + ResosDailyStats.kitchen_id == current_user.kitchen_id, + ResosDailyStats.date >= from_date, + ResosDailyStats.date <= to_date + ) + ) + ) + resos_stats = {stat.date: stat for stat in resos_daily.scalars().all()} + + # Get Newbook occupancy data (total guests per night) + newbook_occupancy = await db.execute( + select(NewbookDailyOccupancy) + .where( + and_( + NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, + NewbookDailyOccupancy.date >= from_date, + NewbookDailyOccupancy.date <= to_date + ) + ) + ) + occupancy_by_date = {occ.date: occ for occ in newbook_occupancy.scalars().all()} + + # Build daily data points for the entire range + data_points = [] + current_date = from_date + while current_date <= to_date: + net_sales = (newbook_by_date.get(current_date, Decimal("0")) + + manual_by_date.get(current_date, Decimal("0"))) + net_purchases = purchases_by_date.get(current_date, Decimal("0")) + + # Extract Resos covers if available + lunch_covers = None + dinner_covers = None + total_covers = None + + if current_date in resos_stats: + stat = resos_stats[current_date] + total_covers = stat.total_covers + + # Extract lunch and dinner covers from service_breakdown + if stat.service_breakdown: + for service in stat.service_breakdown: + period_name = service.get('period', '').lower() + covers = service.get('covers', 0) + + if 'lunch' in period_name: + lunch_covers = covers + elif 'dinner' in period_name: + dinner_covers = covers + + # Extract Newbook occupancy (total guests) if available + occupancy = None + if current_date in occupancy_by_date: + occ = occupancy_by_date[current_date] + occupancy = occ.total_guests + + data_points.append(DailyDataPoint( + date=current_date, + net_sales=net_sales, + net_purchases=net_purchases, + occupancy=occupancy, + lunch_covers=lunch_covers, + dinner_covers=dinner_covers, + total_covers=total_covers + )) + current_date += timedelta(days=1) + + return DailyGPChartResponse( + from_date=from_date, + to_date=to_date, + data=data_points + ) + + +@router.get("/gp/monthly", response_model=MonthlyGPResponse) +async def get_monthly_gp( + year: int | None = None, + month: int | None = None, # 1-12 + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get monthly GP calculation with sales and purchases breakdown""" + from calendar import monthrange, month_name as calendar_month_name + + # Default to current month + today = date.today() + year = year or today.year + month = month or today.month + + # Get first and last day of month + _, days_in_month = monthrange(year, month) + month_start = date(year, month, 1) + month_end = date(year, month, days_in_month) + + # Get Newbook revenue for tracked GL accounts + newbook_revenue_result = await db.execute( + select(func.sum(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 + ) + ) + newbook_revenue = newbook_revenue_result.scalar() or Decimal("0.00") + + # Get manual revenue entries for period + manual_revenue_result = await db.execute( + select(func.sum(RevenueEntry.amount)) + .where( + RevenueEntry.kitchen_id == current_user.kitchen_id, + RevenueEntry.date >= month_start, + RevenueEntry.date <= month_end + ) + ) + manual_revenue = manual_revenue_result.scalar() or Decimal("0.00") + + # Total net food sales + net_food_sales = newbook_revenue + manual_revenue + + # Get net purchases from confirmed invoices - stock items only (exclude non-stock) + # Credit notes (document_type='credit_note') are treated as negative purchases + # Use subquery to sum per invoice, then conditionally negate + from models.line_item import LineItem + from sqlalchemy import or_, and_ + + invoice_stock_subq = ( + select( + Invoice.id.label('inv_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 == current_user.kitchen_id, + Invoice.invoice_date >= month_start, + Invoice.invoice_date <= month_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.document_type) + .subquery() + ) + purchases_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) + ) + net_food_purchases = purchases_result.scalar() or Decimal("0.00") + + # Add cost distribution adjustments + cd_adj_result = await db.execute( + select(func.sum(CostDistributionEntry.amount)) + .join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id) + .where( + CostDistributionEntry.kitchen_id == current_user.kitchen_id, + CostDistributionEntry.entry_date >= month_start, + CostDistributionEntry.entry_date <= month_end, + CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]), + ) + ) + net_food_purchases += cd_adj_result.scalar() or Decimal("0.00") + + # Calculate GP + gross_profit = net_food_sales - net_food_purchases + gross_profit_percent = (gross_profit / net_food_sales * 100) if net_food_sales > 0 else Decimal("0.00") + + return MonthlyGPResponse( + year=year, + month=month, + month_name=calendar_month_name[month], + net_food_sales=net_food_sales, + net_food_purchases=net_food_purchases, + gross_profit=gross_profit, + gross_profit_percent=round(gross_profit_percent, 1) + ) + + +# ============ Top Sellers Models ============ + +class TopSellerItem(BaseModel): + """Individual top seller item""" + item_name: str + qty: int + revenue: Decimal + + +class PackageFavoriteItem(BaseModel): + """Package guest favorite item (qty only)""" + item_name: str + qty: int + + +class CategoryTopSellers(BaseModel): + """Top sellers for a single category""" + category: str # "Starters", "Mains", "Desserts", etc. + top_by_qty: list[TopSellerItem] # Top 10 by quantity + top_by_revenue: list[TopSellerItem] # Top 10 by revenue + + +class TopSellersResponse(BaseModel): + """Response for top sellers data""" + from_date: date + to_date: date + source: str = "newbook" # "sambapos" or "newbook" + # SambaPOS category-based format + categories: list[CategoryTopSellers] = [] + # Legacy Newbook format (flat lists) + top_by_qty: list[TopSellerItem] = [] + top_by_revenue: list[TopSellerItem] = [] + package_favorites: list[PackageFavoriteItem] = [] + total_charges_processed: int = 0 + total_items_aggregated: int = 0 + + +def parse_charge_description(description: str) -> tuple[int, str] | None: + """ + Parse Newbook charge description to extract qty and item name. + + Format: "Ticket: 22900 - 1 x Venison Bourguignon" + Returns: (qty, item_name) or None if cannot parse + """ + import re + + if not description: + return None + + # Try pattern: "Ticket: XXXXX - N x Item Name" + # Also handle variations without ticket number + patterns = [ + r'Ticket:\s*\d+\s*-\s*(\d+)\s*x\s*(.+)', # Ticket: 22900 - 1 x Item + r'^(\d+)\s*x\s*(.+)', # 1 x Item (no ticket prefix) + r'-\s*(\d+)\s*x\s*(.+)', # - 1 x Item + ] + + for pattern in patterns: + match = re.search(pattern, description, re.IGNORECASE) + if match: + try: + qty = int(match.group(1)) + item_name = match.group(2).strip() + # Clean up item name - remove trailing punctuation and whitespace + item_name = re.sub(r'[\s,;.]+$', '', item_name) + if item_name and qty > 0: + return (qty, item_name) + except (ValueError, IndexError): + continue + + return None + + +@router.get("/gp/top-sellers", response_model=TopSellersResponse) +async def get_top_sellers( + from_date: date, + to_date: date, + limit: int = 10, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Get top selling items for a date range. + + If SambaPOS is configured, fetches data from SambaPOS database with category breakdowns. + Otherwise falls back to Newbook charges with flat lists. + + Returns top 10 items by quantity and top 10 by revenue (per category for SambaPOS). + """ + from models.settings import KitchenSettings + from models.newbook import NewbookGLAccount + from services.newbook_api import NewbookAPIClient, NewbookAPIError + from services.sambapos_api import SambaPOSClient + from collections import defaultdict + import logging + logger = logging.getLogger(__name__) + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # 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=400, detail="Settings not configured") + + # Check if SambaPOS is configured - use it if available + if all([ + settings.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + # Use SambaPOS data source + logger.info(f"Top sellers: Using SambaPOS data source for {from_date} to {to_date}") + + # Get tracked categories + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + # Get excluded items + excluded_items = [] + if settings.sambapos_excluded_items: + excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] + + if not tracked_categories: + # Return empty response if no categories configured + return TopSellersResponse( + from_date=from_date, + to_date=to_date, + source="sambapos", + categories=[] + ) + + try: + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + # Get top sellers by quantity and by revenue (excluding configured GroupCodes) + top_by_qty = await client.get_top_sellers(from_date, to_date, tracked_categories, limit, excluded_categories=excluded_items if excluded_items else None) + top_by_revenue = await client.get_top_sellers_by_revenue(from_date, to_date, tracked_categories, limit, excluded_categories=excluded_items if excluded_items else None) + + # Build category response in order of tracked_categories + categories_response = [] + for cat_name in tracked_categories: + qty_items = top_by_qty.get(cat_name, []) + rev_items = top_by_revenue.get(cat_name, []) + + categories_response.append(CategoryTopSellers( + category=cat_name, + top_by_qty=[ + TopSellerItem(item_name=item["item_name"], qty=item["qty"], revenue=item["revenue"]) + for item in qty_items + ], + top_by_revenue=[ + TopSellerItem(item_name=item["item_name"], qty=item["qty"], revenue=item["revenue"]) + for item in rev_items + ] + )) + + return TopSellersResponse( + from_date=from_date, + to_date=to_date, + source="sambapos", + categories=categories_response + ) + + except Exception as e: + logger.error(f"SambaPOS top sellers failed: {e}") + raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}") + + # Fallback to Newbook if SambaPOS not configured + if not settings.newbook_api_username: + raise HTTPException(status_code=400, detail="Neither SambaPOS nor Newbook credentials configured") + + # Get tracked GL accounts for this kitchen (food sales accounts) + gl_result = await db.execute( + select(NewbookGLAccount).where( + NewbookGLAccount.kitchen_id == current_user.kitchen_id, + NewbookGLAccount.is_tracked == True + ) + ) + tracked_accounts = gl_result.scalars().all() + + if not tracked_accounts: + return TopSellersResponse( + from_date=from_date, + to_date=to_date, + top_by_qty=[], + top_by_revenue=[], + total_charges_processed=0, + total_items_aggregated=0 + ) + + # Build set of tracked GL account IDs (as strings) + tracked_gl_ids = {acc.gl_account_id for acc in tracked_accounts} + + # Fetch charges from Newbook + 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: + charges = await client.get_charges_list(from_date, to_date) + except NewbookAPIError as e: + raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") + + # Aggregate items - separate tracking for regular vs package/supplement + import re + import logging + logger = logging.getLogger(__name__) + + regular_items: dict[str, dict] = defaultdict(lambda: {"qty": 0, "revenue": Decimal("0")}) + package_items: dict[str, int] = defaultdict(int) # qty only for package/supplement + total_processed = 0 + total_voided = 0 + total_wrong_gl = 0 + total_unparsed = 0 + sample_unparsed = [] + sample_charges = [] + + logger.info(f"Top sellers: Starting with {len(charges)} total charges, {len(tracked_gl_ids)} tracked GL IDs: {tracked_gl_ids}") + + for charge in charges: + # Log first few charges to see structure + if len(sample_charges) < 5: + sample_charges.append({ + "gl_account_id": charge.get("gl_account_id"), + "description": charge.get("description"), + "voided_when": charge.get("voided_when"), + "voided_by": charge.get("voided_by"), + }) + + # Skip voided charges + voided_when = charge.get("voided_when") + voided_by = charge.get("voided_by", "0") + + # Check if voided - voided_when can be None, empty string, or actual date + # voided_by is "0" when not voided + if voided_when or (voided_by and voided_by != "0"): + total_voided += 1 + continue + + # Filter to tracked GL accounts only + gl_account_id = charge.get("gl_account_id", "") + if gl_account_id not in tracked_gl_ids: + total_wrong_gl += 1 + continue + + total_processed += 1 + + # Parse description to get qty and item name + description = charge.get("description", "") + parsed = parse_charge_description(description) + + if parsed: + qty, item_name = parsed + amount = charge.get("amount_ex_tax", Decimal("0")) + + # Check for [Package] or [Supplement] suffix + is_package = bool(re.search(r'\[(package|supplement)\]', item_name, re.IGNORECASE)) + + # Strip [Package] or [Supplement] suffix + item_name = re.sub(r'\s*\[(package|supplement)\]\s*', '', item_name, flags=re.IGNORECASE) + + # Normalize item name: lowercase, remove extra spaces + item_name = " ".join(item_name.lower().split()) + + if is_package: + # Track package items separately (qty only) + package_items[item_name] += qty + else: + # Regular item - track qty and revenue + regular_items[item_name]["qty"] += qty + regular_items[item_name]["revenue"] += amount + else: + total_unparsed += 1 + if len(sample_unparsed) < 10: + sample_unparsed.append(description) + + # Calculate average prices from regular sales + avg_prices: dict[str, Decimal] = {} + for name, data in regular_items.items(): + if data["qty"] > 0: + avg_prices[name] = data["revenue"] / data["qty"] + + # Combine regular + package for main top sellers + # For package items, use average price from regular sales if available + all_item_names = set(regular_items.keys()) | set(package_items.keys()) + combined_items: dict[str, dict] = {} + + for name in all_item_names: + reg_qty = regular_items.get(name, {}).get("qty", 0) + pkg_qty = package_items.get(name, 0) + total_qty = reg_qty + pkg_qty + + # Revenue: regular revenue + (package qty * avg price if available) + reg_revenue = regular_items.get(name, {}).get("revenue", Decimal("0")) + if name in avg_prices and pkg_qty > 0: + estimated_pkg_revenue = avg_prices[name] * pkg_qty + total_revenue = reg_revenue + estimated_pkg_revenue + else: + total_revenue = reg_revenue + + combined_items[name] = {"qty": total_qty, "revenue": total_revenue} + + logger.info(f"Top sellers: processed {total_processed} charges, {len(regular_items)} regular items, {len(package_items)} package items, {len(combined_items)} combined") + + # Sort and get top items from combined + items_list = [ + {"name": name, "qty": data["qty"], "revenue": data["revenue"]} + for name, data in combined_items.items() + ] + + # Top by quantity + top_by_qty = sorted(items_list, key=lambda x: x["qty"], reverse=True)[:limit] + + # Top by revenue + top_by_revenue = sorted(items_list, key=lambda x: x["revenue"], reverse=True)[:limit] + + # Package favorites (qty only, from package_items) + package_favorites_list = sorted( + [{"name": name, "qty": qty} for name, qty in package_items.items()], + key=lambda x: x["qty"], + reverse=True + )[:limit] + + # Title case helper for display + def title_case(s: str) -> str: + return " ".join(word.capitalize() for word in s.split()) + + return TopSellersResponse( + from_date=from_date, + to_date=to_date, + source="newbook", + top_by_qty=[ + TopSellerItem(item_name=title_case(item["name"]), qty=item["qty"], revenue=item["revenue"]) + for item in top_by_qty + ], + top_by_revenue=[ + TopSellerItem(item_name=title_case(item["name"]), qty=item["qty"], revenue=item["revenue"]) + for item in top_by_revenue + ], + package_favorites=[ + PackageFavoriteItem(item_name=title_case(item["name"]), qty=item["qty"]) + for item in package_favorites_list + ], + total_charges_processed=total_processed, + total_items_aggregated=len(combined_items) + ) + + +# ============ Purchases Report Endpoints ============ + +class PurchasesSummaryResponse(BaseModel): + """Response for purchases summary""" + from_date: date + to_date: date + period_label: str + total_purchases: Decimal + supplier_breakdown: list[SupplierBreakdown] + + +class DailySupplierDataPoint(BaseModel): + """Single data point for daily supplier chart""" + date: date + supplier_id: int | None + supplier_name: str + net_purchases: Decimal + + +class DailySupplierChartResponse(BaseModel): + """Response for daily supplier chart""" + from_date: date + to_date: date + suppliers: list[str] # Ordered list of supplier names for legend + data: list[DailySupplierDataPoint] + + +class TopLineItem(BaseModel): + """Top line item by quantity or value""" + description: str + product_code: str | None + total_quantity: Decimal + total_value: Decimal + avg_unit_price: Decimal + occurrence_count: int + + +class TopItemsResponse(BaseModel): + """Response for top line items""" + from_date: date + to_date: date + top_by_quantity: list[TopLineItem] + top_by_value: list[TopLineItem] + + +@router.get("/purchases/summary", response_model=PurchasesSummaryResponse) +async def get_purchases_summary( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get purchases summary with supplier breakdown for date range""" + from models.line_item import LineItem + from models.supplier import Supplier + from sqlalchemy import or_ + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Build period label + if from_date.year == to_date.year: + if from_date.month == to_date.month: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" + + # Get total purchases (stock items only from confirmed invoices) + # Credit notes (document_type='credit_note') are treated as negative purchases + # Use subquery to sum per invoice, then conditionally negate + from sqlalchemy import and_ + 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 == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + 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() + ) + 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_purchases = total_result.scalar() or Decimal("0.00") + + # Get supplier breakdown (credit notes as negative) + # Reuse the subquery grouped by supplier + 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 + ) + ) + ) + .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()) + ) + + supplier_breakdown = [] + for supplier_id, supplier_name, total in supplier_result.all(): + if total and total != 0: # Include negative totals (net credit notes) + pct = (total / total_purchases * 100) if total_purchases > 0 else Decimal("0") + supplier_breakdown.append(SupplierBreakdown( + supplier_id=supplier_id, + supplier_name=supplier_name or "Unmatched", + net_purchases=total, + percentage=round(pct, 1) + )) + + return PurchasesSummaryResponse( + from_date=from_date, + to_date=to_date, + period_label=period_label, + total_purchases=total_purchases, + supplier_breakdown=supplier_breakdown + ) + + +@router.get("/purchases/daily-by-supplier", response_model=DailySupplierChartResponse) +async def get_daily_purchases_by_supplier( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get daily purchases grouped by supplier for multi-line chart""" + from models.line_item import LineItem + from models.supplier import Supplier + from sqlalchemy import or_ + from datetime import timedelta + from collections import defaultdict + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Get daily purchases by supplier (credit notes as negative) + # Use subquery to sum per invoice first, then aggregate by date/supplier + from sqlalchemy import and_ + invoice_daily_subq = ( + select( + Invoice.id.label('inv_id'), + Invoice.invoice_date.label('inv_date'), + 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 == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + 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.supplier_id, Invoice.document_type) + .subquery() + ) + daily_result = await db.execute( + select( + invoice_daily_subq.c.inv_date, + invoice_daily_subq.c.supplier_id, + Supplier.name, + func.sum( + case( + (and_(invoice_daily_subq.c.doc_type == 'credit_note', + invoice_daily_subq.c.stock_total > 0), + -invoice_daily_subq.c.stock_total), + else_=invoice_daily_subq.c.stock_total + ) + ) + ) + .select_from(invoice_daily_subq) + .outerjoin(Supplier, invoice_daily_subq.c.supplier_id == Supplier.id) + .group_by(invoice_daily_subq.c.inv_date, invoice_daily_subq.c.supplier_id, Supplier.name) + .order_by(invoice_daily_subq.c.inv_date) + ) + + # Collect all supplier totals to determine top suppliers + supplier_totals: dict[str, Decimal] = defaultdict(Decimal) + daily_data: list[tuple] = [] + + for inv_date, supplier_id, supplier_name, total in daily_result.all(): + name = supplier_name or "Unmatched" + supplier_totals[name] += total or Decimal("0") + daily_data.append((inv_date, supplier_id, name, total or Decimal("0"))) + + # Get top 10 suppliers by total purchases + top_suppliers = sorted(supplier_totals.keys(), key=lambda x: supplier_totals[x], reverse=True)[:10] + + # Build data points for top suppliers only + data_points = [] + for inv_date, supplier_id, supplier_name, total in daily_data: + if supplier_name in top_suppliers: + data_points.append(DailySupplierDataPoint( + date=inv_date, + supplier_id=supplier_id, + supplier_name=supplier_name, + net_purchases=total + )) + + return DailySupplierChartResponse( + from_date=from_date, + to_date=to_date, + suppliers=top_suppliers, + data=data_points + ) + + +@router.get("/purchases/top-items", response_model=TopItemsResponse) +async def get_top_purchase_items( + from_date: date, + to_date: date, + limit: int = 10, + supplier_id: Optional[int] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get top line items by quantity and value, optionally filtered by supplier""" + from models.line_item import LineItem + from sqlalchemy import or_ + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Build base query (credit notes as negative values) + # Use -func.abs() to handle suppliers who already use negative values (avoid double-negation) + query = ( + select( + LineItem.description, + LineItem.product_code, + func.sum( + case( + (Invoice.document_type == 'credit_note', -func.abs(LineItem.quantity)), + else_=LineItem.quantity + ) + ).label('total_qty'), + func.sum( + case( + (Invoice.document_type == 'credit_note', -func.abs(LineItem.amount)), + else_=LineItem.amount + ) + ).label('total_value'), + func.avg(LineItem.unit_price).label('avg_price'), + func.count(LineItem.id).label('occurrence_count') + ) + .join(Invoice, LineItem.invoice_id == Invoice.id) + .where( + Invoice.kitchen_id == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date, + Invoice.status == InvoiceStatus.CONFIRMED, + LineItem.amount.isnot(None), + LineItem.description.isnot(None), + or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)) + ) + ) + + # Add supplier filter if specified + if supplier_id is not None: + query = query.where(Invoice.supplier_id == supplier_id) + + query = query.group_by(LineItem.description, LineItem.product_code) + + items_result = await db.execute(query) + + all_items = [] + for row in items_result.all(): + desc, prod_code, total_qty, total_value, avg_price, count = row + if total_qty and total_qty > 0: + all_items.append({ + "description": desc or "Unknown", + "product_code": prod_code, + "total_quantity": total_qty, + "total_value": total_value or Decimal("0"), + "avg_unit_price": avg_price or Decimal("0"), + "occurrence_count": count + }) + + # Sort by quantity + top_by_qty = sorted(all_items, key=lambda x: x["total_quantity"], reverse=True)[:limit] + + # Sort by value + top_by_value = sorted(all_items, key=lambda x: x["total_value"], reverse=True)[:limit] + + return TopItemsResponse( + from_date=from_date, + to_date=to_date, + top_by_quantity=[ + TopLineItem( + description=item["description"], + product_code=item["product_code"], + total_quantity=item["total_quantity"], + total_value=item["total_value"], + avg_unit_price=round(item["avg_unit_price"], 2), + occurrence_count=item["occurrence_count"] + ) + for item in top_by_qty + ], + top_by_value=[ + TopLineItem( + description=item["description"], + product_code=item["product_code"], + total_quantity=item["total_quantity"], + total_value=item["total_value"], + avg_unit_price=round(item["avg_unit_price"], 2), + occurrence_count=item["occurrence_count"] + ) + for item in top_by_value + ] + ) + + +# ============ Allowances Report Endpoints ============ + +class AllowancesSummaryResponse(BaseModel): + """Response for allowances summary""" + from_date: date + to_date: date + period_label: str + wastage_total: Decimal + wastage_count: int + transfer_total: Decimal + transfer_count: int + staff_food_total: Decimal + staff_food_count: int + manual_adjustment_total: Decimal + manual_adjustment_count: int + total_allowances: Decimal + + +class DailyAllowanceDataPoint(BaseModel): + """Single data point for daily allowances chart""" + date: date + wastage: Decimal + transfer: Decimal + staff_food: Decimal + manual_adjustment: Decimal + + +class DailyAllowanceChartResponse(BaseModel): + """Response for daily allowances chart""" + from_date: date + to_date: date + data: list[DailyAllowanceDataPoint] + + +class DisputeTallyRow(BaseModel): + """Single row in dispute tally""" + label: str + count: int + difference_value: Decimal + + +class DisputesSummaryResponse(BaseModel): + """Response for disputes period summary""" + from_date: date + to_date: date + period_label: str + rows: list[DisputeTallyRow] + + +@router.get("/allowances/summary", response_model=AllowancesSummaryResponse) +async def get_allowances_summary( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get allowances summary by entry type""" + from models.logbook import LogbookEntry, EntryType + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Build period label + if from_date.year == to_date.year: + if from_date.month == to_date.month: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" + + # Helper to get total and count for entry type + async def get_entry_stats(entry_type: EntryType) -> tuple[Decimal, int]: + total_result = await db.execute( + select(func.sum(LogbookEntry.total_cost), func.count(LogbookEntry.id)) + .where( + LogbookEntry.kitchen_id == current_user.kitchen_id, + LogbookEntry.entry_date >= from_date, + LogbookEntry.entry_date <= to_date, + LogbookEntry.entry_type == entry_type, + LogbookEntry.is_deleted == False + ) + ) + row = total_result.one() + return (row[0] or Decimal("0.00"), row[1] or 0) + + wastage_total, wastage_count = await get_entry_stats(EntryType.WASTAGE) + transfer_total, transfer_count = await get_entry_stats(EntryType.TRANSFER) + staff_food_total, staff_food_count = await get_entry_stats(EntryType.STAFF_FOOD) + manual_adj_total, manual_adj_count = await get_entry_stats(EntryType.MANUAL_ADJUSTMENT) + + total_allowances = wastage_total + transfer_total + staff_food_total + manual_adj_total + + return AllowancesSummaryResponse( + from_date=from_date, + to_date=to_date, + period_label=period_label, + wastage_total=wastage_total, + wastage_count=wastage_count, + transfer_total=transfer_total, + transfer_count=transfer_count, + staff_food_total=staff_food_total, + staff_food_count=staff_food_count, + manual_adjustment_total=manual_adj_total, + manual_adjustment_count=manual_adj_count, + total_allowances=total_allowances + ) + + +@router.get("/allowances/daily", response_model=DailyAllowanceChartResponse) +async def get_daily_allowances( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get daily allowances breakdown by type for chart""" + from models.logbook import LogbookEntry, EntryType + from datetime import timedelta + from collections import defaultdict + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Get all entries grouped by date and type + result = await db.execute( + select(LogbookEntry.entry_date, LogbookEntry.entry_type, func.sum(LogbookEntry.total_cost)) + .where( + LogbookEntry.kitchen_id == current_user.kitchen_id, + LogbookEntry.entry_date >= from_date, + LogbookEntry.entry_date <= to_date, + LogbookEntry.is_deleted == False + ) + .group_by(LogbookEntry.entry_date, LogbookEntry.entry_type) + ) + + # Build lookup by date and type + daily_data: dict[date, dict[str, Decimal]] = defaultdict(lambda: { + "wastage": Decimal("0"), + "transfer": Decimal("0"), + "staff_food": Decimal("0"), + "manual_adjustment": Decimal("0") + }) + + for entry_date, entry_type, total in result.all(): + type_key = entry_type.value.lower() + daily_data[entry_date][type_key] = total or Decimal("0") + + # Build data points for full date range + data_points = [] + current_date = from_date + while current_date <= to_date: + day_data = daily_data.get(current_date, { + "wastage": Decimal("0"), + "transfer": Decimal("0"), + "staff_food": Decimal("0"), + "manual_adjustment": Decimal("0") + }) + data_points.append(DailyAllowanceDataPoint( + date=current_date, + wastage=day_data["wastage"], + transfer=day_data["transfer"], + staff_food=day_data["staff_food"], + manual_adjustment=day_data["manual_adjustment"] + )) + current_date += timedelta(days=1) + + return DailyAllowanceChartResponse( + from_date=from_date, + to_date=to_date, + data=data_points + ) + + +@router.get("/disputes/period-summary", response_model=DisputesSummaryResponse) +async def get_disputes_period_summary( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get disputes summary for cases opened in period (by invoice date)""" + from models.dispute import InvoiceDispute, DisputeStatus + + # Validate date range + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # Build period label + if from_date.year == to_date.year: + if from_date.month == to_date.month: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d')} - {to_date.strftime('%b %d, %Y')}" + else: + period_label = f"{from_date.strftime('%b %d, %Y')} - {to_date.strftime('%b %d, %Y')}" + + # Base query - disputes where invoice_date falls in period + base_query = ( + select(func.count(InvoiceDispute.id), func.sum(InvoiceDispute.difference_amount)) + .join(Invoice, InvoiceDispute.invoice_id == Invoice.id) + .where( + InvoiceDispute.kitchen_id == current_user.kitchen_id, + Invoice.invoice_date >= from_date, + Invoice.invoice_date <= to_date + ) + ) + + # Total cases + total_result = await db.execute(base_query) + total_row = total_result.one() + total_count = total_row[0] or 0 + total_value = total_row[1] or Decimal("0") + + # Resolved cases + resolved_result = await db.execute( + base_query.where(InvoiceDispute.status == DisputeStatus.RESOLVED) + ) + resolved_row = resolved_result.one() + resolved_count = resolved_row[0] or 0 + resolved_value = resolved_row[1] or Decimal("0") + + # Closed cases + closed_result = await db.execute( + base_query.where(InvoiceDispute.status == DisputeStatus.CLOSED) + ) + closed_row = closed_result.one() + closed_count = closed_row[0] or 0 + closed_value = closed_row[1] or Decimal("0") + + # Still open cases + open_statuses = [ + DisputeStatus.NEW, DisputeStatus.OPEN, DisputeStatus.CONTACTED, + DisputeStatus.IN_PROGRESS, DisputeStatus.AWAITING_CREDIT, + DisputeStatus.AWAITING_REPLACEMENT, DisputeStatus.ESCALATED + ] + open_result = await db.execute( + base_query.where(InvoiceDispute.status.in_(open_statuses)) + ) + open_row = open_result.one() + open_count = open_row[0] or 0 + open_value = open_row[1] or Decimal("0") + + return DisputesSummaryResponse( + from_date=from_date, + to_date=to_date, + period_label=period_label, + rows=[ + DisputeTallyRow(label="Total Cases", count=total_count, difference_value=total_value), + DisputeTallyRow(label="Resolved", count=resolved_count, difference_value=resolved_value), + DisputeTallyRow(label="Closed", count=closed_count, difference_value=closed_value), + DisputeTallyRow(label="Still Open", count=open_count, difference_value=open_value) + ] + ) + + +# ============ Sales GP Report ============ + +class SalesGPItem(BaseModel): + menu_item_name: str + portion_name: str + category: str # SambaPOS Kitchen Course + total_qty: int + total_revenue_net: Decimal # ex-VAT (gross / 1.20) + dbb_qty: int = 0 # Qty from DBB/package orders (price zeroed, original price used) + recipe_id: Optional[int] = None + recipe_name: Optional[str] = None + dish_course: Optional[str] = None # MenuSection name from recipe + cost_per_portion: Optional[Decimal] = None + total_cost: Optional[Decimal] = None + item_gp_percent: Optional[Decimal] = None + + +class SalesGPCourseGroup(BaseModel): + course_name: str + items: list[SalesGPItem] + course_revenue: Decimal + course_cost: Decimal + course_gp_percent: Optional[Decimal] = None + + +class SalesGPResponse(BaseModel): + from_date: date + to_date: date + courses: list[SalesGPCourseGroup] + unmapped_items: list[SalesGPItem] + # GP totals (mapped items ONLY) + mapped_revenue_net: Decimal + mapped_total_cost: Decimal + mapped_gp_percent: Optional[Decimal] = None + # Coverage context + total_all_revenue_net: Decimal + unmapped_revenue_net: Decimal + mapped_revenue_percent: Decimal + mapped_item_count: int + unmapped_item_count: int + + +@router.get("/sales-gp", response_model=SalesGPResponse) +async def get_sales_gp( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Estimated Sales GP% report. + + Fetches SambaPOS sales data for a date range, matches items to dish recipes, + and calculates GP based on recipe costs. Only mapped items contribute to GP calculation. + Unmapped items are listed separately with their revenue for coverage assessment. + """ + from models.settings import KitchenSettings + from models.recipe import Recipe, MenuSection, RecipeCostSnapshot + from services.sambapos_api import SambaPOSClient + + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + # 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=400, detail="Settings not configured") + + # Validate SambaPOS config + if not all([ + settings.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + if not tracked_categories: + raise HTTPException(status_code=400, detail="No SambaPOS categories configured") + + excluded_items = [] + if settings.sambapos_excluded_items: + excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] + + # Fetch sales data from SambaPOS + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + sales = await client.get_sales_breakdown( + from_date, to_date, tracked_categories, + excluded_categories=excluded_items if excluded_items else None + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}") + + # Load all dish recipes with SambaPOS mapping + recipe_result = await db.execute( + select(Recipe) + .options(selectinload(Recipe.menu_section)) + .where( + Recipe.kitchen_id == current_user.kitchen_id, + Recipe.recipe_type == "dish", + Recipe.kds_menu_item_name.isnot(None), + Recipe.kds_menu_item_name != "", + Recipe.is_archived == False, + ) + ) + recipes = recipe_result.scalars().all() + + # Build lookup: (menu_item_name, portion_name_or_none) -> recipe + recipe_lookup: dict[tuple[str, str | None], object] = {} + for r in recipes: + if r.sambapos_portion_name: + recipe_lookup[(r.kds_menu_item_name, r.sambapos_portion_name)] = r + else: + recipe_lookup[(r.kds_menu_item_name, None)] = r + + # Get latest cost snapshots for all mapped recipes + recipe_ids = [r.id for r in recipes] + cost_lookup: dict[int, Decimal] = {} + if recipe_ids: + # Get latest snapshot per recipe using a subquery + from sqlalchemy import and_ + for rid in recipe_ids: + snap_result = await db.execute( + select(RecipeCostSnapshot) + .where(RecipeCostSnapshot.recipe_id == rid) + .order_by(RecipeCostSnapshot.snapshot_date.desc()) + .limit(1) + ) + snap = snap_result.scalar_one_or_none() + if snap and snap.cost_per_portion: + cost_lookup[rid] = snap.cost_per_portion + + # Match sales to recipes and calculate GP + VAT_RATE = Decimal("1.20") + mapped_items: list[SalesGPItem] = [] + unmapped_items: list[SalesGPItem] = [] + + for sale in sales: + revenue_gross = Decimal(str(sale["total_revenue_gross"])) + revenue_net = (revenue_gross / VAT_RATE).quantize(Decimal("0.01")) + qty = sale["total_qty"] + dbb_qty = sale.get("package_qty", 0) + menu_name = sale["menu_item_name"] + portion = sale["portion_name"] + + # Try exact match first, then name-only match + matched_recipe = recipe_lookup.get((menu_name, portion)) + if not matched_recipe and portion != "Normal": + matched_recipe = recipe_lookup.get((menu_name, None)) + if not matched_recipe and portion == "Normal": + matched_recipe = recipe_lookup.get((menu_name, None)) + + if matched_recipe: + cpp = cost_lookup.get(matched_recipe.id) + total_cost = (cpp * qty).quantize(Decimal("0.01")) if cpp else None + gp_pct = None + if total_cost is not None and revenue_net > 0: + gp_pct = ((revenue_net - total_cost) / revenue_net * 100).quantize(Decimal("0.1")) + + mapped_items.append(SalesGPItem( + menu_item_name=menu_name, + portion_name=portion, + category=sale["category"], + total_qty=qty, + total_revenue_net=revenue_net, + dbb_qty=dbb_qty, + recipe_id=matched_recipe.id, + recipe_name=matched_recipe.name, + dish_course=matched_recipe.menu_section.name if matched_recipe.menu_section else "Uncategorised", + cost_per_portion=cpp, + total_cost=total_cost, + item_gp_percent=gp_pct, + )) + else: + unmapped_items.append(SalesGPItem( + menu_item_name=menu_name, + portion_name=portion, + category=sale["category"], + total_qty=qty, + total_revenue_net=revenue_net, + dbb_qty=dbb_qty, + )) + + # Group mapped items by SambaPOS Kitchen Course category (matches tracked_categories order) + course_groups: dict[str, list[SalesGPItem]] = {} + for item in mapped_items: + course = item.category or "Uncategorised" + if course not in course_groups: + course_groups[course] = [] + course_groups[course].append(item) + + # Sort courses using tracked_categories order from settings, unrecognised courses at the end + category_order = {cat: idx for idx, cat in enumerate(tracked_categories)} + sorted_courses = sorted(course_groups.items(), key=lambda x: (category_order.get(x[0], 999), x[0])) + + courses = [] + for course_name, items in sorted_courses: + c_revenue = sum(i.total_revenue_net for i in items) + c_cost = sum(i.total_cost for i in items if i.total_cost) + c_gp = ((c_revenue - c_cost) / c_revenue * 100).quantize(Decimal("0.1")) if c_revenue > 0 else None + # Sort items by revenue descending + items.sort(key=lambda x: x.total_revenue_net, reverse=True) + courses.append(SalesGPCourseGroup( + course_name=course_name, + items=items, + course_revenue=c_revenue, + course_cost=c_cost, + course_gp_percent=c_gp, + )) + + # Summary totals + mapped_revenue = sum(i.total_revenue_net for i in mapped_items) + mapped_cost = sum(i.total_cost for i in mapped_items if i.total_cost) + unmapped_revenue = sum(i.total_revenue_net for i in unmapped_items) + total_revenue = mapped_revenue + unmapped_revenue + mapped_gp = ((mapped_revenue - mapped_cost) / mapped_revenue * 100).quantize(Decimal("0.1")) if mapped_revenue > 0 else None + coverage_pct = (mapped_revenue / total_revenue * 100).quantize(Decimal("0.1")) if total_revenue > 0 else Decimal("0") + + # Sort unmapped by revenue descending + unmapped_items.sort(key=lambda x: x.total_revenue_net, reverse=True) + + return SalesGPResponse( + from_date=from_date, + to_date=to_date, + courses=courses, + unmapped_items=unmapped_items, + mapped_revenue_net=mapped_revenue, + mapped_total_cost=mapped_cost, + mapped_gp_percent=mapped_gp, + total_all_revenue_net=total_revenue, + unmapped_revenue_net=unmapped_revenue, + mapped_revenue_percent=coverage_pct, + mapped_item_count=len(mapped_items), + unmapped_item_count=len(unmapped_items), + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# THEORETICAL VS ACTUAL USAGE (REVERSE COSTING) +# ══════════════════════════════════════════════════════════════════════════════ + +class UsageVarianceItem(BaseModel): + ingredient_id: int + ingredient_name: str + category: str | None = None + standard_unit: str + # Theoretical (from sales × recipes) + theoretical_qty: float + theoretical_value: float + dishes_using: int + # Actual (from invoices) + actual_qty: float | None = None + actual_value: float | None = None + invoice_count: int = 0 + # Variance + variance_qty: float | None = None + variance_pct: float | None = None + variance_value: float | None = None + +class UnmappedSaleItem(BaseModel): + menu_item_name: str + portion_name: str + total_qty: int + category: str | None = None + +class UsageVarianceResponse(BaseModel): + from_date: date + to_date: date + items: list[UsageVarianceItem] + total_theoretical_value: float + total_actual_value: float + total_variance_value: float + mapped_dish_count: int + unmapped_dish_count: int + ingredients_with_purchases: int + ingredients_without_purchases: int + unmapped_sales: list[UnmappedSaleItem] + + +def _expand_recipe_ingredients( + recipe, + scale: float, + theoretical: dict, + dishes_using: dict, + recipe_name: str, + visited: set | None = None, + depth: int = 0, +): + """ + Recursively expand a recipe's ingredients into the theoretical usage dict. + Max depth of 3 to avoid lazy-load errors on deeply nested sub-recipes. + """ + from api.ingredients import convert_to_standard, UNIT_CONVERSIONS + + if depth > 3: + return + if visited is None: + visited = set() + if recipe.id in visited: + return + visited.add(recipe.id) + + # Direct ingredients — guard against lazy load + try: + ingredients = recipe.ingredients + except Exception: + return + + for ri in ingredients: + ing = ri.ingredient + if not ing or ing.is_archived: + continue + + qty_in_unit = float(ri.quantity) * scale + from_unit = (ri.unit or ing.standard_unit).lower().strip() + to_unit = ing.standard_unit.lower().strip() + + if from_unit == to_unit: + qty_std = qty_in_unit + else: + converted = convert_to_standard(Decimal(str(qty_in_unit)), from_unit, to_unit) + qty_std = float(converted) if converted is not None else qty_in_unit + + yield_pct = float(ri.yield_percent or 100) + if yield_pct > 0 and yield_pct < 100: + qty_raw = qty_std / (yield_pct / 100.0) + else: + qty_raw = qty_std + + theoretical[ing.id] = theoretical.get(ing.id, 0.0) + qty_raw + if ing.id not in dishes_using: + dishes_using[ing.id] = set() + dishes_using[ing.id].add(recipe_name) + + # Sub-recipes — guard against lazy load at deeper levels + try: + sub_recipes = recipe.sub_recipes + except Exception: + return + + for sr in sub_recipes: + child = sr.child_recipe + if not child: + continue + + child_output = child.batch_portions or 1 + child_output_unit = "portion" + if child.batch_output_type == "bulk" and child.batch_yield_qty: + child_output = float(child.batch_yield_qty) + child_output_unit = (child.batch_yield_unit or "portion").lower().strip() + + # Convert portions_needed to child's output unit if units differ + needed = float(sr.portions_needed) + needed_unit = (sr.portions_needed_unit or child_output_unit).lower().strip() + if needed_unit != child_output_unit and child.batch_output_type == "bulk": + converted = convert_to_standard(Decimal(str(needed)), needed_unit, child_output_unit) + if converted is not None: + needed = float(converted) + + sub_scale = needed * scale / child_output + _expand_recipe_ingredients( + child, sub_scale, theoretical, dishes_using, recipe_name, visited.copy(), depth + 1 + ) + + +@router.get("/usage-variance", response_model=UsageVarianceResponse) +async def get_usage_variance( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Theoretical vs Actual Usage report (reverse costing). + + Compares ingredient usage implied by SambaPOS sales + recipes + against actual purchases from Flash invoices for the same period. + """ + from models.settings import KitchenSettings + from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe + from models.ingredient import Ingredient, IngredientSource, IngredientCategory + from models.line_item import LineItem + from services.sambapos_api import SambaPOSClient + from api.ingredients import convert_to_standard, UNIT_CONVERSIONS + from sqlalchemy import distinct, and_, or_ + + if from_date > to_date: + raise HTTPException(status_code=400, detail="from_date must be before or equal to to_date") + + kitchen_id = current_user.kitchen_id + + # ── Settings + SambaPOS config ── + 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=400, detail="Settings not configured") + + if not all([ + settings.sambapos_db_host, settings.sambapos_db_name, + settings.sambapos_db_username, settings.sambapos_db_password, + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + tracked_categories = [ + c.strip() for c in (settings.sambapos_tracked_categories or "").split(",") if c.strip() + ] + if not tracked_categories: + raise HTTPException(status_code=400, detail="No SambaPOS categories configured") + + excluded_items = [ + i.strip() for i in (settings.sambapos_excluded_items or "").split("|") if i.strip() + ] + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password, + ) + + # ── Step 1: Get sales + match to recipes ── + try: + sales = await client.get_sales_breakdown( + from_date, to_date, tracked_categories, + excluded_categories=excluded_items if excluded_items else None, + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"SambaPOS query failed: {str(e)}") + + # Load recipes with full ingredient chain + sub-recipes (2 levels deep) + recipe_result = await db.execute( + select(Recipe) + .options( + selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) + .selectinload(Ingredient.sources), + selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) + .selectinload(Ingredient.category), + selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) + .selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) + .selectinload(Ingredient.sources), + selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) + .selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) + .selectinload(Ingredient.category), + selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) + .selectinload(Recipe.sub_recipes).selectinload(RecipeSubRecipe.child_recipe) + .selectinload(Recipe.ingredients).selectinload(RecipeIngredient.ingredient) + .selectinload(Ingredient.sources), + ) + .where( + Recipe.kitchen_id == kitchen_id, + Recipe.recipe_type == "dish", + Recipe.kds_menu_item_name.isnot(None), + Recipe.kds_menu_item_name != "", + Recipe.is_archived == False, + ) + ) + recipes = recipe_result.scalars().all() + + # Build lookup + recipe_lookup: dict[tuple[str, str | None], object] = {} + for r in recipes: + if r.sambapos_portion_name: + recipe_lookup[(r.kds_menu_item_name, r.sambapos_portion_name)] = r + else: + recipe_lookup[(r.kds_menu_item_name, None)] = r + + # ── Step 2: Explode recipes into theoretical ingredient usage ── + theoretical: dict[int, float] = {} # ingredient_id -> qty in std unit + dishes_using: dict[int, set] = {} # ingredient_id -> set of dish names + ingredient_cache: dict[int, object] = {} # ingredient_id -> Ingredient obj + unmapped_sales: list[UnmappedSaleItem] = [] + mapped_dish_count = 0 + unmapped_dish_count = 0 + + for sale in sales: + menu_name = sale["menu_item_name"] + portion = sale["portion_name"] + qty = sale["total_qty"] + + matched_recipe = recipe_lookup.get((menu_name, portion)) + if not matched_recipe and portion != "Normal": + matched_recipe = recipe_lookup.get((menu_name, None)) + if not matched_recipe and portion == "Normal": + matched_recipe = recipe_lookup.get((menu_name, None)) + + if not matched_recipe: + unmapped_dish_count += 1 + unmapped_sales.append(UnmappedSaleItem( + menu_item_name=menu_name, + portion_name=portion, + total_qty=qty, + category=sale.get("category"), + )) + continue + + mapped_dish_count += 1 + batch_portions = matched_recipe.batch_portions or 1 + scale = qty / batch_portions + + _expand_recipe_ingredients( + matched_recipe, scale, theoretical, dishes_using, matched_recipe.name + ) + + # Cache ingredient objects for later use + for ri in matched_recipe.ingredients: + if ri.ingredient and ri.ingredient.id not in ingredient_cache: + ingredient_cache[ri.ingredient.id] = ri.ingredient + for sr in matched_recipe.sub_recipes: + if sr.child_recipe: + for ri in sr.child_recipe.ingredients: + if ri.ingredient and ri.ingredient.id not in ingredient_cache: + ingredient_cache[ri.ingredient.id] = ri.ingredient + + # ── Step 3: Get actual purchases ── + # Value aggregation (reliable — always works) + purchase_value_query = ( + select( + LineItem.ingredient_id, + func.sum( + case( + (Invoice.document_type == "credit_note", -LineItem.amount), + else_=LineItem.amount, + ) + ).label("total_value"), + func.count(distinct(Invoice.id)).label("invoice_count"), + ) + .join(Invoice, LineItem.invoice_id == Invoice.id) + .where( + Invoice.kitchen_id == kitchen_id, + Invoice.status == InvoiceStatus.CONFIRMED, + Invoice.invoice_date.between(from_date, to_date), + LineItem.ingredient_id.isnot(None), + LineItem.amount.isnot(None), + or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)), + ) + .group_by(LineItem.ingredient_id) + ) + pv_result = await db.execute(purchase_value_query) + purchase_values: dict[int, dict] = {} + for row in pv_result.all(): + purchase_values[row.ingredient_id] = { + "total_value": float(row.total_value or 0), + "invoice_count": row.invoice_count, + } + + # Quantity aggregation (per line item — need pack conversion) + # Fetch raw line items for ingredients we care about + all_ingredient_ids = set(theoretical.keys()) | set(purchase_values.keys()) + purchase_qtys: dict[int, float] = {} + + if all_ingredient_ids: + li_query = ( + select(LineItem, Invoice.document_type) + .join(Invoice, LineItem.invoice_id == Invoice.id) + .where( + Invoice.kitchen_id == kitchen_id, + Invoice.status == InvoiceStatus.CONFIRMED, + Invoice.invoice_date.between(from_date, to_date), + LineItem.ingredient_id.in_(all_ingredient_ids), + LineItem.quantity.isnot(None), + or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None)), + ) + ) + li_result = await db.execute(li_query) + + # We need ingredient standard_units — load any missing from DB + missing_ids = all_ingredient_ids - set(ingredient_cache.keys()) + if missing_ids: + ing_result = await db.execute( + select(Ingredient) + .options(selectinload(Ingredient.category), selectinload(Ingredient.sources)) + .where(Ingredient.id.in_(missing_ids)) + ) + for ing in ing_result.scalars().all(): + ingredient_cache[ing.id] = ing + + for row in li_result.all(): + li = row[0] # LineItem + doc_type = row[1] # document_type + ing_id = li.ingredient_id + ing = ingredient_cache.get(ing_id) + if not ing: + continue + + std_unit = ing.standard_unit.lower().strip() + qty = float(li.quantity) + std_qty = None + + # Try pack conversion: quantity × pack_quantity × unit_size → convert + if li.pack_quantity and li.unit_size and li.unit_size_type: + total_source = qty * li.pack_quantity * float(li.unit_size) + converted = convert_to_standard( + Decimal(str(total_source)), li.unit_size_type, std_unit + ) + if converted is not None: + std_qty = float(converted) + # Fallback: if line item unit matches a known unit + elif li.unit and li.unit.lower().strip() in UNIT_CONVERSIONS: + converted = convert_to_standard( + Decimal(str(qty)), li.unit.lower().strip(), std_unit + ) + if converted is not None: + std_qty = float(converted) + + if std_qty is not None: + if doc_type == "credit_note": + std_qty = -std_qty + purchase_qtys[ing_id] = purchase_qtys.get(ing_id, 0.0) + std_qty + + # ── Step 4: Build comparison ── + items: list[UsageVarianceItem] = [] + total_theoretical_value = 0.0 + total_actual_value = 0.0 + ingredients_with_purchases = 0 + ingredients_without_purchases = 0 + + for ing_id in all_ingredient_ids: + ing = ingredient_cache.get(ing_id) + if not ing: + continue + + cat_name = ing.category.name if ing.category else None + std_unit = ing.standard_unit + + # Theoretical + theo_qty = theoretical.get(ing_id, 0.0) + dish_count = len(dishes_using.get(ing_id, set())) + + # Get best price per standard unit for theoretical value calc + price_per_std = None + if ing.sources: + # Use most recent source price + priced_sources = [ + s for s in ing.sources if s.price_per_std_unit and s.price_per_std_unit > 0 + ] + if priced_sources: + priced_sources.sort(key=lambda s: s.latest_invoice_date or date.min, reverse=True) + price_per_std = float(priced_sources[0].price_per_std_unit) + if price_per_std is None and ing.manual_price: + price_per_std = float(ing.manual_price) + + theo_value = theo_qty * price_per_std if price_per_std else 0.0 + total_theoretical_value += theo_value + + # Actual + pv = purchase_values.get(ing_id) + actual_value = pv["total_value"] if pv else None + invoice_count = pv["invoice_count"] if pv else 0 + actual_qty = purchase_qtys.get(ing_id) + + if actual_value is not None: + total_actual_value += actual_value + ingredients_with_purchases += 1 + elif theo_qty > 0: + ingredients_without_purchases += 1 + + # Variance + variance_qty = None + variance_pct = None + variance_value = None + + if actual_qty is not None and theo_qty > 0: + variance_qty = actual_qty - theo_qty + variance_pct = (variance_qty / theo_qty) * 100.0 + + if actual_value is not None and theo_value > 0: + variance_value = actual_value - theo_value + elif actual_value is not None and theo_qty == 0: + # Purchased but no theoretical usage (not in any recipe) + variance_value = actual_value + + items.append(UsageVarianceItem( + ingredient_id=ing_id, + ingredient_name=ing.name, + category=cat_name, + standard_unit=std_unit, + theoretical_qty=round(theo_qty, 2), + theoretical_value=round(theo_value, 2), + dishes_using=dish_count, + actual_qty=round(actual_qty, 2) if actual_qty is not None else None, + actual_value=round(actual_value, 2) if actual_value is not None else None, + invoice_count=invoice_count, + variance_qty=round(variance_qty, 2) if variance_qty is not None else None, + variance_pct=round(variance_pct, 1) if variance_pct is not None else None, + variance_value=round(variance_value, 2) if variance_value is not None else None, + )) + + # Sort by absolute variance value descending (biggest £ problems first) + items.sort(key=lambda x: abs(x.variance_value or 0), reverse=True) + + total_variance = total_actual_value - total_theoretical_value + + return UsageVarianceResponse( + from_date=from_date, + to_date=to_date, + items=items, + total_theoretical_value=round(total_theoretical_value, 2), + total_actual_value=round(total_actual_value, 2), + total_variance_value=round(total_variance, 2), + mapped_dish_count=mapped_dish_count, + unmapped_dish_count=unmapped_dish_count, + ingredients_with_purchases=ingredients_with_purchases, + ingredients_without_purchases=ingredients_without_purchases, + unmapped_sales=unmapped_sales, + ) diff --git a/backend/api/residents_table_chart.py b/backend/api/residents_table_chart.py new file mode 100644 index 0000000..feceef4 --- /dev/null +++ b/backend/api/residents_table_chart.py @@ -0,0 +1,422 @@ +""" +Residents Table Chart API + +Gantt-style visualization showing hotel bookings with restaurant table indicators. +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +from datetime import date, timedelta +from typing import Optional +from pydantic import BaseModel + +from auth import get_current_user, require_cap +from database import get_db +from models.user import User +from models.newbook import NewbookDailyOccupancy +from models.resos import ResosBooking + +router = APIRouter(prefix="/residents-table-chart", tags=["Residents Table Chart"]) + + +class RestaurantBookingDetail(BaseModel): + has_booking: bool + time: Optional[str] = None + people: Optional[int] = None + table_name: Optional[str] = None + opening_hour_name: Optional[str] = None + is_group_match: Optional[bool] = None # True if matched via group/exclude field (not the lead booking) + + +class BookingSegment(BaseModel): + booking_id: str | None + bookings_group_id: Optional[str] = None + check_in: str + check_out: str + nights: list[str] + is_dbb: Optional[bool] = None + is_package: Optional[bool] = None + restaurant_bookings: dict[str, RestaurantBookingDetail] + + +class RoomRow(BaseModel): + room_number: str | None + bookings: list[BookingSegment] # Multiple bookings in the same room + + +class ResidentsTableChartResponse(BaseModel): + date_range: dict + rooms: list[RoomRow] # Changed from 'bookings' to 'rooms' + summary: dict + metrics: Optional[dict] = None # Aggregated metrics for different time periods + + +@router.get("") +async def get_residents_table_chart( + start_date: Optional[date] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResidentsTableChartResponse: + """ + Get Gantt-style chart data showing hotel bookings with restaurant table indicators. + + Args: + start_date: First day of 7-day period (defaults to today) + + Returns: + Chart data with hotel stays and restaurant booking indicators + """ + import logging + logger = logging.getLogger(__name__) + + if start_date is None: + start_date = date.today() + + logger.info(f"ResidentsTableChart API called with start_date={start_date}") + + end_date = start_date + timedelta(days=6) # 7-day period + date_range = { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "dates": [(start_date + timedelta(days=i)).isoformat() for i in range(7)] + } + + # Fetch Newbook occupancy data for 7-day period + result = await db.execute( + select(NewbookDailyOccupancy).where( + and_( + NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, + NewbookDailyOccupancy.date >= start_date, + NewbookDailyOccupancy.date <= end_date, + NewbookDailyOccupancy.rooms_breakdown.isnot(None) # Only records with room breakdown + ) + ).order_by(NewbookDailyOccupancy.date) + ) + occupancy_records = result.scalars().all() + logger.info(f"Found {len(occupancy_records)} occupancy records") + + # Group by room number only (one row per room in Gantt chart) + # Key: room_number, Value: dict of bookings for that room + rooms_dict = {} + for record in occupancy_records: + # Parse JSONB array - each element is a room object for this date + rooms = record.rooms_breakdown or [] + + for room in rooms: + room_number = room.get("room_number") + booking_id = room.get("booking_id") + + if room_number not in rooms_dict: + rooms_dict[room_number] = {} + + # Track each booking within this room + if booking_id not in rooms_dict[room_number]: + rooms_dict[room_number][booking_id] = { + 'booking_id': booking_id, + 'bookings_group_id': room.get("bookings_group_id"), + 'nights': [], + 'is_dbb': room.get("is_dbb", False), + 'is_package': room.get("is_package", False) + } + + rooms_dict[room_number][booking_id]['nights'].append(record.date) + + # Log rooms with multiple bookings to diagnose stacking issue + for room_number, bookings in rooms_dict.items(): + if len(bookings) > 1: + logger.warning(f"Room {room_number} has {len(bookings)} different bookings:") + for booking_id, booking_data in bookings.items(): + nights_str = ', '.join(sorted([n.isoformat() for n in booking_data['nights']])) + logger.warning(f" - Booking {booking_id}: nights={nights_str}") + + # Convert to list - one entry per room with all its bookings + hotel_stays = [] + for room_number, bookings in rooms_dict.items(): + # Collect all bookings for this room + room_bookings = [] + all_nights = [] + + for booking_data in bookings.values(): + nights = sorted(booking_data['nights']) + if nights: + all_nights.extend(nights) + check_in = nights[0] + check_out = nights[-1] + timedelta(days=1) + + room_bookings.append({ + 'booking_id': booking_data['booking_id'], + 'bookings_group_id': booking_data.get('bookings_group_id'), + 'check_in': check_in.isoformat(), + 'check_out': check_out.isoformat(), + 'nights': [n.isoformat() for n in nights], + 'is_dbb': booking_data['is_dbb'], + 'is_package': booking_data['is_package'] + }) + + # Create one entry per room with all bookings + if room_bookings: + all_nights_sorted = sorted(set(all_nights)) + hotel_stays.append({ + 'room_number': room_number, + 'bookings': room_bookings, # Array of all bookings in this room + 'all_nights': [n.isoformat() for n in all_nights_sorted] # All occupied nights for this room + }) + + logger.info(f"Built {len(hotel_stays)} room entries") + + # Fetch Resos bookings for hotel guests in this period + result = await db.execute( + select(ResosBooking).where( + and_( + ResosBooking.kitchen_id == current_user.kitchen_id, + ResosBooking.booking_date >= start_date, + ResosBooking.booking_date <= end_date, + ResosBooking.is_hotel_guest == True, + ResosBooking.hotel_booking_number.isnot(None) + ) + ) + ) + resos_bookings = result.scalars().all() + + # Build lookup: booking_id -> {date -> resos_booking} + # Also handle group bookings via exclude_flag field (format: "#32990,#32991") + resos_lookup = {} + import re + + for resos_booking in resos_bookings: + booking_id = resos_booking.hotel_booking_number + booking_date = resos_booking.booking_date.isoformat() + + if booking_id not in resos_lookup: + resos_lookup[booking_id] = {} + + # Direct match for the lead/primary booking + resos_lookup[booking_id][booking_date] = { + 'has_booking': True, + 'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None, + 'people': resos_booking.people, + 'table_name': resos_booking.table_name, + 'opening_hour_name': resos_booking.opening_hour_name, + 'is_group_match': False # Direct match, not a group member + } + + # Parse exclude_flag for group bookings (format: "#32990,#32991") + if resos_booking.exclude_flag: + # Extract all booking numbers from the exclude_flag field + group_booking_ids = re.findall(r'#(\d+)', resos_booking.exclude_flag) + + for group_id in group_booking_ids: + # Skip the lead booking itself (already added above) + if group_id == booking_id: + continue + + # Add group member with is_group_match=True + if group_id not in resos_lookup: + resos_lookup[group_id] = {} + + # Only add if not already present (don't overwrite direct matches) + if booking_date not in resos_lookup[group_id]: + resos_lookup[group_id][booking_date] = { + 'has_booking': True, + 'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None, + 'people': resos_booking.people, + 'table_name': resos_booking.table_name, + 'opening_hour_name': resos_booking.opening_hour_name, + 'is_group_match': True # Matched via group, not direct + } + + logger.info(f"Built resos_lookup with {len(resos_lookup)} booking IDs (including group matches)") + + # Combine rooms with restaurant bookings + room_rows = [] + total_room_nights = 0 + nights_with_restaurant = 0 + + try: + for room_data in hotel_stays: + booking_segments = [] + + # Process each booking within this room + for booking_data in room_data['bookings']: + # Build restaurant bookings dict for each night in the 7-day period + restaurant_bookings = {} + + for date_str in date_range['dates']: + # Check if this date is within this specific booking's nights + if date_str in booking_data['nights']: + total_room_nights += 1 + + # Check if there's a restaurant booking for this date + resos_data = resos_lookup.get(booking_data['booking_id'], {}).get(date_str) + + if resos_data: + restaurant_bookings[date_str] = resos_data + nights_with_restaurant += 1 + else: + restaurant_bookings[date_str] = {'has_booking': False} + else: + # Not staying this night + restaurant_bookings[date_str] = {'has_booking': False} + + # Create booking segment with restaurant data + booking_segments.append(BookingSegment( + booking_id=booking_data['booking_id'], + bookings_group_id=booking_data.get('bookings_group_id'), + check_in=booking_data['check_in'], + check_out=booking_data['check_out'], + nights=booking_data['nights'], + is_dbb=booking_data['is_dbb'], + is_package=booking_data['is_package'], + restaurant_bookings=restaurant_bookings + )) + + # Create room row with all its bookings + room_rows.append(RoomRow( + room_number=room_data['room_number'], + bookings=booking_segments + )) + except Exception as e: + logger.error(f"Error building room_rows: {e}", exc_info=True) + raise + + logger.info(f"Built {len(room_rows)} room rows") + + # Sort rooms by room number (natural sort for numeric rooms) + def natural_sort_key(room: RoomRow): + """Natural sort key for room numbers (handles both numeric and alphanumeric)""" + if not room.room_number: + return (float('inf'), '') # Put None/empty at end + + # Extract numeric part for sorting (e.g., "102" -> 102, "A-12" -> 12) + import re + numbers = re.findall(r'\d+', room.room_number) + if numbers: + return (int(numbers[0]), room.room_number) + return (float('inf'), room.room_number) + + room_rows.sort(key=natural_sort_key) + + # Calculate summary + coverage_pct = (nights_with_restaurant / total_room_nights * 100) if total_room_nights > 0 else 0.0 + + # Count total bookings across all rooms + total_bookings = sum(len(room.bookings) for room in room_rows) + + summary = { + 'total_rooms': len(room_rows), + 'total_bookings': total_bookings, + 'total_room_nights': total_room_nights, + 'nights_with_restaurant': nights_with_restaurant, + 'coverage_percentage': round(coverage_pct, 1) + } + + # Calculate aggregated metrics for different time periods + def get_week_start(d: date) -> date: + """Get Monday of the week containing date d""" + return d - timedelta(days=d.weekday()) + + async def calculate_period_metrics(period_start: date, period_end: date, is_forecast: Optional[bool] = None) -> dict: + """Calculate metrics for a specific date range""" + query = select(NewbookDailyOccupancy).where( + and_( + NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, + NewbookDailyOccupancy.date >= period_start, + NewbookDailyOccupancy.date <= period_end, + NewbookDailyOccupancy.rooms_breakdown.isnot(None) + ) + ) + + # Filter by forecast status if specified + if is_forecast is not None: + query = query.where(NewbookDailyOccupancy.is_forecast == is_forecast) + + result = await db.execute(query.order_by(NewbookDailyOccupancy.date)) + records = result.scalars().all() + + # Count metrics + total_room_nights_period = 0 + unique_bookings = set() + nights_with_rest = 0 + + for record in records: + rooms = record.rooms_breakdown or [] + for room in rooms: + booking_id = room.get("booking_id") + if booking_id: + unique_bookings.add(booking_id) + total_room_nights_period += 1 + + # Check if has restaurant booking for this date + date_str = record.date.isoformat() + resos_data = resos_lookup.get(booking_id, {}).get(date_str) + if resos_data: + nights_with_rest += 1 + + coverage_pct_period = (nights_with_rest / total_room_nights_period * 100) if total_room_nights_period > 0 else 0.0 + + # Calculate average occupancy + total_available = 0 + total_occupied = 0 + for record in records: + if record.total_rooms and record.occupied_rooms: + total_available += record.total_rooms + total_occupied += record.occupied_rooms + + avg_occupancy = (total_occupied / total_available * 100) if total_available > 0 else 0.0 + + return { + 'total_bookings': len(unique_bookings), + 'total_room_nights': total_room_nights_period, + 'nights_with_restaurant': nights_with_rest, + 'coverage_percentage': round(coverage_pct_period, 1), + 'avg_occupancy_percentage': round(avg_occupancy, 1) + } + + today = date.today() + + # This week (Monday to Sunday) + this_week_start = get_week_start(today) + this_week_end = this_week_start + timedelta(days=6) + + # Last week (previous Monday to Sunday) + last_week_start = this_week_start - timedelta(days=7) + last_week_end = last_week_start + timedelta(days=6) + + # Last 30 days rolling (from yesterday) + yesterday = today - timedelta(days=1) + rolling_30_start = yesterday - timedelta(days=29) + rolling_30_end = yesterday + + # Calculate metrics for each period - always return metrics with default values + default_metrics = { + 'total_bookings': 0, + 'total_room_nights': 0, + 'nights_with_restaurant': 0, + 'coverage_percentage': 0.0, + 'avg_occupancy_percentage': 0.0 + } + + try: + metrics = { + 'this_week_actual': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=False), + 'this_week_forecast': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=True), + 'last_week_actual': await calculate_period_metrics(last_week_start, last_week_end, is_forecast=False), + 'last_30_days_rolling': await calculate_period_metrics(rolling_30_start, rolling_30_end, is_forecast=False), + } + logger.info(f"Calculated metrics: {metrics}") + except Exception as e: + logger.error(f"Error calculating metrics: {e}", exc_info=True) + # Return default metrics structure instead of None + metrics = { + 'this_week_actual': default_metrics.copy(), + 'this_week_forecast': default_metrics.copy(), + 'last_week_actual': default_metrics.copy(), + 'last_30_days_rolling': default_metrics.copy(), + } + + return ResidentsTableChartResponse( + date_range=date_range, + rooms=room_rows, + summary=summary, + metrics=metrics + ) diff --git a/backend/api/resos.py b/backend/api/resos.py new file mode 100644 index 0000000..9d9a9b2 --- /dev/null +++ b/backend/api/resos.py @@ -0,0 +1,809 @@ +""" +Resos API Endpoints + +Handles Resos configuration, sync operations, and booking data retrieval. +""" +import logging +from datetime import date, datetime, timedelta +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, func, Date, case, or_ +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +from database import get_db +from models.user import User +from models.settings import KitchenSettings +from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog +from auth import get_current_user, require_cap +from services.resos_sync import ResosSyncService +from services.resos_api import ResosAPIClient, ResosAPIError + +router = APIRouter() + + +# ============ Pydantic Schemas ============ + +class ResosSettingsResponse(BaseModel): + resos_api_key_set: bool # Masked + resos_last_sync: datetime | None + resos_auto_sync_enabled: bool + resos_upcoming_sync_enabled: bool + resos_upcoming_sync_interval: int + resos_last_upcoming_sync: datetime | None + resos_large_group_threshold: int + resos_note_keywords: str | None + resos_allergy_keywords: str | None + resos_custom_field_mapping: dict | None + resos_opening_hours_mapping: list | None + resos_restaurant_table_entities: str | None + resos_enable_manual_breakfast: bool + resos_manual_breakfast_periods: list | None + resos_flag_icon_mapping: dict | None + resos_arrival_widget_service_filter: str | None # Service type: breakfast/lunch/dinner/other + sambapos_food_gl_codes: str | None # Phase 8.1 + sambapos_beverage_gl_codes: str | None # Phase 8.1 + + class Config: + from_attributes = True + + +class ResosSettingsUpdate(BaseModel): + resos_api_key: str | None = None + resos_auto_sync_enabled: bool | None = None + resos_upcoming_sync_enabled: bool | None = None + resos_upcoming_sync_interval: int | None = None + resos_large_group_threshold: int | None = None + resos_note_keywords: str | None = None + resos_allergy_keywords: str | None = None + resos_custom_field_mapping: dict | None = None + resos_opening_hours_mapping: list | None = None + resos_restaurant_table_entities: str | None = None + resos_enable_manual_breakfast: bool | None = None + resos_manual_breakfast_periods: list | None = None + resos_flag_icon_mapping: dict | None = None + resos_arrival_widget_service_filter: str | None = None # Opening hour ID for arrivals widget filter + sambapos_food_gl_codes: str | None = None # Phase 8.1 + sambapos_beverage_gl_codes: str | None = None # Phase 8.1 + + +class DailyStatsResponse(BaseModel): + date: str + total_bookings: int + total_covers: int + service_breakdown: list[dict] + flagged_booking_count: int + unique_flag_types: list[str] | None + is_forecast: bool + + class Config: + from_attributes = True + + +class BookingResponse(BaseModel): + id: int + resos_booking_id: str + booking_date: str + booking_time: str + people: int + status: str + seating_area: str | None + hotel_booking_number: str | None + is_hotel_guest: bool | None + is_dbb: bool | None + is_package: bool | None + allergies: str | None + notes: str | None + opening_hour_name: str | None + is_flagged: bool + flag_reasons: str | None + + class Config: + from_attributes = True + + +class DashboardCoversResponse(BaseModel): + date: str + total_bookings: int + total_covers: int + service_breakdown: list[dict] + has_flagged_bookings: bool + unique_flag_types: list[str] + + class Config: + from_attributes = True + + +# ============ Settings Endpoints ============ + +@router.get("/settings") +async def get_resos_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResosSettingsResponse: + """Get Resos settings""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one() + + logger.info(f"[Resos GET] Returning upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") + + return ResosSettingsResponse( + resos_api_key_set=bool(settings.resos_api_key), + resos_last_sync=settings.resos_last_sync, + resos_auto_sync_enabled=settings.resos_auto_sync_enabled or False, + resos_upcoming_sync_enabled=settings.resos_upcoming_sync_enabled or False, + resos_upcoming_sync_interval=settings.resos_upcoming_sync_interval or 15, + resos_last_upcoming_sync=settings.resos_last_upcoming_sync, + resos_large_group_threshold=settings.resos_large_group_threshold or 8, + resos_note_keywords=settings.resos_note_keywords, + resos_allergy_keywords=settings.resos_allergy_keywords, + resos_custom_field_mapping=settings.resos_custom_field_mapping, + resos_opening_hours_mapping=settings.resos_opening_hours_mapping, + resos_restaurant_table_entities=settings.resos_restaurant_table_entities, + resos_enable_manual_breakfast=settings.resos_enable_manual_breakfast or False, + resos_manual_breakfast_periods=settings.resos_manual_breakfast_periods, + resos_flag_icon_mapping=settings.resos_flag_icon_mapping, + resos_arrival_widget_service_filter=settings.resos_arrival_widget_service_filter, + sambapos_food_gl_codes=settings.sambapos_food_gl_codes, # Phase 8.1 + sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes # Phase 8.1 + ) + + +@router.patch("/settings") +async def update_resos_settings( + update: ResosSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update Resos settings""" + logger.info(f"[Resos PATCH] Received update: {update.model_dump(exclude_unset=True)}") + + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one() + + logger.info(f"[Resos PATCH] Before update - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") + + if update.resos_api_key is not None: + settings.resos_api_key = update.resos_api_key + if update.resos_auto_sync_enabled is not None: + settings.resos_auto_sync_enabled = update.resos_auto_sync_enabled + if update.resos_upcoming_sync_enabled is not None: + logger.info(f"[Resos PATCH] Setting upcoming_sync_enabled to {update.resos_upcoming_sync_enabled}") + settings.resos_upcoming_sync_enabled = update.resos_upcoming_sync_enabled + if update.resos_upcoming_sync_interval is not None: + settings.resos_upcoming_sync_interval = update.resos_upcoming_sync_interval + if update.resos_large_group_threshold is not None: + settings.resos_large_group_threshold = update.resos_large_group_threshold + if update.resos_note_keywords is not None: + settings.resos_note_keywords = update.resos_note_keywords + if update.resos_allergy_keywords is not None: + settings.resos_allergy_keywords = update.resos_allergy_keywords + if update.resos_custom_field_mapping is not None: + settings.resos_custom_field_mapping = update.resos_custom_field_mapping + if update.resos_opening_hours_mapping is not None: + settings.resos_opening_hours_mapping = update.resos_opening_hours_mapping + if update.resos_restaurant_table_entities is not None: + settings.resos_restaurant_table_entities = update.resos_restaurant_table_entities + if update.resos_enable_manual_breakfast is not None: + settings.resos_enable_manual_breakfast = update.resos_enable_manual_breakfast + if update.resos_manual_breakfast_periods is not None: + settings.resos_manual_breakfast_periods = update.resos_manual_breakfast_periods + if update.resos_flag_icon_mapping is not None: + settings.resos_flag_icon_mapping = update.resos_flag_icon_mapping + if update.resos_arrival_widget_service_filter is not None: + settings.resos_arrival_widget_service_filter = update.resos_arrival_widget_service_filter + # Phase 8.1: GL codes for food/beverage spend split + if update.sambapos_food_gl_codes is not None: + settings.sambapos_food_gl_codes = update.sambapos_food_gl_codes + if update.sambapos_beverage_gl_codes is not None: + settings.sambapos_beverage_gl_codes = update.sambapos_beverage_gl_codes + + await db.commit() + logger.info(f"[Resos PATCH] After commit - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}") + return {"message": "Settings updated successfully"} + + +@router.post("/test-connection") +async def test_resos_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test Resos API connection""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one() + + if not settings.resos_api_key: + raise HTTPException(status_code=400, detail="Resos API key not configured") + + async with ResosAPIClient(settings.resos_api_key) as client: + success = await client.test_connection() + + if not success: + raise HTTPException(status_code=400, detail="Connection failed") + + return {"message": "Connection successful"} + + +@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 settings directly""" + from sqlalchemy import text + + # Get value via ORM + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one() + orm_value = settings.resos_upcoming_sync_enabled + + # Get value via raw SQL + raw_result = await db.execute( + text("SELECT resos_upcoming_sync_enabled, resos_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"), + {"kid": current_user.kitchen_id} + ) + raw_row = raw_result.fetchone() + + return { + "orm_value": orm_value, + "raw_db_enabled": raw_row[0] if raw_row else None, + "raw_db_interval": raw_row[1] if raw_row else None, + "column_exists": raw_row is not None + } + + +@router.get("/custom-fields") +async def fetch_custom_fields( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch custom field definitions from Resos API (GET request only)""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one() + + if not settings.resos_api_key: + raise HTTPException(status_code=400, detail="Resos API key not configured") + + async with ResosAPIClient(settings.resos_api_key) as client: + fields = await client.get_custom_field_definitions() + + return {"custom_fields": fields} + + +@router.get("/opening-hours") +async def fetch_opening_hours( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch opening hours/service periods from Resos API (GET request only)""" + import logging + logger = logging.getLogger(__name__) + + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one() + + if not settings.resos_api_key: + raise HTTPException(status_code=400, detail="Resos API key not configured") + + async with ResosAPIClient(settings.resos_api_key) as client: + hours = await client.get_opening_hours() + + # Log the raw response to understand structure + logger.info(f"Raw opening hours from Resos API: {len(hours)} periods") + + # Filter out special/one-off periods - only return regular service periods + # Filter on 'special' field: True = one-off events, False = recurring service periods + regular_hours = [h for h in hours if h.get('special') == False] + + # Day of week mapping (Resos uses 1=Monday, 7=Sunday) + day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + + # Transform time format: Resos uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00) + # Convert to 'startTime' and 'endTime' in HH:MM format for frontend + for hour in regular_hours: + # Add day of week name + day_num = hour.get('day', 0) + if 1 <= day_num <= 7: + hour['dayName'] = day_names[day_num] + else: + hour['dayName'] = 'Unknown' + + if 'open' in hour: + open_val = hour['open'] + hours_part = open_val // 100 + mins_part = open_val % 100 + hour['startTime'] = f"{hours_part:02d}:{mins_part:02d}" + + if 'close' in hour: + close_val = hour['close'] + hours_part = close_val // 100 + mins_part = close_val % 100 + hour['endTime'] = f"{hours_part:02d}:{mins_part:02d}" + + # Auto-calculate actual end time by subtracting booking duration + # Resos extends close time to allow late bookings + seating = hour.get('seating', {}) + duration = seating.get('duration', 0) # Duration in minutes + if duration > 0: + # Convert close time to minutes + close_minutes = hours_part * 60 + mins_part + # Subtract booking duration + actual_end_minutes = close_minutes - duration + # Convert back to HH:MM + actual_hours = actual_end_minutes // 60 + actual_mins = actual_end_minutes % 60 + hour['actualEnd'] = f"{actual_hours:02d}:{actual_mins:02d}" + hour['bookingDuration'] = duration + + # Sort by day of week first, then by open time within each day + regular_hours.sort(key=lambda h: (h.get('day', 0), h.get('open', 0))) + + logger.info(f"After filtering: {len(regular_hours)} regular periods (filtered out {len(hours) - len(regular_hours)} special periods)") + + return {"opening_hours": regular_hours} + + +@router.get("/opening-hours/{date}") +async def get_opening_hours_for_date( + date: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get opening hours configuration for a specific date + + Returns opening hour periods with their times and intervals. + Used by Gantt chart to determine time range and closed periods. + """ + import logging + logger = logging.getLogger(__name__) + + # Parse date + try: + query_date = datetime.fromisoformat(date).date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format") + + # Get kitchen 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.resos_api_key: + logger.warning("No Resos API key configured") + return [] + + # Fetch opening hours from Resos API + async with ResosAPIClient(settings.resos_api_key) as client: + hours = await client.get_opening_hours() + + # Filter to regular (non-special) hours and format for Gantt chart + day_of_week = query_date.isoweekday() # Monday=1, Sunday=7 + + formatted_hours = [] + for hour in hours: + # Skip special/one-off periods + if hour.get('special') == True: + continue + + # Check if this opening hour applies to the query date's day of week + hour_day = hour.get('day', 0) + if hour_day != day_of_week: + continue + + # Get open and close times (already in HHMM format from API) + open_time = hour.get('open', 0) + close_time = hour.get('close', 0) + + # Find service type from mapping + resos_id = hour.get('id', '') + service_type = None + if settings.resos_opening_hours_mapping: + for mapping in settings.resos_opening_hours_mapping: + if isinstance(mapping, dict) and mapping.get('resos_id') == resos_id: + service_type = mapping.get('service_type', '') + break + + formatted_hours.append({ + "name": hour.get('name', ''), + "service_type": service_type or hour.get('name', ''), + "open": open_time, + "close": close_time, + "is_special": False + }) + + logger.info(f"Found {len(formatted_hours)} opening hours for {query_date} (day {day_of_week})") + return formatted_hours + + +# ============ Sync Endpoints ============ + +@router.post("/sync/upcoming") +async def sync_upcoming( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Manual upcoming sync (next 7 days) - triggers immediately and updates last_upcoming_sync timestamp""" + sync_service = ResosSyncService(current_user.kitchen_id, db) + result = await sync_service.run_upcoming_sync() + logger.info(f"[Resos] Manual upcoming sync completed for kitchen {current_user.kitchen_id}") + return result + + +@router.post("/sync/forecast") +async def sync_forecast( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Manual forecast sync (next 60 days)""" + sync_service = ResosSyncService(current_user.kitchen_id, db) + + today = date.today() + to_date = today + timedelta(days=60) + + result = await sync_service.sync_bookings(today, to_date, is_forecast=True) + return result + + +@router.post("/sync/historical") +async def sync_historical( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Manual historical sync""" + sync_service = ResosSyncService(current_user.kitchen_id, db) + result = await sync_service.sync_bookings(from_date, to_date, is_forecast=False) + return result + + +@router.post("/sync/opening-hours") +async def sync_opening_hours( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Sync opening hours from Resos API to local database""" + import logging + logger = logging.getLogger(__name__) + + logger.info(f"Starting opening hours sync for kitchen {current_user.kitchen_id}") + + sync_service = ResosSyncService(current_user.kitchen_id, db) + count = await sync_service.sync_opening_hours() + + logger.info(f"Successfully synced {count} opening hours to database") + + return { + "message": f"Successfully synced {count} opening hours", + "count": count + } + + +# ============ Data Retrieval Endpoints ============ + +@router.get("/daily-stats") +async def get_daily_stats( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> list[DailyStatsResponse]: + """Get daily stats for date range""" + result = await db.execute( + select(ResosDailyStats).where( + and_( + ResosDailyStats.kitchen_id == current_user.kitchen_id, + ResosDailyStats.date >= from_date, + ResosDailyStats.date <= to_date + ) + ).order_by(ResosDailyStats.date) + ) + + stats = result.scalars().all() + + return [ + DailyStatsResponse( + date=stat.date.isoformat(), + total_bookings=stat.total_bookings, + total_covers=stat.total_covers, + service_breakdown=stat.service_breakdown or [], + flagged_booking_count=stat.flagged_booking_count, + unique_flag_types=stat.unique_flag_types, + is_forecast=stat.is_forecast + ) + for stat in stats + ] + + +@router.get("/bookings/{booking_date}") +async def get_bookings_for_date( + booking_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> list[BookingResponse]: + """Get all bookings for a specific date, excluding cancelled/deleted""" + excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'] + result = await db.execute( + select(ResosBooking).where( + and_( + ResosBooking.kitchen_id == current_user.kitchen_id, + ResosBooking.booking_date == booking_date, + ~func.lower(ResosBooking.status).in_(excluded_statuses) + ) + ).order_by(ResosBooking.booking_time) + ) + + bookings = result.scalars().all() + + return [ + BookingResponse( + id=b.id, + resos_booking_id=b.resos_booking_id, + booking_date=b.booking_date.isoformat(), + booking_time=b.booking_time.isoformat(), + people=b.people, + status=b.status, + seating_area=b.seating_area, + hotel_booking_number=b.hotel_booking_number, + is_hotel_guest=b.is_hotel_guest, + is_dbb=b.is_dbb, + is_package=b.is_package, + allergies=b.allergies, + notes=b.notes, + opening_hour_name=b.opening_hour_name, + is_flagged=b.is_flagged, + flag_reasons=b.flag_reasons + ) + for b in bookings + ] + + +# ============ Dashboard Endpoint ============ + +@router.get("/dashboard/today-tomorrow") +async def get_dashboard_covers( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> dict: + """Get today, tomorrow, and day after tomorrow covers for dashboard""" + import logging + logger = logging.getLogger(__name__) + + today = date.today() + tomorrow = today + timedelta(days=1) + day_after = today + timedelta(days=2) + + # Day names for display + day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] + + result = await db.execute( + select(ResosDailyStats).where( + and_( + ResosDailyStats.kitchen_id == current_user.kitchen_id, + ResosDailyStats.date.in_([today, tomorrow, day_after]) + ) + ) + ) + + stats = {stat.date: stat for stat in result.scalars().all()} + + def build_response(target_date: date, day_label: str) -> Optional[dict]: + if target_date not in stats: + return { + 'date': target_date.isoformat(), + 'day_label': day_label, + 'total_bookings': 0, + 'total_covers': 0, + 'service_breakdown': [], + 'has_flagged_bookings': False, + 'unique_flag_types': [] + } + stat = stats[target_date] + logger.info(f"Date {target_date}: unique_flag_types={stat.unique_flag_types}, type={type(stat.unique_flag_types)}, flagged_count={stat.flagged_booking_count}") + return { + 'date': stat.date.isoformat(), + 'day_label': day_label, + 'total_bookings': stat.total_bookings, + 'total_covers': stat.total_covers, + 'service_breakdown': stat.service_breakdown or [], + 'has_flagged_bookings': stat.flagged_booking_count > 0, + 'unique_flag_types': stat.unique_flag_types or [] + } + + return { + 'today': build_response(today, 'Today'), + 'tomorrow': build_response(tomorrow, 'Tomorrow'), + 'day_after': build_response(day_after, day_names[day_after.weekday()]) + } + + +# ============ Stats Report Endpoint (Phase 8) ============ + +@router.get("/stats") +async def get_bookings_stats( + from_date: date, + to_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> dict: + """ + Get comprehensive booking statistics with spend analysis (Phase 8). + + Calculates: + - Total bookings and covers + - Average lead time (days between booking and reservation) + - Spend analysis (food/beverage/total) matched from SambaPOS + - Resident vs non-resident split (by covers and spend) + - Daily breakdown + - Service period breakdown with avg spend per cover + """ + from services.resos_stats import ResosStatsService + + # Get summary metrics from resos_bookings + result = await db.execute( + select( + func.count(ResosBooking.id).label('total_bookings'), + func.sum(ResosBooking.people).label('total_covers'), + func.avg( + ResosBooking.booking_date - func.cast(ResosBooking.booked_at, Date) + ).label('avg_lead_time_days') + ).where( + and_( + ResosBooking.kitchen_id == current_user.kitchen_id, + ResosBooking.booking_date >= from_date, + ResosBooking.booking_date <= to_date, + func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved']) + ) + ) + ) + summary = result.first() + + total_bookings = summary.total_bookings or 0 + total_covers = summary.total_covers or 0 + avg_lead_time_days = float(summary.avg_lead_time_days) if summary.avg_lead_time_days else 0.0 + + # Get resident/non-resident cover counts + result = await db.execute( + select( + func.sum(case((ResosBooking.is_hotel_guest == True, ResosBooking.people), else_=0)).label('resident_covers'), + func.sum(case((ResosBooking.is_hotel_guest == False, ResosBooking.people), else_=0)).label('non_resident_covers') + ).where( + and_( + ResosBooking.kitchen_id == current_user.kitchen_id, + ResosBooking.booking_date >= from_date, + ResosBooking.booking_date <= to_date, + func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved']) + ) + ) + ) + resident_split = result.first() + resident_covers_booking = resident_split.resident_covers or 0 + non_resident_covers_booking = resident_split.non_resident_covers or 0 + + # Get spend statistics (Phase 8 integration) + stats_service = ResosStatsService(current_user.kitchen_id, db) + spend_stats = await stats_service.get_spend_statistics(from_date, to_date) + + # Calculate resident percentages + resident_pct_covers = (resident_covers_booking / total_covers * 100) if total_covers > 0 else 0.0 + total_spend = spend_stats['total_spend'] + resident_pct_spend = (spend_stats['resident_spend'] / total_spend * 100) if total_spend > 0 else 0.0 + + # Combine booking data with spend data + return { + 'summary': { + 'total_bookings': total_bookings, + 'total_covers': total_covers, + 'avg_lead_time_days': round(avg_lead_time_days, 1), + 'resident_covers': resident_covers_booking, + 'non_resident_covers': non_resident_covers_booking, + 'resident_pct_covers': round(resident_pct_covers, 1), + 'resident_pct_spend': round(resident_pct_spend, 1) + }, + 'spend': { + 'total_spend': spend_stats['total_spend'], + 'food_spend': spend_stats['food_spend'], + 'beverage_spend': spend_stats['beverage_spend'], + 'resident_spend': spend_stats['resident_spend'], + 'non_resident_spend': spend_stats['non_resident_spend'], + 'total_tickets': spend_stats['total_tickets'], + 'resident_tickets': spend_stats['resident_tickets'], + 'non_resident_tickets': spend_stats['non_resident_tickets'], + 'matched_to_resos': spend_stats['matched_to_resos'], + 'unmatched_to_resos': spend_stats['unmatched_to_resos'], + 'classification': spend_stats['classification'] + }, + 'daily_breakdown': spend_stats['daily_breakdown'], + 'service_period_breakdown': spend_stats['service_period_breakdown'], + 'daily_service_breakdown': spend_stats['daily_service_breakdown'] + } + + +# ============ Resident Covers for Budget ============ + +@router.get("/resident-covers") +async def get_resident_covers( + start_date: date, + end_date: date, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> dict: + """ + Get per-date, per-service-period hotel guest (resident) cover counts. + Used by the Budget page to show 'inc Residents' row in the forecast table. + """ + excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'] + + # Query bookings grouped by date and opening_hour + result = await db.execute( + select( + ResosBooking.booking_date, + ResosBooking.opening_hour_id, + ResosBooking.opening_hour_name, + func.sum(ResosBooking.people).label('resident_covers'), + func.count(ResosBooking.id).label('resident_bookings'), + ).where( + and_( + ResosBooking.kitchen_id == current_user.kitchen_id, + ResosBooking.booking_date >= start_date, + ResosBooking.booking_date <= end_date, + ResosBooking.is_hotel_guest == True, + ~func.lower(ResosBooking.status).in_(excluded_statuses) + ) + ).group_by( + ResosBooking.booking_date, + ResosBooking.opening_hour_id, + ResosBooking.opening_hour_name + ) + ) + + # Get kitchen settings for service type mapping + settings_result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = settings_result.scalar_one_or_none() + opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None + + service_type_map = {} + if opening_hours_mapping: + for mapping in opening_hours_mapping: + if isinstance(mapping, dict): + resos_id = mapping.get('resos_id', '') + service_type = mapping.get('service_type', '') + if resos_id and service_type: + service_type_map[resos_id] = service_type + + # Build per-date, per-period response with covers and booking counts + dates: dict[str, dict[str, dict[str, int]]] = {} + for row in result: + date_str = row.booking_date.isoformat() + if date_str not in dates: + dates[date_str] = {} + + opening_hour_id = row.opening_hour_id + opening_hour_name = row.opening_hour_name or 'Unknown' + service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name + period = service_type.lower() if service_type else 'unknown' + + # Accumulate in case multiple opening hours map to same period + if period not in dates[date_str]: + dates[date_str][period] = {"covers": 0, "bookings": 0} + dates[date_str][period]["covers"] += row.resident_covers or 0 + dates[date_str][period]["bookings"] += row.resident_bookings or 0 + + return {"dates": dates} diff --git a/backend/api/sambapos.py b/backend/api/sambapos.py new file mode 100644 index 0000000..06d9c86 --- /dev/null +++ b/backend/api/sambapos.py @@ -0,0 +1,690 @@ +""" +SambaPOS API Endpoints + +Handles SambaPOS MSSQL configuration and category management. +""" +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.settings import KitchenSettings +from auth import get_current_user, require_cap +from services.sambapos_api import SambaPOSClient + +router = APIRouter() + + +# ============ Pydantic Schemas ============ + +class SambaPOSSettingsResponse(BaseModel): + sambapos_db_host: str | None + sambapos_db_port: int | None + sambapos_db_name: str | None + sambapos_db_username: str | None + sambapos_db_password_set: bool + sambapos_tracked_categories: list[str] + sambapos_excluded_items: list[str] + + class Config: + from_attributes = True + + +class SambaPOSSettingsUpdate(BaseModel): + sambapos_db_host: str | None = None + sambapos_db_port: int | None = None + sambapos_db_name: str | None = None + sambapos_db_username: str | None = None + sambapos_db_password: str | None = None + + +class CategoryResponse(BaseModel): + id: int + name: str + + +class MenuItemResponse(BaseModel): + name: str + category: str + + +class TrackedCategoriesUpdate(BaseModel): + categories: list[str] + + +class ExcludedItemsUpdate(BaseModel): + items: list[str] + + +# ============ Settings Endpoints ============ + +@router.get("/settings", response_model=SambaPOSSettingsResponse) +async def get_sambapos_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get SambaPOS connection 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") + + # Parse tracked categories from comma-separated string + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + # Parse excluded items from comma-separated string + excluded_items = [] + if settings.sambapos_excluded_items: + excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] + + return SambaPOSSettingsResponse( + sambapos_db_host=settings.sambapos_db_host, + sambapos_db_port=settings.sambapos_db_port, + sambapos_db_name=settings.sambapos_db_name, + sambapos_db_username=settings.sambapos_db_username, + sambapos_db_password_set=bool(settings.sambapos_db_password), + sambapos_tracked_categories=tracked_categories, + sambapos_excluded_items=excluded_items + ) + + +@router.patch("/settings", response_model=SambaPOSSettingsResponse) +async def update_sambapos_settings( + update: SambaPOSSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update SambaPOS connection 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") + + # Update fields + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if value is not None: + setattr(settings, field, value) + + await db.commit() + await db.refresh(settings) + + # Parse tracked categories from comma-separated string + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + # Parse excluded items from pipe-separated string + excluded_items = [] + if settings.sambapos_excluded_items: + excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] + + return SambaPOSSettingsResponse( + sambapos_db_host=settings.sambapos_db_host, + sambapos_db_port=settings.sambapos_db_port, + sambapos_db_name=settings.sambapos_db_name, + sambapos_db_username=settings.sambapos_db_username, + sambapos_db_password_set=bool(settings.sambapos_db_password), + sambapos_tracked_categories=tracked_categories, + sambapos_excluded_items=excluded_items + ) + + +@router.post("/test-connection") +async def test_sambapos_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test SambaPOS database 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + result = await client.test_connection() + + if result["success"]: + return {"status": "success", "message": "SambaPOS connection successful"} + else: + raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}") + + +# ============ Categories Endpoints ============ + +@router.get("/categories", response_model=list[CategoryResponse]) +async def get_sambapos_categories( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch all menu categories from SambaPOS database""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + categories = await client.get_categories() + return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories] + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}") + + +@router.get("/tracked-categories") +async def get_tracked_categories( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get list of category names enabled for top sellers""" + 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") + + # Parse tracked categories from comma-separated string + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + return {"categories": tracked_categories} + + +@router.patch("/tracked-categories") +async def update_tracked_categories( + update: TrackedCategoriesUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update which categories are included in top sellers""" + 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") + + # Store as comma-separated string + settings.sambapos_tracked_categories = ','.join(update.categories) + + await db.commit() + + return {"status": "success", "categories": update.categories} + + +@router.get("/debug/menuitems") +async def debug_menuitems( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Debug endpoint to explore MenuItems table structure""" + 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.sambapos_db_password: + raise HTTPException(status_code=400, detail="SambaPOS not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + return await client.debug_menu_items() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ============ Menu Items Endpoints ============ + +@router.get("/menu-items", response_model=list[MenuItemResponse]) +async def get_menu_items( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch all unique menu item names with their categories for exclusion selection""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + # Get tracked categories to filter menu items + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None) + return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items] + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}") + + +class MenuItemWithPortionResponse(BaseModel): + menu_item_name: str + portion_name: str + category: str + on_pos_menu: bool + + +@router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse]) +async def get_menu_items_with_portions( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch all menu items with their portion names, grouped by Kitchen Course category.""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + # Get tracked categories to filter + tracked_categories = [] + if settings.sambapos_tracked_categories: + tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + items = await client.get_menu_items_with_portions( + categories=tracked_categories if tracked_categories else None + ) + return [ + MenuItemWithPortionResponse( + menu_item_name=item["menu_item_name"], + portion_name=item["portion_name"], + category=item["category"], + on_pos_menu=item.get("on_pos_menu", False) + ) + for item in items + ] + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}") + + +@router.get("/excluded-items") +async def get_excluded_items( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get list of menu item names excluded from top sellers report""" + 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") + + # Parse excluded items from pipe-separated string + excluded_items = [] + if settings.sambapos_excluded_items: + excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] + + return {"items": excluded_items} + + +@router.patch("/excluded-items") +async def update_excluded_items( + update: ExcludedItemsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update which menu item GroupCodes are excluded from top sellers report""" + 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") + + # Store as pipe-separated string (to allow commas in names) + settings.sambapos_excluded_items = '|'.join(update.items) + + await db.commit() + + return {"status": "success", "items": update.items} + + +# ============ Group Codes Endpoints ============ + +class GroupCodeResponse(BaseModel): + name: str + + +@router.get("/group-codes", response_model=list[GroupCodeResponse]) +async def get_group_codes( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch all distinct GroupCode values from MenuItems table for exclusion selection""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + group_codes = await client.get_menu_group_codes() + return [GroupCodeResponse(name=gc["name"]) for gc in group_codes] + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}") + + +# ============ GL Codes Endpoints (Phase 8) ============ + +class GLCodeResponse(BaseModel): + code: str + + +class GLCodesUpdate(BaseModel): + food_codes: list[str] + beverage_codes: list[str] + + +@router.get("/gl-codes", response_model=list[GLCodeResponse]) +async def get_gl_codes( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Fetch all unique GL codes from ProductTag custom tags for food/beverage classification""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + gl_codes = await client.get_gl_codes() + return [GLCodeResponse(code=gc["code"]) for gc in gl_codes] + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}") + + +@router.get("/gl-codes/selected") +async def get_selected_gl_codes( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get currently selected food and beverage GL codes""" + 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") + + # Parse food GL codes from comma-separated string + food_codes = [] + if settings.sambapos_food_gl_codes: + food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()] + + # Parse beverage GL codes from comma-separated string + beverage_codes = [] + if settings.sambapos_beverage_gl_codes: + beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()] + + return { + "food_codes": food_codes, + "beverage_codes": beverage_codes + } + + +@router.patch("/gl-codes") +async def update_gl_codes( + update: GLCodesUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update food and beverage GL code selections""" + 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") + + # Store as comma-separated strings + settings.sambapos_food_gl_codes = ','.join(update.food_codes) + settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes) + + await db.commit() + + return { + "status": "success", + "food_codes": update.food_codes, + "beverage_codes": update.beverage_codes + } + + +@router.get("/debug/custom-tags") +async def debug_custom_tags( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Debug endpoint to see sample CustomTags from MenuItems""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + return await client.debug_menu_items() + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}") + + +@router.get("/debug/zero-price-order-states") +async def debug_zero_price_order_states( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Debug endpoint: returns sample OrderStates blobs for zero-priced orders. + Use this to discover the JSON structure and identify where original/package price is stored. + """ + 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.sambapos_db_password: + raise HTTPException(status_code=400, detail="SambaPOS not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + return await client.debug_zero_price_order_states() + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/debug/table-schema") +async def debug_table_schema( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Debug endpoint to inspect column names in SambaPOS tables""" + 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.sambapos_db_host, + settings.sambapos_db_name, + settings.sambapos_db_username, + settings.sambapos_db_password + ]): + raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") + + client = SambaPOSClient( + host=settings.sambapos_db_host, + port=settings.sambapos_db_port or 1433, + database=settings.sambapos_db_name, + username=settings.sambapos_db_username, + password=settings.sambapos_db_password + ) + + try: + return await client.debug_table_schema() + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}") diff --git a/backend/api/search.py b/backend/api/search.py new file mode 100644 index 0000000..68eb7ec --- /dev/null +++ b/backend/api/search.py @@ -0,0 +1,744 @@ +""" +Search API endpoints for searching invoices, line items, and product definitions. +""" +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, desc +from pydantic import BaseModel + +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.product_definition import ProductDefinition +from models.settings import KitchenSettings +from auth import get_current_user, require_cap +from services.price_history import PriceHistoryService + +router = APIRouter(prefix="/api/search", tags=["search"]) + + +# ============ Response Models ============ + +class GroupSummary(BaseModel): + name: str + count: int + total: Optional[Decimal] = None + + +class InvoiceSearchItem(BaseModel): + id: int + invoice_number: Optional[str] + invoice_date: Optional[date] + total: Optional[Decimal] + net_total: Optional[Decimal] + supplier_id: Optional[int] + supplier_name: Optional[str] + vendor_name: Optional[str] + status: str + document_type: Optional[str] + + class Config: + from_attributes = True + + +class InvoiceSearchResponse(BaseModel): + items: List[InvoiceSearchItem] + total_count: int + grouped_by: Optional[str] + groups: Optional[List[GroupSummary]] + + +class LineItemSearchItem(BaseModel): + product_code: Optional[str] + description: Optional[str] + supplier_id: Optional[int] + supplier_name: Optional[str] + unit: Optional[str] + most_recent_price: Optional[Decimal] + earliest_price_in_period: Optional[Decimal] + price_change_percent: Optional[float] + price_change_status: str + total_quantity: Optional[Decimal] + occurrence_count: int + most_recent_invoice_id: Optional[int] + most_recent_invoice_number: Optional[str] + most_recent_date: Optional[date] + has_definition: bool + portions_per_unit: Optional[int] + pack_quantity: Optional[int] + most_recent_line_item_id: Optional[int] = None + most_recent_line_number: Optional[int] = None + most_recent_raw_content: Optional[str] = None + most_recent_pack_quantity: Optional[int] = None + most_recent_unit_size: Optional[Decimal] = None + most_recent_unit_size_type: Optional[str] = None + # Ingredient mapping info + ingredient_id: Optional[int] = None + ingredient_name: Optional[str] = None + ingredient_standard_unit: Optional[str] = None + price_per_std_unit: Optional[Decimal] = None + + class Config: + from_attributes = True + + +class LineItemSearchResponse(BaseModel): + items: List[LineItemSearchItem] + total_count: int + grouped_by: Optional[str] + groups: Optional[List[GroupSummary]] + + +class DefinitionSearchItem(BaseModel): + id: int + product_code: Optional[str] + description_pattern: Optional[str] + supplier_id: Optional[int] + supplier_name: Optional[str] + pack_quantity: Optional[int] + unit_size: Optional[Decimal] + unit_size_type: Optional[str] + portions_per_unit: Optional[int] + portion_description: Optional[str] + source_invoice_id: Optional[int] + source_invoice_number: Optional[str] + most_recent_price: Optional[Decimal] + updated_at: datetime + + class Config: + from_attributes = True + + +class DefinitionSearchResponse(BaseModel): + items: List[DefinitionSearchItem] + total_count: int + + +class PriceHistoryPointResponse(BaseModel): + date: date + price: Decimal + invoice_id: int + invoice_number: Optional[str] + quantity: Optional[Decimal] + + +class LineItemHistoryResponse(BaseModel): + product_code: Optional[str] + description: Optional[str] + supplier_id: int + supplier_name: Optional[str] + price_history: List[PriceHistoryPointResponse] + total_occurrences: int + total_quantity: Decimal + avg_qty_per_invoice: Decimal + avg_qty_per_week: Decimal + avg_qty_per_month: Decimal + current_price: Optional[Decimal] + price_change_status: str + + +class AcknowledgePriceRequest(BaseModel): + product_code: Optional[str] = None + description: Optional[str] = None + supplier_id: int + new_price: Decimal + source_invoice_id: Optional[int] = None + source_line_item_id: Optional[int] = None + + +class AcknowledgePriceResponse(BaseModel): + id: int + acknowledged_price: Decimal + acknowledged_at: datetime + + +# ============ Invoice Search ============ + +@router.get("/invoices", response_model=InvoiceSearchResponse) +async def search_invoices( + q: str = "", + include_line_items: bool = False, + supplier_id: Optional[int] = None, + status: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + group_by: Optional[str] = None, + limit: int = Query(default=100, le=500), + offset: int = 0, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Search invoices with optional filters. + + - q: Search term (invoice_number, vendor_name) + - include_line_items: Also search line item product_code/description + - supplier_id: Filter by supplier + - status: Filter by status (pending, confirmed, etc.) + - date_from/date_to: Date range (default: last 30 days) + - group_by: "supplier" or "month" for grouped results + """ + # Default date range: last 30 days + if date_to is None: + date_to = date.today() + if date_from is None: + date_from = date_to - timedelta(days=30) + + # Build base conditions + conditions = [ + Invoice.kitchen_id == current_user.kitchen_id, + Invoice.invoice_date >= date_from, + Invoice.invoice_date <= date_to, + ] + + if supplier_id: + conditions.append(Invoice.supplier_id == supplier_id) + + if status: + conditions.append(Invoice.status == status) + + # Search filter + if q: + search_pattern = f"%{q}%" + search_conditions = [ + Invoice.invoice_number.ilike(search_pattern), + Invoice.vendor_name.ilike(search_pattern), + ] + + if include_line_items: + # Need to join line items and search there too + line_item_subquery = ( + select(LineItem.invoice_id) + .where(or_( + LineItem.product_code.ilike(search_pattern), + LineItem.description.ilike(search_pattern) + )) + .distinct() + ) + search_conditions.append(Invoice.id.in_(line_item_subquery)) + + conditions.append(or_(*search_conditions)) + + # Get total count + count_query = select(func.count(Invoice.id)).where(and_(*conditions)) + count_result = await db.execute(count_query) + total_count = count_result.scalar() or 0 + + # Get invoices with supplier name + query = ( + select(Invoice, Supplier.name.label('supplier_name')) + .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) + .where(and_(*conditions)) + .order_by(desc(Invoice.invoice_date)) + .limit(limit) + .offset(offset) + ) + result = await db.execute(query) + rows = result.fetchall() + + items = [ + InvoiceSearchItem( + id=row.Invoice.id, + invoice_number=row.Invoice.invoice_number, + invoice_date=row.Invoice.invoice_date, + total=row.Invoice.total, + net_total=row.Invoice.net_total, + supplier_id=row.Invoice.supplier_id, + supplier_name=row.supplier_name, + vendor_name=row.Invoice.vendor_name, + status=row.Invoice.status.value if isinstance(row.Invoice.status, InvoiceStatus) else row.Invoice.status, + document_type=row.Invoice.document_type + ) + for row in rows + ] + + # Handle grouping + groups = None + if group_by == "supplier": + group_query = ( + select( + Supplier.name, + func.count(Invoice.id).label('count'), + func.sum(Invoice.net_total).label('total') + ) + .outerjoin(Supplier, Invoice.supplier_id == Supplier.id) + .where(and_(*conditions)) + .group_by(Supplier.name) + .order_by(desc('total')) + ) + group_result = await db.execute(group_query) + groups = [ + GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2]) + for row in group_result.fetchall() + ] + elif group_by == "month": + group_query = ( + select( + func.to_char(Invoice.invoice_date, 'YYYY-MM').label('month'), + func.count(Invoice.id).label('count'), + func.sum(Invoice.net_total).label('total') + ) + .where(and_(*conditions)) + .group_by(func.to_char(Invoice.invoice_date, 'YYYY-MM')) + .order_by(desc('month')) + ) + group_result = await db.execute(group_query) + groups = [ + GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2]) + for row in group_result.fetchall() + ] + + return InvoiceSearchResponse( + items=items, + total_count=total_count, + grouped_by=group_by, + groups=groups + ) + + +# ============ Line Items Search (Consolidated) ============ + +@router.get("/line-items", response_model=LineItemSearchResponse) +async def search_line_items( + q: str = "", + supplier_id: Optional[int] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + group_by: Optional[str] = None, + mapped: Optional[str] = Query(default=None, description="Filter by ingredient mapping: 'yes', 'no'"), + limit: int = Query(default=100, le=500), + offset: int = 0, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Search line items with consolidation. + + Returns DISTINCT line items by (product_code OR description + supplier), + with most recent price, price change status, total quantity, occurrence count. + """ + price_service = PriceHistoryService(db, current_user.kitchen_id) + + items_data, total_count = await price_service.get_consolidated_line_items( + search_query=q if q else None, + supplier_id=supplier_id, + date_from=date_from, + date_to=date_to, + limit=limit, + offset=offset + ) + + items = [LineItemSearchItem(**item) for item in items_data] + + # Filter by ingredient mapping status + if mapped == 'yes': + items = [i for i in items if i.ingredient_id is not None] + total_count = len(items) + elif mapped == 'no': + items = [i for i in items if i.ingredient_id is None] + total_count = len(items) + + # Handle grouping (for UI display) + groups = None + if group_by == "supplier": + # Group items by supplier + supplier_groups = {} + for item in items: + name = item.supplier_name or "Unknown" + if name not in supplier_groups: + supplier_groups[name] = {"count": 0, "total": Decimal(0)} + supplier_groups[name]["count"] += item.occurrence_count + if item.total_quantity: + supplier_groups[name]["total"] += item.total_quantity + + groups = [ + GroupSummary(name=name, count=data["count"], total=data["total"]) + for name, data in sorted(supplier_groups.items(), key=lambda x: -x[1]["count"]) + ] + + return LineItemSearchResponse( + items=items, + total_count=total_count, + grouped_by=group_by, + groups=groups + ) + + +# ============ Definitions Search ============ + +@router.get("/definitions", response_model=DefinitionSearchResponse) +async def search_definitions( + q: str = "", + supplier_id: Optional[int] = None, + has_portions: Optional[bool] = None, + limit: int = Query(default=100, le=500), + offset: int = 0, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Search product definitions. + + - q: Search term (product_code, description_pattern) + - supplier_id: Filter by supplier + - has_portions: Filter by whether portions_per_unit is set + """ + conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id] + + if supplier_id: + conditions.append(ProductDefinition.supplier_id == supplier_id) + + if q: + search_pattern = f"%{q}%" + conditions.append(or_( + ProductDefinition.product_code.ilike(search_pattern), + ProductDefinition.description_pattern.ilike(search_pattern) + )) + + if has_portions is not None: + if has_portions: + conditions.append(ProductDefinition.portions_per_unit.isnot(None)) + else: + conditions.append(ProductDefinition.portions_per_unit.is_(None)) + + # Get total count + count_query = select(func.count(ProductDefinition.id)).where(and_(*conditions)) + count_result = await db.execute(count_query) + total_count = count_result.scalar() or 0 + + # Get definitions with supplier name and source invoice number + query = ( + select( + ProductDefinition, + Supplier.name.label('supplier_name'), + Invoice.invoice_number.label('source_invoice_number') + ) + .outerjoin(Supplier, ProductDefinition.supplier_id == Supplier.id) + .outerjoin(Invoice, ProductDefinition.source_invoice_id == Invoice.id) + .where(and_(*conditions)) + .order_by(desc(ProductDefinition.updated_at)) + .limit(limit) + .offset(offset) + ) + result = await db.execute(query) + rows = result.fetchall() + + items = [] + for row in rows: + definition = row.ProductDefinition + + # Get most recent price from matching line items + most_recent_price = None + price_conditions = [Invoice.kitchen_id == current_user.kitchen_id] + + if definition.supplier_id: + price_conditions.append(Invoice.supplier_id == definition.supplier_id) + + if definition.product_code: + price_conditions.append(LineItem.product_code == definition.product_code) + elif definition.description_pattern: + price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%")) + + if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id + price_query = ( + select(LineItem.unit_price) + .join(Invoice, LineItem.invoice_id == Invoice.id) + .where(and_(*price_conditions)) + .order_by(desc(Invoice.invoice_date)) + .limit(1) + ) + price_result = await db.execute(price_query) + price_row = price_result.scalar_one_or_none() + if price_row is not None: + most_recent_price = price_row + + items.append(DefinitionSearchItem( + id=definition.id, + product_code=definition.product_code, + description_pattern=definition.description_pattern, + supplier_id=definition.supplier_id, + supplier_name=row.supplier_name, + pack_quantity=definition.pack_quantity, + unit_size=definition.unit_size, + unit_size_type=definition.unit_size_type, + portions_per_unit=definition.portions_per_unit, + portion_description=definition.portion_description, + source_invoice_id=definition.source_invoice_id, + source_invoice_number=row.source_invoice_number, + most_recent_price=most_recent_price, + updated_at=definition.updated_at + )) + + return DefinitionSearchResponse(items=items, total_count=total_count) + + +# ============ Line Item History ============ + +@router.get("/line-items/history", response_model=LineItemHistoryResponse) +async def get_line_item_history( + supplier_id: int, + product_code: Optional[str] = None, + description: Optional[str] = None, + unit: Optional[str] = None, + date_from: Optional[date] = None, + date_to: Optional[date] = None, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Get price and quantity history for a specific line item. + + Used by the history modal to show price chart and stats. + """ + if not product_code and not description: + raise HTTPException( + status_code=400, + detail="Either product_code or description is required" + ) + + price_service = PriceHistoryService(db, current_user.kitchen_id) + history = await price_service.get_history( + supplier_id=supplier_id, + product_code=product_code, + description=description, + unit=unit, + date_from=date_from, + date_to=date_to + ) + + return LineItemHistoryResponse( + product_code=history.product_code, + description=history.description, + supplier_id=history.supplier_id, + supplier_name=history.supplier_name, + price_history=[ + PriceHistoryPointResponse( + date=point.date, + price=point.price, + invoice_id=point.invoice_id, + invoice_number=point.invoice_number, + quantity=point.quantity + ) + for point in history.price_history + ], + total_occurrences=history.total_occurrences, + total_quantity=history.total_quantity, + avg_qty_per_invoice=history.avg_qty_per_invoice, + avg_qty_per_week=history.avg_qty_per_week, + avg_qty_per_month=history.avg_qty_per_month, + current_price=history.current_price, + price_change_status=history.price_change_status + ) + + +# ============ Price Acknowledgement ============ + +@router.post("/line-items/acknowledge-price", response_model=AcknowledgePriceResponse) +async def acknowledge_price_change( + request: AcknowledgePriceRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Acknowledge a price change for a line item. + + Creates or updates the AcknowledgedPrice record so the price + won't be flagged as changed in future. + """ + if not request.product_code and not request.description: + raise HTTPException( + status_code=400, + detail="Either product_code or description is required" + ) + + price_service = PriceHistoryService(db, current_user.kitchen_id) + acknowledged = await price_service.acknowledge_price( + user_id=current_user.id, + supplier_id=request.supplier_id, + product_code=request.product_code, + description=request.description, + new_price=request.new_price, + source_invoice_id=request.source_invoice_id, + source_line_item_id=request.source_line_item_id + ) + + return AcknowledgePriceResponse( + id=acknowledged.id, + acknowledged_price=acknowledged.acknowledged_price, + acknowledged_at=acknowledged.acknowledged_at + ) + + +# ============ Search Settings ============ + +class SearchSettingsResponse(BaseModel): + price_change_lookback_days: int + price_change_amber_threshold: int + price_change_red_threshold: int + + +class SearchSettingsUpdate(BaseModel): + price_change_lookback_days: Optional[int] = None + price_change_amber_threshold: Optional[int] = None + price_change_red_threshold: Optional[int] = None + + +@router.get("/settings", response_model=SearchSettingsResponse) +async def get_search_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get search/price change settings.""" + result = await db.execute( + select(KitchenSettings).where( + KitchenSettings.kitchen_id == current_user.kitchen_id + ) + ) + settings = result.scalar_one_or_none() + + return SearchSettingsResponse( + price_change_lookback_days=settings.price_change_lookback_days if settings else 30, + price_change_amber_threshold=settings.price_change_amber_threshold if settings else 10, + price_change_red_threshold=settings.price_change_red_threshold if settings else 20 + ) + + +@router.patch("/settings", response_model=SearchSettingsResponse) +async def update_search_settings( + update: SearchSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update search/price change 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 value is not None: + setattr(settings, field, value) + + await db.commit() + await db.refresh(settings) + + return SearchSettingsResponse( + price_change_lookback_days=settings.price_change_lookback_days, + price_change_amber_threshold=settings.price_change_amber_threshold, + price_change_red_threshold=settings.price_change_red_threshold + ) + + +# ============ Definition Update ============ + +class DefinitionUpdateRequest(BaseModel): + pack_quantity: Optional[int] = None + unit_size: Optional[Decimal] = None + unit_size_type: Optional[str] = None + portions_per_unit: Optional[int] = None + portion_description: Optional[str] = None + + +@router.patch("/definitions/{definition_id}", response_model=DefinitionSearchItem) +async def update_definition( + definition_id: int, + update: DefinitionUpdateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update a product definition.""" + # Fetch the definition + result = await db.execute( + select(ProductDefinition).where( + ProductDefinition.id == definition_id, + ProductDefinition.kitchen_id == current_user.kitchen_id + ) + ) + definition = result.scalar_one_or_none() + + if not definition: + raise HTTPException(status_code=404, detail="Definition not found") + + # Update fields + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(definition, field, value) + + # Update saved_by metadata + definition.saved_by_user_id = current_user.id + + await db.commit() + await db.refresh(definition) + + # Get supplier name for response + supplier_name = None + if definition.supplier_id: + supplier_result = await db.execute( + select(Supplier.name).where(Supplier.id == definition.supplier_id) + ) + supplier_name = supplier_result.scalar_one_or_none() + + # Get source invoice number + source_invoice_number = None + if definition.source_invoice_id: + invoice_result = await db.execute( + select(Invoice.invoice_number).where(Invoice.id == definition.source_invoice_id) + ) + source_invoice_number = invoice_result.scalar_one_or_none() + + # Get most recent price + most_recent_price = None + price_conditions = [Invoice.kitchen_id == current_user.kitchen_id] + + if definition.supplier_id: + price_conditions.append(Invoice.supplier_id == definition.supplier_id) + + if definition.product_code: + price_conditions.append(LineItem.product_code == definition.product_code) + elif definition.description_pattern: + price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%")) + + if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id + price_query = ( + select(LineItem.unit_price) + .join(Invoice, LineItem.invoice_id == Invoice.id) + .where(and_(*price_conditions)) + .order_by(desc(Invoice.invoice_date)) + .limit(1) + ) + price_result = await db.execute(price_query) + price_row = price_result.scalar_one_or_none() + if price_row is not None: + most_recent_price = price_row + + return DefinitionSearchItem( + id=definition.id, + product_code=definition.product_code, + description_pattern=definition.description_pattern, + supplier_id=definition.supplier_id, + supplier_name=supplier_name, + pack_quantity=definition.pack_quantity, + unit_size=definition.unit_size, + unit_size_type=definition.unit_size_type, + portions_per_unit=definition.portions_per_unit, + portion_description=definition.portion_description, + source_invoice_id=definition.source_invoice_id, + source_invoice_number=source_invoice_number, + most_recent_price=most_recent_price, + updated_at=definition.updated_at + ) diff --git a/backend/api/settings.py b/backend/api/settings.py new file mode 100644 index 0000000..a88603e --- /dev/null +++ b/backend/api/settings.py @@ -0,0 +1,798 @@ +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.settings import KitchenSettings +from auth import get_current_user, require_cap + +router = APIRouter() + + +class SettingsResponse(BaseModel): + azure_endpoint: str | None + azure_key_set: bool # Don't expose the actual key, just whether it's set + currency_symbol: str + date_format: str + high_quantity_threshold: int + # SMTP settings + smtp_host: str | None + smtp_port: int | None + smtp_username: str | None + smtp_password_set: bool # Don't expose the actual password + smtp_use_tls: bool + smtp_from_email: str | None + smtp_from_name: str | None + support_email: str | None + # Dext integration + dext_email: str | None + dext_include_notes: bool + dext_include_non_stock: bool + dext_auto_send_enabled: bool + dext_manual_send_enabled: bool + dext_include_annotations: bool + # PDF annotation settings + pdf_annotations_enabled: bool + pdf_preview_show_annotations: bool + # OCR post-processing options + ocr_clean_product_codes: bool + ocr_filter_subtotal_rows: bool + ocr_use_weight_as_quantity: bool + # Cost distribution settings + cost_distribution_max_days: int + # LLM settings — see LLM-MANIFEST.md for removal instructions + llm_enabled: bool = False + anthropic_api_key_set: bool = False # Don't expose the actual key + llm_model: str | None = None + llm_confidence_threshold: float | None = None + llm_monthly_token_limit: int = 500000 + llm_features_enabled: dict | None = None + + class Config: + from_attributes = True + + +class SettingsUpdate(BaseModel): + azure_endpoint: str | None = None + azure_key: str | None = None + currency_symbol: str | None = None + date_format: str | None = None + high_quantity_threshold: int | None = None + # SMTP settings + smtp_host: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None # Only set if provided + smtp_use_tls: bool | None = None + smtp_from_email: str | None = None + smtp_from_name: str | None = None + support_email: str | None = None + # Dext integration + dext_email: str | None = None + dext_include_notes: bool | None = None + dext_include_non_stock: bool | None = None + dext_auto_send_enabled: bool | None = None + dext_manual_send_enabled: bool | None = None + dext_include_annotations: bool | None = None + # PDF annotation settings + pdf_annotations_enabled: bool | None = None + pdf_preview_show_annotations: bool | None = None + # OCR post-processing options + ocr_clean_product_codes: bool | None = None + ocr_filter_subtotal_rows: bool | None = None + ocr_use_weight_as_quantity: bool | None = None + # Cost distribution settings + cost_distribution_max_days: int | None = None + # LLM settings — see LLM-MANIFEST.md for removal instructions + llm_enabled: bool | None = None + anthropic_api_key: str | None = None # Only set if provided + llm_model: str | None = None + llm_confidence_threshold: float | None = None + llm_monthly_token_limit: int | None = None + llm_features_enabled: dict | None = None + + +@router.get("/", response_model=SettingsResponse) +async def get_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get current kitchen settings""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one_or_none() + + if not settings: + # Create default settings if none exist + settings = KitchenSettings( + kitchen_id=current_user.kitchen_id, + currency_symbol="£", + date_format="DD/MM/YYYY" + ) + db.add(settings) + await db.commit() + await db.refresh(settings) + + return _build_settings_response(settings) + + +def _build_settings_response(settings: KitchenSettings) -> SettingsResponse: + """Build SettingsResponse from a KitchenSettings model instance.""" + return SettingsResponse( + azure_endpoint=settings.azure_endpoint, + azure_key_set=bool(settings.azure_key), + currency_symbol=settings.currency_symbol, + date_format=settings.date_format, + high_quantity_threshold=settings.high_quantity_threshold, + # SMTP settings + smtp_host=settings.smtp_host, + smtp_port=settings.smtp_port, + smtp_username=settings.smtp_username, + smtp_password_set=bool(settings.smtp_password), + smtp_use_tls=settings.smtp_use_tls, + smtp_from_email=settings.smtp_from_email, + smtp_from_name=settings.smtp_from_name, + support_email=settings.support_email, + # Dext integration + dext_email=settings.dext_email, + dext_include_notes=settings.dext_include_notes, + dext_include_non_stock=settings.dext_include_non_stock, + dext_auto_send_enabled=settings.dext_auto_send_enabled, + dext_manual_send_enabled=settings.dext_manual_send_enabled, + dext_include_annotations=settings.dext_include_annotations, + # PDF annotation settings + pdf_annotations_enabled=settings.pdf_annotations_enabled, + pdf_preview_show_annotations=settings.pdf_preview_show_annotations, + # OCR post-processing options + ocr_clean_product_codes=settings.ocr_clean_product_codes, + ocr_filter_subtotal_rows=settings.ocr_filter_subtotal_rows, + ocr_use_weight_as_quantity=settings.ocr_use_weight_as_quantity, + cost_distribution_max_days=settings.cost_distribution_max_days, + # LLM settings — see LLM-MANIFEST.md for removal instructions + llm_enabled=settings.llm_enabled, + anthropic_api_key_set=bool(settings.anthropic_api_key), + llm_model=settings.llm_model, + llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None, + llm_monthly_token_limit=settings.llm_monthly_token_limit, + llm_features_enabled=settings.llm_features_enabled, + ) + + +@router.patch("/", response_model=SettingsResponse) +async def update_settings( + update: SettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update kitchen settings""" + 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 fields + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if value is not None: + setattr(settings, field, value) + + await db.commit() + await db.refresh(settings) + + return _build_settings_response(settings) + + +@router.post("/test-azure") +async def test_azure_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test Azure Document Intelligence connection""" + 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.azure_endpoint or not settings.azure_key: + raise HTTPException( + status_code=400, + detail="Azure credentials not configured" + ) + + try: + from azure.ai.formrecognizer import DocumentAnalysisClient + from azure.core.credentials import AzureKeyCredential + + client = DocumentAnalysisClient( + endpoint=settings.azure_endpoint, + credential=AzureKeyCredential(settings.azure_key) + ) + # Simple connection test - this will validate credentials + # The actual analysis would happen during invoice processing + return {"status": "success", "message": "Azure connection successful"} + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Azure connection failed: {str(e)}" + ) + + +@router.post("/test-smtp") +async def test_smtp_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test SMTP connection with current settings""" + from services.email_service import EmailService + + 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.smtp_host or not settings.smtp_from_email: + raise HTTPException( + status_code=400, + detail="SMTP not fully configured. Please set SMTP host and from email." + ) + + email_service = EmailService(settings) + success, message = email_service.test_connection() + + if not success: + raise HTTPException(status_code=400, detail=message) + + return {"status": "success", "message": message} + + +# ============ Kitchen Details Endpoints ============ + +class KitchenDetailsResponse(BaseModel): + kitchen_display_name: str | None = None + kitchen_address_line1: str | None = None + kitchen_address_line2: str | None = None + kitchen_city: str | None = None + kitchen_postcode: str | None = None + kitchen_phone: str | None = None + kitchen_email: str | None = None + + +class KitchenDetailsUpdate(BaseModel): + kitchen_display_name: str | None = None + kitchen_address_line1: str | None = None + kitchen_address_line2: str | None = None + kitchen_city: str | None = None + kitchen_postcode: str | None = None + kitchen_phone: str | None = None + kitchen_email: str | None = None + + +@router.get("/kitchen-details", response_model=KitchenDetailsResponse) +async def get_kitchen_details( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get kitchen details for PO letterhead""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one_or_none() + + if not settings: + return KitchenDetailsResponse() + + return KitchenDetailsResponse( + kitchen_display_name=settings.kitchen_display_name, + kitchen_address_line1=settings.kitchen_address_line1, + kitchen_address_line2=settings.kitchen_address_line2, + kitchen_city=settings.kitchen_city, + kitchen_postcode=settings.kitchen_postcode, + kitchen_phone=settings.kitchen_phone, + kitchen_email=settings.kitchen_email, + ) + + +@router.patch("/kitchen-details", response_model=KitchenDetailsResponse) +async def update_kitchen_details( + update: KitchenDetailsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update kitchen details for PO letterhead""" + 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(): + setattr(settings, field, value) + + await db.commit() + await db.refresh(settings) + + return KitchenDetailsResponse( + kitchen_display_name=settings.kitchen_display_name, + kitchen_address_line1=settings.kitchen_address_line1, + kitchen_address_line2=settings.kitchen_address_line2, + kitchen_city=settings.kitchen_city, + kitchen_postcode=settings.kitchen_postcode, + kitchen_phone=settings.kitchen_phone, + kitchen_email=settings.kitchen_email, + ) + + +# ============ Page Restrictions Endpoints ============ + +class PageRestrictionsResponse(BaseModel): + restricted_pages: list[str] + + +class PageRestrictionsUpdate(BaseModel): + restricted_pages: list[str] + + +@router.get("/page-restrictions", response_model=PageRestrictionsResponse) +async def get_page_restrictions( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get list of pages restricted to admin users only""" + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one_or_none() + + if not settings: + return PageRestrictionsResponse(restricted_pages=[]) + + # Parse comma-separated list + restricted = [] + if settings.admin_restricted_pages: + restricted = [p.strip() for p in settings.admin_restricted_pages.split(',') if p.strip()] + + return PageRestrictionsResponse(restricted_pages=restricted) + + +@router.patch("/page-restrictions", response_model=PageRestrictionsResponse) +async def update_page_restrictions( + update: PageRestrictionsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update list of pages restricted to admin users only (admin only)""" + if not current_user.is_admin: + raise HTTPException( + status_code=403, + detail="Only admins can modify page restrictions" + ) + + 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) + + # Store as comma-separated string + settings.admin_restricted_pages = ','.join(update.restricted_pages) if update.restricted_pages else None + + await db.commit() + + return PageRestrictionsResponse(restricted_pages=update.restricted_pages) + + +# ============ Nextcloud Settings Endpoints ============ + +class NextcloudSettingsResponse(BaseModel): + nextcloud_host: str | None + nextcloud_username: str | None + nextcloud_password_set: bool + nextcloud_base_path: str | None + nextcloud_enabled: bool + nextcloud_delete_local: bool + + class Config: + from_attributes = True + + +class NextcloudSettingsUpdate(BaseModel): + nextcloud_host: str | None = None + nextcloud_username: str | None = None + nextcloud_password: str | None = None + nextcloud_base_path: str | None = None + nextcloud_enabled: bool | None = None + nextcloud_delete_local: bool | None = None + + +class NextcloudStatsResponse(BaseModel): + pending_count: int + archived_count: int + local_count: int + nextcloud_enabled: bool + nextcloud_configured: bool + + +class NextcloudArchiveResponse(BaseModel): + success_count: int + failed_count: int + errors: list[str] + + +@router.get("/nextcloud", response_model=NextcloudSettingsResponse) +async def get_nextcloud_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get Nextcloud 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 NextcloudSettingsResponse( + nextcloud_host=None, + nextcloud_username=None, + nextcloud_password_set=False, + nextcloud_base_path="/Kitchen Invoices", + nextcloud_enabled=False, + nextcloud_delete_local=False + ) + + return NextcloudSettingsResponse( + nextcloud_host=settings.nextcloud_host, + nextcloud_username=settings.nextcloud_username, + nextcloud_password_set=bool(settings.nextcloud_password), + nextcloud_base_path=settings.nextcloud_base_path, + nextcloud_enabled=settings.nextcloud_enabled, + nextcloud_delete_local=settings.nextcloud_delete_local + ) + + +@router.patch("/nextcloud", response_model=NextcloudSettingsResponse) +async def update_nextcloud_settings( + update: NextcloudSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update Nextcloud 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(): + # Map 'nextcloud_password' to the model field + if field == 'nextcloud_password' and value: + setattr(settings, field, value) + elif value is not None: + setattr(settings, field, value) + + await db.commit() + await db.refresh(settings) + + return NextcloudSettingsResponse( + nextcloud_host=settings.nextcloud_host, + nextcloud_username=settings.nextcloud_username, + nextcloud_password_set=bool(settings.nextcloud_password), + nextcloud_base_path=settings.nextcloud_base_path, + nextcloud_enabled=settings.nextcloud_enabled, + nextcloud_delete_local=settings.nextcloud_delete_local + ) + + +@router.post("/nextcloud/test") +async def test_nextcloud_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test Nextcloud WebDAV connection""" + 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 all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]): + raise HTTPException(status_code=400, detail="Nextcloud not fully configured") + + nc = NextcloudService( + settings.nextcloud_host, + settings.nextcloud_username, + settings.nextcloud_password, + settings.nextcloud_base_path + ) + + success, message = await nc.test_connection() + await nc.close() + + if not success: + raise HTTPException(status_code=400, detail=message) + + return {"status": "success", "message": message} + + +@router.get("/nextcloud/stats", response_model=NextcloudStatsResponse) +async def get_nextcloud_stats( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get Nextcloud archive statistics""" + from services.file_archival_service import FileArchivalService + + archival_service = FileArchivalService(db, current_user.kitchen_id) + stats = await archival_service.get_archive_stats() + + return NextcloudStatsResponse(**stats) + + +@router.post("/nextcloud/archive-all", response_model=NextcloudArchiveResponse) +async def archive_all_pending( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Manually archive all pending invoices to Nextcloud""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + + from services.file_archival_service import FileArchivalService + + archival_service = FileArchivalService(db, current_user.kitchen_id) + success_count, failed_count, errors = await archival_service.archive_all_pending() + + return NextcloudArchiveResponse( + success_count=success_count, + failed_count=failed_count, + errors=errors + ) + + +@router.post("/nextcloud/archive/{invoice_id}") +async def archive_single_invoice( + invoice_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Archive a single invoice to Nextcloud (for testing)""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin only") + + from models.invoice import Invoice + from services.file_archival_service import FileArchivalService + + # Get the 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") + + if invoice.file_storage_location == "nextcloud": + return {"status": "skipped", "message": "Invoice already archived to Nextcloud"} + + # Check Nextcloud config + 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 all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]): + raise HTTPException(status_code=400, detail="Nextcloud not fully configured") + + archival_service = FileArchivalService(db, current_user.kitchen_id) + success, message = await archival_service.archive_invoice_file(invoice) + + if not success: + raise HTTPException(status_code=500, detail=message) + + return {"status": "success", "message": message, "nextcloud_path": invoice.nextcloud_path} + + +# ============ API Access Endpoints ============ + +class ApiAccessResponse(BaseModel): + api_key: str | None + api_key_enabled: bool + + +class ApiAccessUpdate(BaseModel): + api_key_enabled: bool + + +@router.get("/api-access", response_model=ApiAccessResponse) +async def get_api_access( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get API access 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 ApiAccessResponse(api_key=None, api_key_enabled=False) + + return ApiAccessResponse( + api_key=settings.api_key, + api_key_enabled=settings.api_key_enabled, + ) + + +@router.patch("/api-access", response_model=ApiAccessResponse) +async def update_api_access( + update: ApiAccessUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update API access settings (enable/disable)""" + 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) + + settings.api_key_enabled = update.api_key_enabled + + await db.commit() + await db.refresh(settings) + + return ApiAccessResponse( + api_key=settings.api_key, + api_key_enabled=settings.api_key_enabled, + ) + + +@router.post("/api-access/regenerate") +async def regenerate_api_key( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Generate or regenerate the API key""" + import secrets + + 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) + + new_key = secrets.token_urlsafe(32) + settings.api_key = new_key + settings.api_key_enabled = True + + await db.commit() + await db.refresh(settings) + + return {"api_key": new_key, "api_key_enabled": True} + + +# ============ LLM Usage Stats Endpoints ============ +# LLM FEATURE — see LLM-MANIFEST.md for removal instructions + + +class LlmUsageStatsResponse(BaseModel): + total_calls: int = 0 + successful_calls: int = 0 + failed_calls: int = 0 + total_input_tokens: int = 0 + total_output_tokens: int = 0 + total_tokens: int = 0 + estimated_cost_usd: float = 0.0 + cache_entries_this_month: int = 0 + + +@router.get("/llm-usage", response_model=LlmUsageStatsResponse) +async def get_llm_usage( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get aggregated LLM usage stats for the current month""" + from services.llm_service import get_usage_stats + + stats = await get_usage_stats(db, current_user.kitchen_id) + return LlmUsageStatsResponse(**stats) + + +@router.post("/test-llm") +async def test_llm_connection( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Test Anthropic API connection with 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 or not settings.anthropic_api_key: + raise HTTPException( + status_code=400, + detail="Anthropic API key not configured" + ) + + if not settings.llm_enabled: + raise HTTPException( + status_code=400, + detail="LLM features are disabled. Enable them in Settings first." + ) + + try: + import anthropic + from services.llm_service import DEFAULT_LLM_MODEL + client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key) + response = await client.messages.create( + model=settings.llm_model or DEFAULT_LLM_MODEL, + max_tokens=10, + messages=[{"role": "user", "content": "Say 'ok'"}], + ) + return {"status": "success", "message": f"Connection successful. Model: {response.model}"} + except anthropic.AuthenticationError: + raise HTTPException(status_code=400, detail="Authentication failed — check your API key") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}") + + +# LLM FEATURE — see LLM-MANIFEST.md for removal instructions +@router.get("/llm-models") +async def get_llm_models( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Fetch available models from Anthropic API.""" + 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.anthropic_api_key: + return {"models": [], "default": None, "error": "No API key configured"} + + from services.llm_service import list_available_models, DEFAULT_LLM_MODEL + models = await list_available_models(settings.anthropic_api_key) + + return { + "models": models, + "default": DEFAULT_LLM_MODEL, + "current": settings.llm_model or DEFAULT_LLM_MODEL, + } diff --git a/backend/api/suppliers.py b/backend/api/suppliers.py new file mode 100644 index 0000000..92bf916 --- /dev/null +++ b/backend/api/suppliers.py @@ -0,0 +1,343 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel + +from database import get_db, AsyncSessionLocal +from models.user import User +from models.supplier import Supplier +from models.invoice import Invoice +from auth import get_current_user, require_cap +from ocr.parser import identify_supplier + +router = APIRouter() + + +async def rematch_unmatched_invoices(kitchen_id: int): + """ + Re-run supplier matching for all invoices without a supplier. + Called after supplier create/update to match previously unmatched invoices. + """ + async with AsyncSessionLocal() as db: + # Get all invoices without a supplier that have vendor_name from OCR + result = await db.execute( + select(Invoice).where( + Invoice.kitchen_id == kitchen_id, + Invoice.supplier_id == None, + Invoice.vendor_name != None + ) + ) + invoices = result.scalars().all() + + for invoice in invoices: + if invoice.vendor_name: + supplier_id, match_type = await identify_supplier( + invoice.vendor_name, kitchen_id, db + ) + if supplier_id: + invoice.supplier_id = supplier_id + invoice.supplier_match_type = match_type + + await db.commit() + + +class SupplierCreate(BaseModel): + name: str + aliases: list[str] = [] + template_config: dict = {} + identifier_config: dict = {} + skip_dext: bool = False + order_email: Optional[str] = None + account_number: Optional[str] = None + + +class SupplierUpdate(BaseModel): + name: Optional[str] = None + aliases: Optional[list[str]] = None + template_config: Optional[dict] = None + identifier_config: Optional[dict] = None + skip_dext: Optional[bool] = None + order_email: Optional[str] = None + account_number: Optional[str] = None + + +class SupplierResponse(BaseModel): + id: int + name: str + aliases: list[str] + template_config: dict + identifier_config: dict + skip_dext: bool + order_email: Optional[str] = None + account_number: Optional[str] = None + created_at: str + + class Config: + from_attributes = True + + +@router.post("/", response_model=SupplierResponse) +async def create_supplier( + request: SupplierCreate, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Create a new supplier with extraction templates""" + supplier = Supplier( + kitchen_id=current_user.kitchen_id, + name=request.name, + aliases=request.aliases, + template_config=request.template_config, + identifier_config=request.identifier_config, + skip_dext=request.skip_dext, + order_email=request.order_email, + account_number=request.account_number, + ) + db.add(supplier) + await db.commit() + await db.refresh(supplier) + + # Rematch unmatched invoices in background + background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) + + return SupplierResponse( + id=supplier.id, + name=supplier.name, + aliases=supplier.aliases or [], + template_config=supplier.template_config, + identifier_config=supplier.identifier_config, + skip_dext=supplier.skip_dext, + order_email=supplier.order_email, + account_number=supplier.account_number, + created_at=supplier.created_at.isoformat() + ) + + +@router.get("/", response_model=list[SupplierResponse]) +async def list_suppliers( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """List all suppliers for the current kitchen""" + result = await db.execute( + select(Supplier) + .where(Supplier.kitchen_id == current_user.kitchen_id) + .order_by(Supplier.name) + ) + suppliers = result.scalars().all() + + return [ + SupplierResponse( + id=s.id, + name=s.name, + aliases=s.aliases or [], + template_config=s.template_config, + identifier_config=s.identifier_config, + skip_dext=s.skip_dext, + order_email=s.order_email, + account_number=s.account_number, + created_at=s.created_at.isoformat() + ) + for s in suppliers + ] + + +@router.get("/{supplier_id}", response_model=SupplierResponse) +async def get_supplier( + supplier_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Get a supplier by ID""" + result = await db.execute( + select(Supplier).where( + Supplier.id == supplier_id, + Supplier.kitchen_id == current_user.kitchen_id + ) + ) + supplier = result.scalar_one_or_none() + + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + return SupplierResponse( + id=supplier.id, + name=supplier.name, + aliases=supplier.aliases or [], + template_config=supplier.template_config, + identifier_config=supplier.identifier_config, + skip_dext=supplier.skip_dext, + order_email=supplier.order_email, + account_number=supplier.account_number, + created_at=supplier.created_at.isoformat() + ) + + +@router.patch("/{supplier_id}", response_model=SupplierResponse) +async def update_supplier( + supplier_id: int, + update: SupplierUpdate, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Update a supplier's template configuration""" + result = await db.execute( + select(Supplier).where( + Supplier.id == supplier_id, + Supplier.kitchen_id == current_user.kitchen_id + ) + ) + supplier = result.scalar_one_or_none() + + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + update_data = update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(supplier, field, value) + + await db.commit() + await db.refresh(supplier) + + # Rematch unmatched invoices in background + background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) + + return SupplierResponse( + id=supplier.id, + name=supplier.name, + aliases=supplier.aliases or [], + template_config=supplier.template_config, + identifier_config=supplier.identifier_config, + skip_dext=supplier.skip_dext, + order_email=supplier.order_email, + account_number=supplier.account_number, + created_at=supplier.created_at.isoformat() + ) + + +@router.delete("/{supplier_id}") +async def delete_supplier( + supplier_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Delete a supplier""" + result = await db.execute( + select(Supplier).where( + Supplier.id == supplier_id, + Supplier.kitchen_id == current_user.kitchen_id + ) + ) + supplier = result.scalar_one_or_none() + + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + await db.delete(supplier) + await db.commit() + + return {"message": "Supplier deleted"} + + +class AddAliasRequest(BaseModel): + alias: str + invoice_id: Optional[int] = None # If provided, update this invoice's match type to 'exact' + + +@router.post("/{supplier_id}/aliases", response_model=SupplierResponse) +async def add_supplier_alias( + supplier_id: int, + request: AddAliasRequest, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """Add an alias to a supplier for better matching""" + result = await db.execute( + select(Supplier).where( + Supplier.id == supplier_id, + Supplier.kitchen_id == current_user.kitchen_id + ) + ) + supplier = result.scalar_one_or_none() + + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + alias = request.alias.strip() + if not alias: + raise HTTPException(status_code=400, detail="Alias cannot be empty") + + # Add alias if not already present + # Create a new list to ensure SQLAlchemy detects the change (JSON columns don't detect in-place mutations) + current_aliases = list(supplier.aliases or []) + if alias not in current_aliases: + current_aliases.append(alias) + supplier.aliases = current_aliases + + # If invoice_id provided, update that invoice's match type to 'exact' + if request.invoice_id: + inv_result = await db.execute( + select(Invoice).where( + Invoice.id == request.invoice_id, + Invoice.kitchen_id == current_user.kitchen_id + ) + ) + invoice = inv_result.scalar_one_or_none() + if invoice and invoice.supplier_match_type == 'fuzzy': + invoice.supplier_match_type = 'exact' + + await db.commit() + await db.refresh(supplier) + + # Rematch unmatched invoices in background + background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) + + return SupplierResponse( + id=supplier.id, + name=supplier.name, + aliases=supplier.aliases or [], + template_config=supplier.template_config, + identifier_config=supplier.identifier_config, + skip_dext=supplier.skip_dext, + order_email=supplier.order_email, + account_number=supplier.account_number, + created_at=supplier.created_at.isoformat() + ) + + +@router.post("/rematch-fuzzy") +async def rematch_fuzzy_invoices( + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Clear all fuzzy-matched invoices and re-run supplier matching. + Use this after updating matching logic to fix incorrect fuzzy matches. + """ + # Count fuzzy matches before clearing + count_result = await db.execute( + select(Invoice).where( + Invoice.kitchen_id == current_user.kitchen_id, + Invoice.supplier_match_type == "fuzzy" + ) + ) + fuzzy_invoices = count_result.scalars().all() + count = len(fuzzy_invoices) + + # Clear supplier assignment for all fuzzy matches + for invoice in fuzzy_invoices: + invoice.supplier_id = None + invoice.supplier_match_type = None + + await db.commit() + + # Re-run matching in background + background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id) + + return {"message": f"Cleared {count} fuzzy matches. Re-matching in background."} diff --git a/backend/api/support.py b/backend/api/support.py new file mode 100644 index 0000000..40f6e8c --- /dev/null +++ b/backend/api/support.py @@ -0,0 +1,204 @@ +""" +Support Request API + +Handles user support requests with page screenshots. +""" +import base64 +import logging +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from auth import get_current_user, require_cap +from models.user import User, Kitchen +from models.settings import KitchenSettings +from services.email_service import EmailService +from sqlalchemy import select + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class SupportRequest(BaseModel): + """Support request payload""" + description: str + screenshot: str # Base64 encoded PNG + page_url: str + browser_info: str | None = None + + +class SupportResponse(BaseModel): + """Support request response""" + success: bool + message: str + + +def generate_support_email_html( + user_name: str, + user_email: str, + kitchen_name: str, + description: str, + page_url: str, + browser_info: str | None, + timestamp: datetime +) -> str: + """Generate HTML email body for support request""" + return f""" + + + + + +
    +

    Support Request

    +
    +
    +
    +

    From: {user_name} ({user_email})

    +

    Kitchen: {kitchen_name}

    +

    Page URL: {page_url}

    +

    Timestamp: {timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}

    + {f'

    Browser: {browser_info}

    ' if browser_info else ''} +
    + +

    Issue Description

    +
    +

    {description.replace(chr(10), '
    ')}

    +
    + +

    A screenshot of the page is attached to this email.

    +
    + + + """ + + +@router.post("/support/request", response_model=SupportResponse) +async def submit_support_request( + request: SupportRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Submit a support request with page screenshot. + + The screenshot is sent as an email attachment to the configured support email. + """ + # Get kitchen 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="Kitchen settings not found") + + # Check if support email is configured + if not settings.support_email: + raise HTTPException( + status_code=400, + detail="Support email not configured. Please contact your administrator." + ) + + # Check if SMTP is configured + if not settings.smtp_host or not settings.smtp_from_email: + raise HTTPException( + status_code=400, + detail="Email settings not configured. Please contact your administrator." + ) + + # Fetch kitchen name explicitly to avoid lazy loading issues + kitchen_result = await db.execute( + select(Kitchen).where(Kitchen.id == current_user.kitchen_id) + ) + kitchen = kitchen_result.scalar_one_or_none() + kitchen_name = kitchen.name if kitchen else "Unknown Kitchen" + + try: + # Decode screenshot from base64 + # Remove data URL prefix if present + screenshot_data = request.screenshot + if screenshot_data.startswith('data:'): + screenshot_data = screenshot_data.split(',', 1)[1] + + screenshot_bytes = base64.b64decode(screenshot_data) + + # Generate email + timestamp = datetime.utcnow() + html_body = generate_support_email_html( + user_name=current_user.name, + user_email=current_user.email, + kitchen_name=kitchen_name, + description=request.description, + page_url=request.page_url, + browser_info=request.browser_info, + timestamp=timestamp + ) + + # Create email subject + subject = f"Support Request from {current_user.name} - {kitchen_name}" + + # Send email with screenshot attachment + email_service = EmailService(settings) + filename = f"screenshot_{timestamp.strftime('%Y%m%d_%H%M%S')}.png" + + success = email_service.send_email( + to_email=settings.support_email, + subject=subject, + html_body=html_body, + attachments=[(filename, screenshot_bytes)] + ) + + if success: + logger.info(f"Support request sent from {current_user.name} to {settings.support_email}") + return SupportResponse( + success=True, + message="Support request sent successfully. We'll get back to you soon." + ) + else: + logger.error(f"Failed to send support request email") + raise HTTPException( + status_code=500, + detail="Failed to send support request. Please try again later." + ) + + except base64.binascii.Error: + raise HTTPException(status_code=400, detail="Invalid screenshot data") + except Exception as e: + logger.error(f"Support request error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/support/enabled") +async def check_support_enabled( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + Check if support requests are enabled (support email configured). + Returns whether the support button should be shown. + """ + result = await db.execute( + select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) + ) + settings = result.scalar_one_or_none() + + enabled = bool( + settings and + settings.support_email and + settings.smtp_host and + settings.smtp_from_email + ) + + return {"enabled": enabled}