diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c6fdac --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.pyo +.env +*.egg-info/ +dist/ +node_modules/ +frontend/dist/ diff --git a/backend/api/__init__.py b/backend/api/__init__.py deleted file mode 100644 index 72f2cd4..0000000 --- a/backend/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import invoices, suppliers, reports, newbook, resos - -__all__ = ["invoices", "suppliers", "reports", "newbook", "resos"] diff --git a/backend/api/__pycache__/__init__.cpython-313.pyc b/backend/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b110fab..0000000 Binary files a/backend/api/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/backup.cpython-313.pyc b/backend/api/__pycache__/backup.cpython-313.pyc deleted file mode 100644 index 568cf82..0000000 Binary files a/backend/api/__pycache__/backup.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/budget.cpython-313.pyc b/backend/api/__pycache__/budget.cpython-313.pyc deleted file mode 100644 index f95405f..0000000 Binary files a/backend/api/__pycache__/budget.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/calendar_events.cpython-313.pyc b/backend/api/__pycache__/calendar_events.cpython-313.pyc deleted file mode 100644 index 3e2a2ff..0000000 Binary files a/backend/api/__pycache__/calendar_events.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/cost_distributions.cpython-313.pyc b/backend/api/__pycache__/cost_distributions.cpython-313.pyc deleted file mode 100644 index 9711e4c..0000000 Binary files a/backend/api/__pycache__/cost_distributions.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/cover_overrides.cpython-313.pyc b/backend/api/__pycache__/cover_overrides.cpython-313.pyc deleted file mode 100644 index 18c8d2a..0000000 Binary files a/backend/api/__pycache__/cover_overrides.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/credit_notes.cpython-313.pyc b/backend/api/__pycache__/credit_notes.cpython-313.pyc deleted file mode 100644 index 3dbb207..0000000 Binary files a/backend/api/__pycache__/credit_notes.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/disputes.cpython-313.pyc b/backend/api/__pycache__/disputes.cpython-313.pyc deleted file mode 100644 index ca58b57..0000000 Binary files a/backend/api/__pycache__/disputes.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/event_orders.cpython-313.pyc b/backend/api/__pycache__/event_orders.cpython-313.pyc deleted file mode 100644 index 7b3788c..0000000 Binary files a/backend/api/__pycache__/event_orders.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/external.cpython-313.pyc b/backend/api/__pycache__/external.cpython-313.pyc deleted file mode 100644 index d9c0e5c..0000000 Binary files a/backend/api/__pycache__/external.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/field_mappings.cpython-313.pyc b/backend/api/__pycache__/field_mappings.cpython-313.pyc deleted file mode 100644 index 6de6615..0000000 Binary files a/backend/api/__pycache__/field_mappings.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/food_flags.cpython-313.pyc b/backend/api/__pycache__/food_flags.cpython-313.pyc deleted file mode 100644 index 9568b0b..0000000 Binary files a/backend/api/__pycache__/food_flags.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/imap.cpython-313.pyc b/backend/api/__pycache__/imap.cpython-313.pyc deleted file mode 100644 index 32dd4ab..0000000 Binary files a/backend/api/__pycache__/imap.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/ingredients.cpython-313.pyc b/backend/api/__pycache__/ingredients.cpython-313.pyc deleted file mode 100644 index 4f24997..0000000 Binary files a/backend/api/__pycache__/ingredients.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/invoices.cpython-313.pyc b/backend/api/__pycache__/invoices.cpython-313.pyc deleted file mode 100644 index cd2130e..0000000 Binary files a/backend/api/__pycache__/invoices.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/kds.cpython-313.pyc b/backend/api/__pycache__/kds.cpython-313.pyc deleted file mode 100644 index 71f34ce..0000000 Binary files a/backend/api/__pycache__/kds.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/logbook.cpython-313.pyc b/backend/api/__pycache__/logbook.cpython-313.pyc deleted file mode 100644 index 4238d2b..0000000 Binary files a/backend/api/__pycache__/logbook.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/menus.cpython-313.pyc b/backend/api/__pycache__/menus.cpython-313.pyc deleted file mode 100644 index 598ed11..0000000 Binary files a/backend/api/__pycache__/menus.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/newbook.cpython-313.pyc b/backend/api/__pycache__/newbook.cpython-313.pyc deleted file mode 100644 index de4db9a..0000000 Binary files a/backend/api/__pycache__/newbook.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/public.cpython-313.pyc b/backend/api/__pycache__/public.cpython-313.pyc deleted file mode 100644 index 7e948f4..0000000 Binary files a/backend/api/__pycache__/public.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/purchase_orders.cpython-313.pyc b/backend/api/__pycache__/purchase_orders.cpython-313.pyc deleted file mode 100644 index 9e6c378..0000000 Binary files a/backend/api/__pycache__/purchase_orders.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/recipes.cpython-313.pyc b/backend/api/__pycache__/recipes.cpython-313.pyc deleted file mode 100644 index 2311f8d..0000000 Binary files a/backend/api/__pycache__/recipes.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/reports.cpython-313.pyc b/backend/api/__pycache__/reports.cpython-313.pyc deleted file mode 100644 index 8669763..0000000 Binary files a/backend/api/__pycache__/reports.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/residents_table_chart.cpython-313.pyc b/backend/api/__pycache__/residents_table_chart.cpython-313.pyc deleted file mode 100644 index 2689fc7..0000000 Binary files a/backend/api/__pycache__/residents_table_chart.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/resos.cpython-313.pyc b/backend/api/__pycache__/resos.cpython-313.pyc deleted file mode 100644 index a41ce39..0000000 Binary files a/backend/api/__pycache__/resos.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/sambapos.cpython-313.pyc b/backend/api/__pycache__/sambapos.cpython-313.pyc deleted file mode 100644 index efe9689..0000000 Binary files a/backend/api/__pycache__/sambapos.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/search.cpython-313.pyc b/backend/api/__pycache__/search.cpython-313.pyc deleted file mode 100644 index 8c512c1..0000000 Binary files a/backend/api/__pycache__/search.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/settings.cpython-313.pyc b/backend/api/__pycache__/settings.cpython-313.pyc deleted file mode 100644 index d06e1dc..0000000 Binary files a/backend/api/__pycache__/settings.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/suppliers.cpython-313.pyc b/backend/api/__pycache__/suppliers.cpython-313.pyc deleted file mode 100644 index 6252f3a..0000000 Binary files a/backend/api/__pycache__/suppliers.cpython-313.pyc and /dev/null differ diff --git a/backend/api/__pycache__/support.cpython-313.pyc b/backend/api/__pycache__/support.cpython-313.pyc deleted file mode 100644 index 051e5bd..0000000 Binary files a/backend/api/__pycache__/support.cpython-313.pyc and /dev/null differ diff --git a/backend/api/backup.py b/backend/api/backup.py deleted file mode 100644 index da6b2ab..0000000 --- a/backend/api/backup.py +++ /dev/null @@ -1,374 +0,0 @@ -""" -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 deleted file mode 100644 index 28d9e65..0000000 --- a/backend/api/budget.py +++ /dev/null @@ -1,1121 +0,0 @@ -""" -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 deleted file mode 100644 index 2461420..0000000 --- a/backend/api/calendar_events.py +++ /dev/null @@ -1,236 +0,0 @@ -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 deleted file mode 100644 index 4ff295c..0000000 --- a/backend/api/cost_distributions.py +++ /dev/null @@ -1,863 +0,0 @@ -""" -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 deleted file mode 100644 index f123e73..0000000 --- a/backend/api/cover_overrides.py +++ /dev/null @@ -1,648 +0,0 @@ -""" -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 deleted file mode 100644 index 0cadc2a..0000000 --- a/backend/api/credit_notes.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -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 deleted file mode 100644 index 012f0a1..0000000 --- a/backend/api/disputes.py +++ /dev/null @@ -1,1125 +0,0 @@ -""" -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 deleted file mode 100644 index fb9920f..0000000 --- a/backend/api/event_orders.py +++ /dev/null @@ -1,694 +0,0 @@ -""" -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 deleted file mode 100644 index 860fe5a..0000000 --- a/backend/api/external.py +++ /dev/null @@ -1,497 +0,0 @@ -""" -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 deleted file mode 100644 index 2ecabdc..0000000 --- a/backend/api/field_mappings.py +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index 3a9c594..0000000 --- a/backend/api/food_flags.py +++ /dev/null @@ -1,2309 +0,0 @@ -""" -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 deleted file mode 100644 index b2863bb..0000000 --- a/backend/api/imap.py +++ /dev/null @@ -1,341 +0,0 @@ -""" -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 deleted file mode 100644 index 2982884..0000000 --- a/backend/api/ingredients.py +++ /dev/null @@ -1,2013 +0,0 @@ -""" -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 deleted file mode 100644 index 0d9ba2e..0000000 --- a/backend/api/internal.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -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 deleted file mode 100644 index 8e3d686..0000000 --- a/backend/api/invoices.py +++ /dev/null @@ -1,4077 +0,0 @@ -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 deleted file mode 100644 index 3acac85..0000000 --- a/backend/api/logbook.py +++ /dev/null @@ -1,745 +0,0 @@ -""" -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 deleted file mode 100644 index 0af6049..0000000 --- a/backend/api/menus.py +++ /dev/null @@ -1,1308 +0,0 @@ -""" -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 deleted file mode 100644 index 985b1e0..0000000 --- a/backend/api/newbook.py +++ /dev/null @@ -1,1016 +0,0 @@ -""" -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 deleted file mode 100644 index 7f92f96..0000000 --- a/backend/api/public.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -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 deleted file mode 100644 index d6275e1..0000000 --- a/backend/api/purchase_orders.py +++ /dev/null @@ -1,835 +0,0 @@ -""" -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 deleted file mode 100644 index 7cb4b42..0000000 --- a/backend/api/recipes.py +++ /dev/null @@ -1,2318 +0,0 @@ -""" -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 deleted file mode 100644 index 5948012..0000000 --- a/backend/api/reconciliation.py +++ /dev/null @@ -1,1187 +0,0 @@ -""" -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 deleted file mode 100644 index 2aa0a00..0000000 --- a/backend/api/reports.py +++ /dev/null @@ -1,3421 +0,0 @@ -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 deleted file mode 100644 index feceef4..0000000 --- a/backend/api/residents_table_chart.py +++ /dev/null @@ -1,422 +0,0 @@ -""" -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 deleted file mode 100644 index 9d9a9b2..0000000 --- a/backend/api/resos.py +++ /dev/null @@ -1,809 +0,0 @@ -""" -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 deleted file mode 100644 index 06d9c86..0000000 --- a/backend/api/sambapos.py +++ /dev/null @@ -1,690 +0,0 @@ -""" -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 deleted file mode 100644 index 68eb7ec..0000000 --- a/backend/api/search.py +++ /dev/null @@ -1,744 +0,0 @@ -""" -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 deleted file mode 100644 index a88603e..0000000 --- a/backend/api/settings.py +++ /dev/null @@ -1,798 +0,0 @@ -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 deleted file mode 100644 index 92bf916..0000000 --- a/backend/api/suppliers.py +++ /dev/null @@ -1,343 +0,0 @@ -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 deleted file mode 100644 index 40f6e8c..0000000 --- a/backend/api/support.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -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} diff --git a/backend/migrations/__pycache__/__init__.cpython-313.pyc b/backend/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index aebd520..0000000 Binary files a/backend/migrations/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_admin_restricted_pages.cpython-313.pyc b/backend/migrations/__pycache__/add_admin_restricted_pages.cpython-313.pyc deleted file mode 100644 index 276727e..0000000 Binary files a/backend/migrations/__pycache__/add_admin_restricted_pages.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_allergen_keywords.cpython-313.pyc b/backend/migrations/__pycache__/add_allergen_keywords.cpython-313.pyc deleted file mode 100644 index e08356d..0000000 Binary files a/backend/migrations/__pycache__/add_allergen_keywords.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_awaiting_replacement_status.cpython-313.pyc b/backend/migrations/__pycache__/add_awaiting_replacement_status.cpython-313.pyc deleted file mode 100644 index c9e5140..0000000 Binary files a/backend/migrations/__pycache__/add_awaiting_replacement_status.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_brakes_cache.cpython-313.pyc b/backend/migrations/__pycache__/add_brakes_cache.cpython-313.pyc deleted file mode 100644 index efd437c..0000000 Binary files a/backend/migrations/__pycache__/add_brakes_cache.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_brakes_dietary_info.cpython-313.pyc b/backend/migrations/__pycache__/add_brakes_dietary_info.cpython-313.pyc deleted file mode 100644 index 522e141..0000000 Binary files a/backend/migrations/__pycache__/add_brakes_dietary_info.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_budget_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_budget_settings.cpython-313.pyc deleted file mode 100644 index 54a5fd9..0000000 Binary files a/backend/migrations/__pycache__/add_budget_settings.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_calendar_events.cpython-313.pyc b/backend/migrations/__pycache__/add_calendar_events.cpython-313.pyc deleted file mode 100644 index 38bd31b..0000000 Binary files a/backend/migrations/__pycache__/add_calendar_events.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_changelog_invoice_link.cpython-313.pyc b/backend/migrations/__pycache__/add_changelog_invoice_link.cpython-313.pyc deleted file mode 100644 index 3ada167..0000000 Binary files a/backend/migrations/__pycache__/add_changelog_invoice_link.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_cost_distributions.cpython-313.pyc b/backend/migrations/__pycache__/add_cost_distributions.cpython-313.pyc deleted file mode 100644 index 55e5e3f..0000000 Binary files a/backend/migrations/__pycache__/add_cost_distributions.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_cover_overrides.cpython-313.pyc b/backend/migrations/__pycache__/add_cover_overrides.cpython-313.pyc deleted file mode 100644 index 25e409b..0000000 Binary files a/backend/migrations/__pycache__/add_cover_overrides.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_description_aliases.cpython-313.pyc b/backend/migrations/__pycache__/add_description_aliases.cpython-313.pyc deleted file mode 100644 index 2c54449..0000000 Binary files a/backend/migrations/__pycache__/add_description_aliases.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_dext_integration.cpython-313.pyc b/backend/migrations/__pycache__/add_dext_integration.cpython-313.pyc deleted file mode 100644 index 556a92b..0000000 Binary files a/backend/migrations/__pycache__/add_dext_integration.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_dext_manual_send.cpython-313.pyc b/backend/migrations/__pycache__/add_dext_manual_send.cpython-313.pyc deleted file mode 100644 index 63f877b..0000000 Binary files a/backend/migrations/__pycache__/add_dext_manual_send.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_dish_separation.cpython-313.pyc b/backend/migrations/__pycache__/add_dish_separation.cpython-313.pyc deleted file mode 100644 index cbe2c32..0000000 Binary files a/backend/migrations/__pycache__/add_dish_separation.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_dispute_attachment_public_hash.cpython-313.pyc b/backend/migrations/__pycache__/add_dispute_attachment_public_hash.cpython-313.pyc deleted file mode 100644 index 447aed3..0000000 Binary files a/backend/migrations/__pycache__/add_dispute_attachment_public_hash.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_flag_assessment.cpython-313.pyc b/backend/migrations/__pycache__/add_flag_assessment.cpython-313.pyc deleted file mode 100644 index f99f1ae..0000000 Binary files a/backend/migrations/__pycache__/add_flag_assessment.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_gross_sell_price.cpython-313.pyc b/backend/migrations/__pycache__/add_gross_sell_price.cpython-313.pyc deleted file mode 100644 index 5e1a88a..0000000 Binary files a/backend/migrations/__pycache__/add_gross_sell_price.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_imap_integration.cpython-313.pyc b/backend/migrations/__pycache__/add_imap_integration.cpython-313.pyc deleted file mode 100644 index 083b310..0000000 Binary files a/backend/migrations/__pycache__/add_imap_integration.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_ingredient_flag_dismissals.cpython-313.pyc b/backend/migrations/__pycache__/add_ingredient_flag_dismissals.cpython-313.pyc deleted file mode 100644 index 1cfca44..0000000 Binary files a/backend/migrations/__pycache__/add_ingredient_flag_dismissals.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_ingredient_is_free.cpython-313.pyc b/backend/migrations/__pycache__/add_ingredient_is_free.cpython-313.pyc deleted file mode 100644 index 44b9619..0000000 Binary files a/backend/migrations/__pycache__/add_ingredient_is_free.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_invoice_disputes.cpython-313.pyc b/backend/migrations/__pycache__/add_invoice_disputes.cpython-313.pyc deleted file mode 100644 index 32a6a56..0000000 Binary files a/backend/migrations/__pycache__/add_invoice_disputes.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_invoice_features.cpython-313.pyc b/backend/migrations/__pycache__/add_invoice_features.cpython-313.pyc deleted file mode 100644 index 3c1ba30..0000000 Binary files a/backend/migrations/__pycache__/add_invoice_features.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_kds_bookings_refresh.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_bookings_refresh.cpython-313.pyc deleted file mode 100644 index 2c3ff88..0000000 Binary files a/backend/migrations/__pycache__/add_kds_bookings_refresh.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_kds_course_flow.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_course_flow.cpython-313.pyc deleted file mode 100644 index 79786b9..0000000 Binary files a/backend/migrations/__pycache__/add_kds_course_flow.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_kds_order_tracking.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_order_tracking.cpython-313.pyc deleted file mode 100644 index 7c6161a..0000000 Binary files a/backend/migrations/__pycache__/add_kds_order_tracking.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_kds_tables.cpython-313.pyc b/backend/migrations/__pycache__/add_kds_tables.cpython-313.pyc deleted file mode 100644 index 65e62f3..0000000 Binary files a/backend/migrations/__pycache__/add_kds_tables.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_kitchen_details.cpython-313.pyc b/backend/migrations/__pycache__/add_kitchen_details.cpython-313.pyc deleted file mode 100644 index 70aba34..0000000 Binary files a/backend/migrations/__pycache__/add_kitchen_details.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_line_item_search.cpython-313.pyc b/backend/migrations/__pycache__/add_line_item_search.cpython-313.pyc deleted file mode 100644 index 07894ef..0000000 Binary files a/backend/migrations/__pycache__/add_line_item_search.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_linked_dispute.cpython-313.pyc b/backend/migrations/__pycache__/add_linked_dispute.cpython-313.pyc deleted file mode 100644 index 64145a3..0000000 Binary files a/backend/migrations/__pycache__/add_linked_dispute.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_llm_infrastructure.cpython-313.pyc b/backend/migrations/__pycache__/add_llm_infrastructure.cpython-313.pyc deleted file mode 100644 index 36a3487..0000000 Binary files a/backend/migrations/__pycache__/add_llm_infrastructure.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_logbook.cpython-313.pyc b/backend/migrations/__pycache__/add_logbook.cpython-313.pyc deleted file mode 100644 index af6e584..0000000 Binary files a/backend/migrations/__pycache__/add_logbook.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_menus.cpython-313.pyc b/backend/migrations/__pycache__/add_menus.cpython-313.pyc deleted file mode 100644 index cd25b78..0000000 Binary files a/backend/migrations/__pycache__/add_menus.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_new_status.cpython-313.pyc b/backend/migrations/__pycache__/add_new_status.cpython-313.pyc deleted file mode 100644 index fc53637..0000000 Binary files a/backend/migrations/__pycache__/add_new_status.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_newbook_arrival_tracking.cpython-313.pyc b/backend/migrations/__pycache__/add_newbook_arrival_tracking.cpython-313.pyc deleted file mode 100644 index 83f2911..0000000 Binary files a/backend/migrations/__pycache__/add_newbook_arrival_tracking.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_newbook_tables.cpython-313.pyc b/backend/migrations/__pycache__/add_newbook_tables.cpython-313.pyc deleted file mode 100644 index d24a9ca..0000000 Binary files a/backend/migrations/__pycache__/add_newbook_tables.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_newbook_upcoming_sync.cpython-313.pyc b/backend/migrations/__pycache__/add_newbook_upcoming_sync.cpython-313.pyc deleted file mode 100644 index 8d421e5..0000000 Binary files a/backend/migrations/__pycache__/add_newbook_upcoming_sync.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_nextcloud_backup.cpython-313.pyc b/backend/migrations/__pycache__/add_nextcloud_backup.cpython-313.pyc deleted file mode 100644 index 34e9c88..0000000 Binary files a/backend/migrations/__pycache__/add_nextcloud_backup.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_ocr_post_processing.cpython-313.pyc b/backend/migrations/__pycache__/add_ocr_post_processing.cpython-313.pyc deleted file mode 100644 index 6075f35..0000000 Binary files a/backend/migrations/__pycache__/add_ocr_post_processing.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_ocr_weight_setting.cpython-313.pyc b/backend/migrations/__pycache__/add_ocr_weight_setting.cpython-313.pyc deleted file mode 100644 index d8f2e61..0000000 Binary files a/backend/migrations/__pycache__/add_ocr_weight_setting.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_pdf_annotation_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_pdf_annotation_settings.cpython-313.pyc deleted file mode 100644 index eb12c1f..0000000 Binary files a/backend/migrations/__pycache__/add_pdf_annotation_settings.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_price_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_price_settings.cpython-313.pyc deleted file mode 100644 index a232412..0000000 Binary files a/backend/migrations/__pycache__/add_price_settings.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_purchase_orders.cpython-313.pyc b/backend/migrations/__pycache__/add_purchase_orders.cpython-313.pyc deleted file mode 100644 index 474b08a..0000000 Binary files a/backend/migrations/__pycache__/add_purchase_orders.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_recipe_batch_type.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_batch_type.cpython-313.pyc deleted file mode 100644 index decdea2..0000000 Binary files a/backend/migrations/__pycache__/add_recipe_batch_type.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_recipe_ingredient_unit.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_ingredient_unit.cpython-313.pyc deleted file mode 100644 index 8447c44..0000000 Binary files a/backend/migrations/__pycache__/add_recipe_ingredient_unit.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_recipe_ingredient_yield.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_ingredient_yield.cpython-313.pyc deleted file mode 100644 index 9f97c27..0000000 Binary files a/backend/migrations/__pycache__/add_recipe_ingredient_yield.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_recipe_system.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_system.cpython-313.pyc deleted file mode 100644 index 92e142e..0000000 Binary files a/backend/migrations/__pycache__/add_recipe_system.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_recipe_text_flag_dismissals.cpython-313.pyc b/backend/migrations/__pycache__/add_recipe_text_flag_dismissals.cpython-313.pyc deleted file mode 100644 index a4983c9..0000000 Binary files a/backend/migrations/__pycache__/add_recipe_text_flag_dismissals.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_residents_table_chart.cpython-313.pyc b/backend/migrations/__pycache__/add_residents_table_chart.cpython-313.pyc deleted file mode 100644 index 698ee5d..0000000 Binary files a/backend/migrations/__pycache__/add_residents_table_chart.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_resos_arrival_widget_filter.cpython-313.pyc b/backend/migrations/__pycache__/add_resos_arrival_widget_filter.cpython-313.pyc deleted file mode 100644 index 073c994..0000000 Binary files a/backend/migrations/__pycache__/add_resos_arrival_widget_filter.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_resos_integration.cpython-313.pyc b/backend/migrations/__pycache__/add_resos_integration.cpython-313.pyc deleted file mode 100644 index 2f587d0..0000000 Binary files a/backend/migrations/__pycache__/add_resos_integration.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_resos_manual_breakfast.cpython-313.pyc b/backend/migrations/__pycache__/add_resos_manual_breakfast.cpython-313.pyc deleted file mode 100644 index 2a7f48c..0000000 Binary files a/backend/migrations/__pycache__/add_resos_manual_breakfast.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_resos_upcoming_sync.cpython-313.pyc b/backend/migrations/__pycache__/add_resos_upcoming_sync.cpython-313.pyc deleted file mode 100644 index 56c9453..0000000 Binary files a/backend/migrations/__pycache__/add_resos_upcoming_sync.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_rooms_breakdown.cpython-313.pyc b/backend/migrations/__pycache__/add_rooms_breakdown.cpython-313.pyc deleted file mode 100644 index 3fe501a..0000000 Binary files a/backend/migrations/__pycache__/add_rooms_breakdown.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_sambapos_excluded_items.cpython-313.pyc b/backend/migrations/__pycache__/add_sambapos_excluded_items.cpython-313.pyc deleted file mode 100644 index 0a91b34..0000000 Binary files a/backend/migrations/__pycache__/add_sambapos_excluded_items.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_sambapos_portion_name.cpython-313.pyc b/backend/migrations/__pycache__/add_sambapos_portion_name.cpython-313.pyc deleted file mode 100644 index 63da320..0000000 Binary files a/backend/migrations/__pycache__/add_sambapos_portion_name.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_sambapos_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_sambapos_settings.cpython-313.pyc deleted file mode 100644 index 36418f3..0000000 Binary files a/backend/migrations/__pycache__/add_sambapos_settings.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_step_title.cpython-313.pyc b/backend/migrations/__pycache__/add_step_title.cpython-313.pyc deleted file mode 100644 index e306a6c..0000000 Binary files a/backend/migrations/__pycache__/add_step_title.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_sub_recipe_unit.cpython-313.pyc b/backend/migrations/__pycache__/add_sub_recipe_unit.cpython-313.pyc deleted file mode 100644 index eb03aaa..0000000 Binary files a/backend/migrations/__pycache__/add_sub_recipe_unit.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_supplier_po_fields.cpython-313.pyc b/backend/migrations/__pycache__/add_supplier_po_fields.cpython-313.pyc deleted file mode 100644 index d4faeb8..0000000 Binary files a/backend/migrations/__pycache__/add_supplier_po_fields.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_supplier_skip_dext.cpython-313.pyc b/backend/migrations/__pycache__/add_supplier_skip_dext.cpython-313.pyc deleted file mode 100644 index 6b4286b..0000000 Binary files a/backend/migrations/__pycache__/add_supplier_skip_dext.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_support_request.cpython-313.pyc b/backend/migrations/__pycache__/add_support_request.cpython-313.pyc deleted file mode 100644 index 912027a..0000000 Binary files a/backend/migrations/__pycache__/add_support_request.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_unique_flag_types.cpython-313.pyc b/backend/migrations/__pycache__/add_unique_flag_types.cpython-313.pyc deleted file mode 100644 index ea61bd4..0000000 Binary files a/backend/migrations/__pycache__/add_unique_flag_types.cpython-313.pyc and /dev/null differ diff --git a/backend/migrations/__pycache__/add_vat_rate_settings.cpython-313.pyc b/backend/migrations/__pycache__/add_vat_rate_settings.cpython-313.pyc deleted file mode 100644 index 3beed02..0000000 Binary files a/backend/migrations/__pycache__/add_vat_rate_settings.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/__init__.cpython-313.pyc b/backend/models/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b379d40..0000000 Binary files a/backend/models/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/acknowledged_price.cpython-313.pyc b/backend/models/__pycache__/acknowledged_price.cpython-313.pyc deleted file mode 100644 index c72ea5a..0000000 Binary files a/backend/models/__pycache__/acknowledged_price.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/backup.cpython-313.pyc b/backend/models/__pycache__/backup.cpython-313.pyc deleted file mode 100644 index a1f04ef..0000000 Binary files a/backend/models/__pycache__/backup.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/calendar_events.cpython-313.pyc b/backend/models/__pycache__/calendar_events.cpython-313.pyc deleted file mode 100644 index 50333e4..0000000 Binary files a/backend/models/__pycache__/calendar_events.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/cost_distribution.cpython-313.pyc b/backend/models/__pycache__/cost_distribution.cpython-313.pyc deleted file mode 100644 index e92d742..0000000 Binary files a/backend/models/__pycache__/cost_distribution.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/cover_override.cpython-313.pyc b/backend/models/__pycache__/cover_override.cpython-313.pyc deleted file mode 100644 index 408371f..0000000 Binary files a/backend/models/__pycache__/cover_override.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/dispute.cpython-313.pyc b/backend/models/__pycache__/dispute.cpython-313.pyc deleted file mode 100644 index dae6259..0000000 Binary files a/backend/models/__pycache__/dispute.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/email_processing.cpython-313.pyc b/backend/models/__pycache__/email_processing.cpython-313.pyc deleted file mode 100644 index 28bf804..0000000 Binary files a/backend/models/__pycache__/email_processing.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/event_order.cpython-313.pyc b/backend/models/__pycache__/event_order.cpython-313.pyc deleted file mode 100644 index 7452e68..0000000 Binary files a/backend/models/__pycache__/event_order.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/field_mapping.cpython-313.pyc b/backend/models/__pycache__/field_mapping.cpython-313.pyc deleted file mode 100644 index ac1a769..0000000 Binary files a/backend/models/__pycache__/field_mapping.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/food_flag.cpython-313.pyc b/backend/models/__pycache__/food_flag.cpython-313.pyc deleted file mode 100644 index af2bdad..0000000 Binary files a/backend/models/__pycache__/food_flag.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/gp.cpython-313.pyc b/backend/models/__pycache__/gp.cpython-313.pyc deleted file mode 100644 index c991ad9..0000000 Binary files a/backend/models/__pycache__/gp.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/ingredient.cpython-313.pyc b/backend/models/__pycache__/ingredient.cpython-313.pyc deleted file mode 100644 index aa8ee92..0000000 Binary files a/backend/models/__pycache__/ingredient.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/invoice.cpython-313.pyc b/backend/models/__pycache__/invoice.cpython-313.pyc deleted file mode 100644 index 0dacf5f..0000000 Binary files a/backend/models/__pycache__/invoice.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/kds.cpython-313.pyc b/backend/models/__pycache__/kds.cpython-313.pyc deleted file mode 100644 index 7a258d4..0000000 Binary files a/backend/models/__pycache__/kds.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/line_item.cpython-313.pyc b/backend/models/__pycache__/line_item.cpython-313.pyc deleted file mode 100644 index c19bacd..0000000 Binary files a/backend/models/__pycache__/line_item.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/llm.cpython-313.pyc b/backend/models/__pycache__/llm.cpython-313.pyc deleted file mode 100644 index f7a9a54..0000000 Binary files a/backend/models/__pycache__/llm.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/logbook.cpython-313.pyc b/backend/models/__pycache__/logbook.cpython-313.pyc deleted file mode 100644 index 6fb53f6..0000000 Binary files a/backend/models/__pycache__/logbook.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/menu.cpython-313.pyc b/backend/models/__pycache__/menu.cpython-313.pyc deleted file mode 100644 index 062dd48..0000000 Binary files a/backend/models/__pycache__/menu.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/newbook.cpython-313.pyc b/backend/models/__pycache__/newbook.cpython-313.pyc deleted file mode 100644 index 669cf2f..0000000 Binary files a/backend/models/__pycache__/newbook.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/product_definition.cpython-313.pyc b/backend/models/__pycache__/product_definition.cpython-313.pyc deleted file mode 100644 index b84aafd..0000000 Binary files a/backend/models/__pycache__/product_definition.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/purchase_order.cpython-313.pyc b/backend/models/__pycache__/purchase_order.cpython-313.pyc deleted file mode 100644 index 9ff33ed..0000000 Binary files a/backend/models/__pycache__/purchase_order.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/recipe.cpython-313.pyc b/backend/models/__pycache__/recipe.cpython-313.pyc deleted file mode 100644 index 7c6fe62..0000000 Binary files a/backend/models/__pycache__/recipe.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/resos.cpython-313.pyc b/backend/models/__pycache__/resos.cpython-313.pyc deleted file mode 100644 index a680306..0000000 Binary files a/backend/models/__pycache__/resos.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/settings.cpython-313.pyc b/backend/models/__pycache__/settings.cpython-313.pyc deleted file mode 100644 index 5850378..0000000 Binary files a/backend/models/__pycache__/settings.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/supplier.cpython-313.pyc b/backend/models/__pycache__/supplier.cpython-313.pyc deleted file mode 100644 index 12c6273..0000000 Binary files a/backend/models/__pycache__/supplier.cpython-313.pyc and /dev/null differ diff --git a/backend/models/__pycache__/user.cpython-313.pyc b/backend/models/__pycache__/user.cpython-313.pyc deleted file mode 100644 index 87d6f45..0000000 Binary files a/backend/models/__pycache__/user.cpython-313.pyc and /dev/null differ diff --git a/backend/ocr/__pycache__/__init__.cpython-313.pyc b/backend/ocr/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 5d77db6..0000000 Binary files a/backend/ocr/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/ocr/__pycache__/azure_extractor.cpython-313.pyc b/backend/ocr/__pycache__/azure_extractor.cpython-313.pyc deleted file mode 100644 index 48bbf6f..0000000 Binary files a/backend/ocr/__pycache__/azure_extractor.cpython-313.pyc and /dev/null differ diff --git a/backend/ocr/__pycache__/extractor.cpython-313.pyc b/backend/ocr/__pycache__/extractor.cpython-313.pyc deleted file mode 100644 index 4e2a190..0000000 Binary files a/backend/ocr/__pycache__/extractor.cpython-313.pyc and /dev/null differ diff --git a/backend/ocr/__pycache__/parser.cpython-313.pyc b/backend/ocr/__pycache__/parser.cpython-313.pyc deleted file mode 100644 index 1ee5467..0000000 Binary files a/backend/ocr/__pycache__/parser.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/__init__.cpython-313.pyc b/backend/services/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 080d4cd..0000000 Binary files a/backend/services/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/backup_service.cpython-313.pyc b/backend/services/__pycache__/backup_service.cpython-313.pyc deleted file mode 100644 index b8b8a4f..0000000 Binary files a/backend/services/__pycache__/backup_service.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/brakes_scraper.cpython-313.pyc b/backend/services/__pycache__/brakes_scraper.cpython-313.pyc deleted file mode 100644 index 69cd31b..0000000 Binary files a/backend/services/__pycache__/brakes_scraper.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/dispute_archival_service.cpython-313.pyc b/backend/services/__pycache__/dispute_archival_service.cpython-313.pyc deleted file mode 100644 index 27555df..0000000 Binary files a/backend/services/__pycache__/dispute_archival_service.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/duplicate_detector.cpython-313.pyc b/backend/services/__pycache__/duplicate_detector.cpython-313.pyc deleted file mode 100644 index 15d0096..0000000 Binary files a/backend/services/__pycache__/duplicate_detector.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/email_service.cpython-313.pyc b/backend/services/__pycache__/email_service.cpython-313.pyc deleted file mode 100644 index 0e38d90..0000000 Binary files a/backend/services/__pycache__/email_service.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/file_archival_service.cpython-313.pyc b/backend/services/__pycache__/file_archival_service.cpython-313.pyc deleted file mode 100644 index 0849cd9..0000000 Binary files a/backend/services/__pycache__/file_archival_service.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/forecast_api.cpython-313.pyc b/backend/services/__pycache__/forecast_api.cpython-313.pyc deleted file mode 100644 index 26bf068..0000000 Binary files a/backend/services/__pycache__/forecast_api.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/imap_sync.cpython-313.pyc b/backend/services/__pycache__/imap_sync.cpython-313.pyc deleted file mode 100644 index 0f76e32..0000000 Binary files a/backend/services/__pycache__/imap_sync.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/kds_graphql.cpython-313.pyc b/backend/services/__pycache__/kds_graphql.cpython-313.pyc deleted file mode 100644 index bc281a4..0000000 Binary files a/backend/services/__pycache__/kds_graphql.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/llm_service.cpython-313.pyc b/backend/services/__pycache__/llm_service.cpython-313.pyc deleted file mode 100644 index 66f8164..0000000 Binary files a/backend/services/__pycache__/llm_service.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/newbook_api.cpython-313.pyc b/backend/services/__pycache__/newbook_api.cpython-313.pyc deleted file mode 100644 index 0ca2ae0..0000000 Binary files a/backend/services/__pycache__/newbook_api.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/newbook_sync.cpython-313.pyc b/backend/services/__pycache__/newbook_sync.cpython-313.pyc deleted file mode 100644 index ca93764..0000000 Binary files a/backend/services/__pycache__/newbook_sync.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/nextcloud_service.cpython-313.pyc b/backend/services/__pycache__/nextcloud_service.cpython-313.pyc deleted file mode 100644 index 56569fa..0000000 Binary files a/backend/services/__pycache__/nextcloud_service.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/pdf_highlighter.cpython-313.pyc b/backend/services/__pycache__/pdf_highlighter.cpython-313.pyc deleted file mode 100644 index c0edf1a..0000000 Binary files a/backend/services/__pycache__/pdf_highlighter.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/pdf_rotation.cpython-313.pyc b/backend/services/__pycache__/pdf_rotation.cpython-313.pyc deleted file mode 100644 index 31c95c3..0000000 Binary files a/backend/services/__pycache__/pdf_rotation.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/po_matching.cpython-313.pyc b/backend/services/__pycache__/po_matching.cpython-313.pyc deleted file mode 100644 index 194539a..0000000 Binary files a/backend/services/__pycache__/po_matching.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/price_history.cpython-313.pyc b/backend/services/__pycache__/price_history.cpython-313.pyc deleted file mode 100644 index 2d010e0..0000000 Binary files a/backend/services/__pycache__/price_history.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/resos_api.cpython-313.pyc b/backend/services/__pycache__/resos_api.cpython-313.pyc deleted file mode 100644 index db13d91..0000000 Binary files a/backend/services/__pycache__/resos_api.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/resos_stats.cpython-313.pyc b/backend/services/__pycache__/resos_stats.cpython-313.pyc deleted file mode 100644 index d517b17..0000000 Binary files a/backend/services/__pycache__/resos_stats.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/resos_sync.cpython-313.pyc b/backend/services/__pycache__/resos_sync.cpython-313.pyc deleted file mode 100644 index 6618c90..0000000 Binary files a/backend/services/__pycache__/resos_sync.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/sambapos_api.cpython-313.pyc b/backend/services/__pycache__/sambapos_api.cpython-313.pyc deleted file mode 100644 index cae84ba..0000000 Binary files a/backend/services/__pycache__/sambapos_api.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/signalr_listener.cpython-313.pyc b/backend/services/__pycache__/signalr_listener.cpython-313.pyc deleted file mode 100644 index 9e57e9f..0000000 Binary files a/backend/services/__pycache__/signalr_listener.cpython-313.pyc and /dev/null differ diff --git a/backend/services/__pycache__/stock_history.cpython-313.pyc b/backend/services/__pycache__/stock_history.cpython-313.pyc deleted file mode 100644 index 5ff99e9..0000000 Binary files a/backend/services/__pycache__/stock_history.cpython-313.pyc and /dev/null differ