Issue Description
-{description.replace(chr(10), '
')}
A screenshot of the page is attached to this email.
-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"""
| Code | -Description | -Unit | -Price | -Qty | -Total | -
|---|
{' | '.join(parts)}
" - - kitchen_font_size = "font-size:16px;" if format == "kitchen" else "" - - return f""" - - - -- {recipe_type.upper()} - {f'Yield: {output_qty:g}{output_unit}' if recipe_type == 'component' and batch_output_type == 'bulk' else (f'Batch: {batch} portions' if recipe_type == 'component' else '')} -
- {time_info} - {f'{description}
' if description else ''} -| Ingredient | -Quantity | -Cost | -
|---|
Printed {date.today().strftime("%d/%m/%Y")}
'} - -""" - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -# Unit conversion factors to a common base (g for weight, ml for volume) -_UNIT_TO_BASE = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0} - -# Which units are compatible (same measurement type) -_COMPATIBLE_UNITS = { - "g": ("g", "kg"), "kg": ("g", "kg"), - "ml": ("ml", "ltr"), "ltr": ("ml", "ltr"), - "portion": ("portion",), "each": ("each",), -} - - -def _convert_unit(value: float, from_unit: str, to_unit: str) -> float: - """Convert a value between compatible units. Returns original value if incompatible.""" - if from_unit == to_unit: - return value - from_base = _UNIT_TO_BASE.get(from_unit) - to_base = _UNIT_TO_BASE.get(to_unit) - if from_base is None or to_base is None: - return value - # Check compatibility - if to_unit not in _COMPATIBLE_UNITS.get(from_unit, ()): - return value - return value * from_base / to_base - - -def _get_compatible_units(unit: str) -> list[str]: - """Get list of compatible units for a given output unit.""" - return list(_COMPATIBLE_UNITS.get(unit, (unit,))) - - -def _get_output_qty(recipe) -> float: - """Unified output quantity: bulk uses yield_qty, portioned uses batch_portions.""" - if recipe.batch_output_type == "bulk" and recipe.batch_yield_qty: - return float(recipe.batch_yield_qty) - return recipe.batch_portions or 1 - -def _get_output_unit(recipe) -> str: - """Unified output unit label: bulk uses yield_unit, portioned uses 'portion'.""" - if recipe.batch_output_type == "bulk" and recipe.batch_yield_unit: - return recipe.batch_yield_unit - return "portion" - -async def _get_recipe(recipe_id: int, kitchen_id: int, db: AsyncSession) -> Recipe: - result = await db.execute( - select(Recipe).where(Recipe.id == recipe_id, Recipe.kitchen_id == kitchen_id) - ) - recipe = result.scalar_one_or_none() - if not recipe: - raise HTTPException(404, "Recipe not found") - return recipe diff --git a/backend/api/reconciliation.py b/backend/api/reconciliation.py 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":{description.replace(chr(10), '
')}
A screenshot of the page is attached to this email.
-