Add .gitignore, remove __pycache__ from tracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:16:04 +00:00
parent 8d688b459d
commit c8fad1cf36
181 changed files with 8 additions and 29855 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.pyo
.env
*.egg-info/
dist/
node_modules/
frontend/dist/

View file

@ -1,3 +0,0 @@
from . import invoices, suppliers, reports, newbook, resos
__all__ = ["invoices", "suppliers", "reports", "newbook", "resos"]

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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
]
}

View file

@ -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,
)

View file

@ -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}

View file

@ -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"}

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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)

View file

@ -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"}

File diff suppressed because it is too large Load diff

View file

@ -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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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
]

File diff suppressed because it is too large Load diff

View file

@ -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"}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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
}

View file

@ -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 = "<br>".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", "<br>") 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"""<tr>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.product_code or "")}</td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.description or "")}</td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.unit or "")}</td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{currency}{li.unit_price:.2f}</td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{li.quantity:g}</td>
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{currency}{li.total:.2f}</td>
</tr>"""
items_html = f"""
<table style="width:100%;border-collapse:collapse;margin-top:20px;font-size:14px;">
<thead>
<tr style="background:#f5f5f5;">
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Code</th>
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Description</th>
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Unit</th>
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Price</th>
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Qty</th>
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Total</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>"""
elif po.order_type == "single_value":
items_html = f"""
<div style="margin-top:20px;padding:12px;background:#f9f9f9;border-radius:6px;">
<strong>Order Value:</strong> {currency}{float(po.total_amount or 0):.2f}
{f'<br><strong>Order Ref:</strong> {esc(po.order_reference)}' if po.order_reference else ''}
</div>"""
total_amount = float(po.total_amount or 0)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Purchase Order {po_number}</title>
<style>
body {{ font-family: Arial, Helvetica, sans-serif; color: #333; margin: 0; padding: 0; }}
.page {{ max-width: 800px; margin: 20px auto; padding: 40px; }}
@media print {{
body {{ margin: 0; }}
.page {{ max-width: 100%; margin: 0; padding: 20px; }}
.no-print {{ display: none !important; }}
}}
</style>
</head>
<body>
<div class="page">
<!-- Letterhead -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:30px;padding-bottom:20px;border-bottom:3px solid #1a1a2e;">
<div>
<h1 style="margin:0;color:#1a1a2e;font-size:22px;">{kitchen_name}</h1>
<div style="margin-top:6px;font-size:13px;color:#666;line-height:1.5;">{addr_html}</div>
{f'<div style="margin-top:4px;font-size:13px;color:#666;">Tel: {kitchen_phone}</div>' if kitchen_phone else ''}
{f'<div style="font-size:13px;color:#666;">{kitchen_email}</div>' if kitchen_email else ''}
</div>
<div style="text-align:right;">
<h2 style="margin:0;color:#1a1a2e;font-size:24px;">PURCHASE ORDER</h2>
<div style="margin-top:8px;font-size:16px;font-weight:bold;color:#555;">{po_number}</div>
</div>
</div>
<!-- PO Details -->
<div style="display:flex;justify-content:space-between;margin-bottom:20px;">
<div>
<div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;">Supplier</div>
<div style="font-size:16px;font-weight:bold;margin-top:4px;">{supplier_name}</div>
{f'<div style="font-size:13px;color:#666;margin-top:2px;">Account: {account_number}</div>' if account_number else ''}
</div>
<div style="text-align:right;">
<div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;">Date</div>
<div style="font-size:16px;font-weight:bold;margin-top:4px;">{order_date}</div>
<div style="font-size:13px;color:#666;margin-top:2px;">Status: {po.status}</div>
</div>
</div>
<!-- Items -->
{items_html}
<!-- Total -->
<div style="text-align:right;margin-top:16px;padding:12px 10px;border-top:2px solid #1a1a2e;font-size:18px;">
<strong>Total: {currency}{total_amount:.2f}</strong>
</div>
<!-- Notes -->
{f'<div style="margin-top:20px;padding:12px;background:#fffef0;border-left:3px solid #e6c200;border-radius:4px;font-size:13px;"><strong>Notes:</strong><br>{notes}</div>' if notes else ''}
<!-- Print button (hidden on print) -->
<div class="no-print" style="margin-top:30px;text-align:center;">
<button onclick="window.print()" style="padding:10px 24px;background:#1a1a2e;color:white;border:none;border-radius:6px;font-size:14px;cursor:pointer;">
Print / Save PDF
</button>
</div>
</div>
</body>
</html>"""
@router.get("/{po_id}/preview")
async def preview_purchase_order(
po_id: int,
token: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""Return a print-friendly HTML preview of the purchase order (query-param auth)."""
if not token:
raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token")
current_user = await get_current_user_from_token(token, db)
if not current_user:
raise HTTPException(status_code=401, detail="Not authenticated")
po = await _load_po(db, po_id, current_user.kitchen_id)
# Load kitchen settings for letterhead
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
kitchen = settings_result.scalar_one_or_none()
if not kitchen:
kitchen = KitchenSettings(kitchen_id=current_user.kitchen_id)
currency = kitchen.currency_symbol or "£"
html = _build_po_html(po, kitchen, currency)
return HTMLResponse(content=html)
@router.post("/{po_id}/send-email")
async def send_po_email(
po_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Email the PO to the supplier's order_email address."""
po = await _load_po(db, po_id, current_user.kitchen_id)
# Validate supplier has an order email
if not po.supplier or not po.supplier.order_email:
raise HTTPException(status_code=400, detail="Supplier does not have an order email address configured")
# Load kitchen settings for SMTP + letterhead
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
kitchen = settings_result.scalar_one_or_none()
if not kitchen or not kitchen.smtp_host or not kitchen.smtp_from_email:
raise HTTPException(status_code=400, detail="SMTP email is not configured in Settings")
currency = kitchen.currency_symbol or "£"
html = _build_po_html(po, kitchen, currency)
po_number = f"PO-{po.id}"
kitchen_name = kitchen.kitchen_display_name or "Kitchen"
subject = f"Purchase Order {po_number} from {kitchen_name}"
email_service = EmailService(kitchen)
success = email_service.send_email(
to_email=po.supplier.order_email,
subject=subject,
html_body=html,
plain_body=f"Please find attached Purchase Order {po_number}. Total: {currency}{float(po.total_amount or 0):.2f}",
)
if not success:
raise HTTPException(status_code=500, detail="Failed to send email. Check SMTP settings.")
# Update status to PENDING if currently DRAFT
if po.status == "DRAFT":
po.status = "PENDING"
po.updated_by = current_user.id
await db.commit()
return {"ok": True, "message": f"PO emailed to {po.supplier.order_email}", "new_status": po.status}
# ── Invoice Matching ─────────────────────────────────────────────────────────
class LinkRequest(BaseModel):
invoice_id: int
@router.get("/matching/for-invoice")
async def get_matching_pos(
invoice_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Find pending POs that match a given invoice (by supplier)."""
from services.po_matching import find_matching_pos
# Load the invoice
inv_result = await db.execute(
select(Invoice).where(
Invoice.id == invoice_id,
Invoice.kitchen_id == current_user.kitchen_id,
)
)
invoice = inv_result.scalar_one_or_none()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if not invoice.supplier_id:
return {"matches": [], "linked_po": None}
# Check if invoice already has a linked PO
linked_result = await db.execute(
select(PurchaseOrder)
.where(
PurchaseOrder.kitchen_id == current_user.kitchen_id,
PurchaseOrder.linked_invoice_id == invoice_id,
PurchaseOrder.status == "LINKED",
)
.options(
selectinload(PurchaseOrder.supplier),
selectinload(PurchaseOrder.line_items),
)
)
linked_po = linked_result.scalar_one_or_none()
if linked_po:
return {
"matches": [],
"linked_po": {
"po_id": linked_po.id,
"order_date": linked_po.order_date.isoformat() if linked_po.order_date else None,
"total_amount": float(linked_po.total_amount) if linked_po.total_amount else None,
"order_reference": linked_po.order_reference,
"status": linked_po.status,
"order_type": linked_po.order_type,
},
}
# Find matching POs
matches = await find_matching_pos(
db,
kitchen_id=current_user.kitchen_id,
supplier_id=invoice.supplier_id,
invoice_date=invoice.invoice_date,
invoice_total=invoice.total,
)
return {"matches": matches, "linked_po": None}
@router.post("/{po_id}/link", response_model=PurchaseOrderOut)
async def link_po_to_invoice(
po_id: int,
data: LinkRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Link a PO to an invoice. Sets PO status to LINKED."""
from services.po_matching import link_po_to_invoice as do_link
po = await do_link(db, po_id, data.invoice_id, current_user.kitchen_id, current_user.id)
if not po:
raise HTTPException(status_code=404, detail="PO or invoice not found")
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
@router.post("/{po_id}/unlink", response_model=PurchaseOrderOut)
async def unlink_po_from_invoice(
po_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Unlink a PO from its invoice. Returns PO to PENDING status."""
from services.po_matching import unlink_po as do_unlink
po = await do_unlink(db, po_id, current_user.kitchen_id, current_user.id)
if not po:
raise HTTPException(status_code=404, detail="PO not found")
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,422 +0,0 @@
"""
Residents Table Chart API
Gantt-style visualization showing hotel bookings with restaurant table indicators.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from datetime import date, timedelta
from typing import Optional
from pydantic import BaseModel
from auth import get_current_user, require_cap
from database import get_db
from models.user import User
from models.newbook import NewbookDailyOccupancy
from models.resos import ResosBooking
router = APIRouter(prefix="/residents-table-chart", tags=["Residents Table Chart"])
class RestaurantBookingDetail(BaseModel):
has_booking: bool
time: Optional[str] = None
people: Optional[int] = None
table_name: Optional[str] = None
opening_hour_name: Optional[str] = None
is_group_match: Optional[bool] = None # True if matched via group/exclude field (not the lead booking)
class BookingSegment(BaseModel):
booking_id: str | None
bookings_group_id: Optional[str] = None
check_in: str
check_out: str
nights: list[str]
is_dbb: Optional[bool] = None
is_package: Optional[bool] = None
restaurant_bookings: dict[str, RestaurantBookingDetail]
class RoomRow(BaseModel):
room_number: str | None
bookings: list[BookingSegment] # Multiple bookings in the same room
class ResidentsTableChartResponse(BaseModel):
date_range: dict
rooms: list[RoomRow] # Changed from 'bookings' to 'rooms'
summary: dict
metrics: Optional[dict] = None # Aggregated metrics for different time periods
@router.get("")
async def get_residents_table_chart(
start_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> ResidentsTableChartResponse:
"""
Get Gantt-style chart data showing hotel bookings with restaurant table indicators.
Args:
start_date: First day of 7-day period (defaults to today)
Returns:
Chart data with hotel stays and restaurant booking indicators
"""
import logging
logger = logging.getLogger(__name__)
if start_date is None:
start_date = date.today()
logger.info(f"ResidentsTableChart API called with start_date={start_date}")
end_date = start_date + timedelta(days=6) # 7-day period
date_range = {
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"dates": [(start_date + timedelta(days=i)).isoformat() for i in range(7)]
}
# Fetch Newbook occupancy data for 7-day period
result = await db.execute(
select(NewbookDailyOccupancy).where(
and_(
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
NewbookDailyOccupancy.date >= start_date,
NewbookDailyOccupancy.date <= end_date,
NewbookDailyOccupancy.rooms_breakdown.isnot(None) # Only records with room breakdown
)
).order_by(NewbookDailyOccupancy.date)
)
occupancy_records = result.scalars().all()
logger.info(f"Found {len(occupancy_records)} occupancy records")
# Group by room number only (one row per room in Gantt chart)
# Key: room_number, Value: dict of bookings for that room
rooms_dict = {}
for record in occupancy_records:
# Parse JSONB array - each element is a room object for this date
rooms = record.rooms_breakdown or []
for room in rooms:
room_number = room.get("room_number")
booking_id = room.get("booking_id")
if room_number not in rooms_dict:
rooms_dict[room_number] = {}
# Track each booking within this room
if booking_id not in rooms_dict[room_number]:
rooms_dict[room_number][booking_id] = {
'booking_id': booking_id,
'bookings_group_id': room.get("bookings_group_id"),
'nights': [],
'is_dbb': room.get("is_dbb", False),
'is_package': room.get("is_package", False)
}
rooms_dict[room_number][booking_id]['nights'].append(record.date)
# Log rooms with multiple bookings to diagnose stacking issue
for room_number, bookings in rooms_dict.items():
if len(bookings) > 1:
logger.warning(f"Room {room_number} has {len(bookings)} different bookings:")
for booking_id, booking_data in bookings.items():
nights_str = ', '.join(sorted([n.isoformat() for n in booking_data['nights']]))
logger.warning(f" - Booking {booking_id}: nights={nights_str}")
# Convert to list - one entry per room with all its bookings
hotel_stays = []
for room_number, bookings in rooms_dict.items():
# Collect all bookings for this room
room_bookings = []
all_nights = []
for booking_data in bookings.values():
nights = sorted(booking_data['nights'])
if nights:
all_nights.extend(nights)
check_in = nights[0]
check_out = nights[-1] + timedelta(days=1)
room_bookings.append({
'booking_id': booking_data['booking_id'],
'bookings_group_id': booking_data.get('bookings_group_id'),
'check_in': check_in.isoformat(),
'check_out': check_out.isoformat(),
'nights': [n.isoformat() for n in nights],
'is_dbb': booking_data['is_dbb'],
'is_package': booking_data['is_package']
})
# Create one entry per room with all bookings
if room_bookings:
all_nights_sorted = sorted(set(all_nights))
hotel_stays.append({
'room_number': room_number,
'bookings': room_bookings, # Array of all bookings in this room
'all_nights': [n.isoformat() for n in all_nights_sorted] # All occupied nights for this room
})
logger.info(f"Built {len(hotel_stays)} room entries")
# Fetch Resos bookings for hotel guests in this period
result = await db.execute(
select(ResosBooking).where(
and_(
ResosBooking.kitchen_id == current_user.kitchen_id,
ResosBooking.booking_date >= start_date,
ResosBooking.booking_date <= end_date,
ResosBooking.is_hotel_guest == True,
ResosBooking.hotel_booking_number.isnot(None)
)
)
)
resos_bookings = result.scalars().all()
# Build lookup: booking_id -> {date -> resos_booking}
# Also handle group bookings via exclude_flag field (format: "#32990,#32991")
resos_lookup = {}
import re
for resos_booking in resos_bookings:
booking_id = resos_booking.hotel_booking_number
booking_date = resos_booking.booking_date.isoformat()
if booking_id not in resos_lookup:
resos_lookup[booking_id] = {}
# Direct match for the lead/primary booking
resos_lookup[booking_id][booking_date] = {
'has_booking': True,
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
'people': resos_booking.people,
'table_name': resos_booking.table_name,
'opening_hour_name': resos_booking.opening_hour_name,
'is_group_match': False # Direct match, not a group member
}
# Parse exclude_flag for group bookings (format: "#32990,#32991")
if resos_booking.exclude_flag:
# Extract all booking numbers from the exclude_flag field
group_booking_ids = re.findall(r'#(\d+)', resos_booking.exclude_flag)
for group_id in group_booking_ids:
# Skip the lead booking itself (already added above)
if group_id == booking_id:
continue
# Add group member with is_group_match=True
if group_id not in resos_lookup:
resos_lookup[group_id] = {}
# Only add if not already present (don't overwrite direct matches)
if booking_date not in resos_lookup[group_id]:
resos_lookup[group_id][booking_date] = {
'has_booking': True,
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
'people': resos_booking.people,
'table_name': resos_booking.table_name,
'opening_hour_name': resos_booking.opening_hour_name,
'is_group_match': True # Matched via group, not direct
}
logger.info(f"Built resos_lookup with {len(resos_lookup)} booking IDs (including group matches)")
# Combine rooms with restaurant bookings
room_rows = []
total_room_nights = 0
nights_with_restaurant = 0
try:
for room_data in hotel_stays:
booking_segments = []
# Process each booking within this room
for booking_data in room_data['bookings']:
# Build restaurant bookings dict for each night in the 7-day period
restaurant_bookings = {}
for date_str in date_range['dates']:
# Check if this date is within this specific booking's nights
if date_str in booking_data['nights']:
total_room_nights += 1
# Check if there's a restaurant booking for this date
resos_data = resos_lookup.get(booking_data['booking_id'], {}).get(date_str)
if resos_data:
restaurant_bookings[date_str] = resos_data
nights_with_restaurant += 1
else:
restaurant_bookings[date_str] = {'has_booking': False}
else:
# Not staying this night
restaurant_bookings[date_str] = {'has_booking': False}
# Create booking segment with restaurant data
booking_segments.append(BookingSegment(
booking_id=booking_data['booking_id'],
bookings_group_id=booking_data.get('bookings_group_id'),
check_in=booking_data['check_in'],
check_out=booking_data['check_out'],
nights=booking_data['nights'],
is_dbb=booking_data['is_dbb'],
is_package=booking_data['is_package'],
restaurant_bookings=restaurant_bookings
))
# Create room row with all its bookings
room_rows.append(RoomRow(
room_number=room_data['room_number'],
bookings=booking_segments
))
except Exception as e:
logger.error(f"Error building room_rows: {e}", exc_info=True)
raise
logger.info(f"Built {len(room_rows)} room rows")
# Sort rooms by room number (natural sort for numeric rooms)
def natural_sort_key(room: RoomRow):
"""Natural sort key for room numbers (handles both numeric and alphanumeric)"""
if not room.room_number:
return (float('inf'), '') # Put None/empty at end
# Extract numeric part for sorting (e.g., "102" -> 102, "A-12" -> 12)
import re
numbers = re.findall(r'\d+', room.room_number)
if numbers:
return (int(numbers[0]), room.room_number)
return (float('inf'), room.room_number)
room_rows.sort(key=natural_sort_key)
# Calculate summary
coverage_pct = (nights_with_restaurant / total_room_nights * 100) if total_room_nights > 0 else 0.0
# Count total bookings across all rooms
total_bookings = sum(len(room.bookings) for room in room_rows)
summary = {
'total_rooms': len(room_rows),
'total_bookings': total_bookings,
'total_room_nights': total_room_nights,
'nights_with_restaurant': nights_with_restaurant,
'coverage_percentage': round(coverage_pct, 1)
}
# Calculate aggregated metrics for different time periods
def get_week_start(d: date) -> date:
"""Get Monday of the week containing date d"""
return d - timedelta(days=d.weekday())
async def calculate_period_metrics(period_start: date, period_end: date, is_forecast: Optional[bool] = None) -> dict:
"""Calculate metrics for a specific date range"""
query = select(NewbookDailyOccupancy).where(
and_(
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
NewbookDailyOccupancy.date >= period_start,
NewbookDailyOccupancy.date <= period_end,
NewbookDailyOccupancy.rooms_breakdown.isnot(None)
)
)
# Filter by forecast status if specified
if is_forecast is not None:
query = query.where(NewbookDailyOccupancy.is_forecast == is_forecast)
result = await db.execute(query.order_by(NewbookDailyOccupancy.date))
records = result.scalars().all()
# Count metrics
total_room_nights_period = 0
unique_bookings = set()
nights_with_rest = 0
for record in records:
rooms = record.rooms_breakdown or []
for room in rooms:
booking_id = room.get("booking_id")
if booking_id:
unique_bookings.add(booking_id)
total_room_nights_period += 1
# Check if has restaurant booking for this date
date_str = record.date.isoformat()
resos_data = resos_lookup.get(booking_id, {}).get(date_str)
if resos_data:
nights_with_rest += 1
coverage_pct_period = (nights_with_rest / total_room_nights_period * 100) if total_room_nights_period > 0 else 0.0
# Calculate average occupancy
total_available = 0
total_occupied = 0
for record in records:
if record.total_rooms and record.occupied_rooms:
total_available += record.total_rooms
total_occupied += record.occupied_rooms
avg_occupancy = (total_occupied / total_available * 100) if total_available > 0 else 0.0
return {
'total_bookings': len(unique_bookings),
'total_room_nights': total_room_nights_period,
'nights_with_restaurant': nights_with_rest,
'coverage_percentage': round(coverage_pct_period, 1),
'avg_occupancy_percentage': round(avg_occupancy, 1)
}
today = date.today()
# This week (Monday to Sunday)
this_week_start = get_week_start(today)
this_week_end = this_week_start + timedelta(days=6)
# Last week (previous Monday to Sunday)
last_week_start = this_week_start - timedelta(days=7)
last_week_end = last_week_start + timedelta(days=6)
# Last 30 days rolling (from yesterday)
yesterday = today - timedelta(days=1)
rolling_30_start = yesterday - timedelta(days=29)
rolling_30_end = yesterday
# Calculate metrics for each period - always return metrics with default values
default_metrics = {
'total_bookings': 0,
'total_room_nights': 0,
'nights_with_restaurant': 0,
'coverage_percentage': 0.0,
'avg_occupancy_percentage': 0.0
}
try:
metrics = {
'this_week_actual': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=False),
'this_week_forecast': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=True),
'last_week_actual': await calculate_period_metrics(last_week_start, last_week_end, is_forecast=False),
'last_30_days_rolling': await calculate_period_metrics(rolling_30_start, rolling_30_end, is_forecast=False),
}
logger.info(f"Calculated metrics: {metrics}")
except Exception as e:
logger.error(f"Error calculating metrics: {e}", exc_info=True)
# Return default metrics structure instead of None
metrics = {
'this_week_actual': default_metrics.copy(),
'this_week_forecast': default_metrics.copy(),
'last_week_actual': default_metrics.copy(),
'last_30_days_rolling': default_metrics.copy(),
}
return ResidentsTableChartResponse(
date_range=date_range,
rooms=room_rows,
summary=summary,
metrics=metrics
)

View file

@ -1,809 +0,0 @@
"""
Resos API Endpoints
Handles Resos configuration, sync operations, and booking data retrieval.
"""
import logging
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, Date, case, or_
from pydantic import BaseModel
logger = logging.getLogger(__name__)
from database import get_db
from models.user import User
from models.settings import KitchenSettings
from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
from auth import get_current_user, require_cap
from services.resos_sync import ResosSyncService
from services.resos_api import ResosAPIClient, ResosAPIError
router = APIRouter()
# ============ Pydantic Schemas ============
class ResosSettingsResponse(BaseModel):
resos_api_key_set: bool # Masked
resos_last_sync: datetime | None
resos_auto_sync_enabled: bool
resos_upcoming_sync_enabled: bool
resos_upcoming_sync_interval: int
resos_last_upcoming_sync: datetime | None
resos_large_group_threshold: int
resos_note_keywords: str | None
resos_allergy_keywords: str | None
resos_custom_field_mapping: dict | None
resos_opening_hours_mapping: list | None
resos_restaurant_table_entities: str | None
resos_enable_manual_breakfast: bool
resos_manual_breakfast_periods: list | None
resos_flag_icon_mapping: dict | None
resos_arrival_widget_service_filter: str | None # Service type: breakfast/lunch/dinner/other
sambapos_food_gl_codes: str | None # Phase 8.1
sambapos_beverage_gl_codes: str | None # Phase 8.1
class Config:
from_attributes = True
class ResosSettingsUpdate(BaseModel):
resos_api_key: str | None = None
resos_auto_sync_enabled: bool | None = None
resos_upcoming_sync_enabled: bool | None = None
resos_upcoming_sync_interval: int | None = None
resos_large_group_threshold: int | None = None
resos_note_keywords: str | None = None
resos_allergy_keywords: str | None = None
resos_custom_field_mapping: dict | None = None
resos_opening_hours_mapping: list | None = None
resos_restaurant_table_entities: str | None = None
resos_enable_manual_breakfast: bool | None = None
resos_manual_breakfast_periods: list | None = None
resos_flag_icon_mapping: dict | None = None
resos_arrival_widget_service_filter: str | None = None # Opening hour ID for arrivals widget filter
sambapos_food_gl_codes: str | None = None # Phase 8.1
sambapos_beverage_gl_codes: str | None = None # Phase 8.1
class DailyStatsResponse(BaseModel):
date: str
total_bookings: int
total_covers: int
service_breakdown: list[dict]
flagged_booking_count: int
unique_flag_types: list[str] | None
is_forecast: bool
class Config:
from_attributes = True
class BookingResponse(BaseModel):
id: int
resos_booking_id: str
booking_date: str
booking_time: str
people: int
status: str
seating_area: str | None
hotel_booking_number: str | None
is_hotel_guest: bool | None
is_dbb: bool | None
is_package: bool | None
allergies: str | None
notes: str | None
opening_hour_name: str | None
is_flagged: bool
flag_reasons: str | None
class Config:
from_attributes = True
class DashboardCoversResponse(BaseModel):
date: str
total_bookings: int
total_covers: int
service_breakdown: list[dict]
has_flagged_bookings: bool
unique_flag_types: list[str]
class Config:
from_attributes = True
# ============ Settings Endpoints ============
@router.get("/settings")
async def get_resos_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> ResosSettingsResponse:
"""Get Resos settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one()
logger.info(f"[Resos GET] Returning upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
return ResosSettingsResponse(
resos_api_key_set=bool(settings.resos_api_key),
resos_last_sync=settings.resos_last_sync,
resos_auto_sync_enabled=settings.resos_auto_sync_enabled or False,
resos_upcoming_sync_enabled=settings.resos_upcoming_sync_enabled or False,
resos_upcoming_sync_interval=settings.resos_upcoming_sync_interval or 15,
resos_last_upcoming_sync=settings.resos_last_upcoming_sync,
resos_large_group_threshold=settings.resos_large_group_threshold or 8,
resos_note_keywords=settings.resos_note_keywords,
resos_allergy_keywords=settings.resos_allergy_keywords,
resos_custom_field_mapping=settings.resos_custom_field_mapping,
resos_opening_hours_mapping=settings.resos_opening_hours_mapping,
resos_restaurant_table_entities=settings.resos_restaurant_table_entities,
resos_enable_manual_breakfast=settings.resos_enable_manual_breakfast or False,
resos_manual_breakfast_periods=settings.resos_manual_breakfast_periods,
resos_flag_icon_mapping=settings.resos_flag_icon_mapping,
resos_arrival_widget_service_filter=settings.resos_arrival_widget_service_filter,
sambapos_food_gl_codes=settings.sambapos_food_gl_codes, # Phase 8.1
sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes # Phase 8.1
)
@router.patch("/settings")
async def update_resos_settings(
update: ResosSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update Resos settings"""
logger.info(f"[Resos PATCH] Received update: {update.model_dump(exclude_unset=True)}")
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one()
logger.info(f"[Resos PATCH] Before update - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
if update.resos_api_key is not None:
settings.resos_api_key = update.resos_api_key
if update.resos_auto_sync_enabled is not None:
settings.resos_auto_sync_enabled = update.resos_auto_sync_enabled
if update.resos_upcoming_sync_enabled is not None:
logger.info(f"[Resos PATCH] Setting upcoming_sync_enabled to {update.resos_upcoming_sync_enabled}")
settings.resos_upcoming_sync_enabled = update.resos_upcoming_sync_enabled
if update.resos_upcoming_sync_interval is not None:
settings.resos_upcoming_sync_interval = update.resos_upcoming_sync_interval
if update.resos_large_group_threshold is not None:
settings.resos_large_group_threshold = update.resos_large_group_threshold
if update.resos_note_keywords is not None:
settings.resos_note_keywords = update.resos_note_keywords
if update.resos_allergy_keywords is not None:
settings.resos_allergy_keywords = update.resos_allergy_keywords
if update.resos_custom_field_mapping is not None:
settings.resos_custom_field_mapping = update.resos_custom_field_mapping
if update.resos_opening_hours_mapping is not None:
settings.resos_opening_hours_mapping = update.resos_opening_hours_mapping
if update.resos_restaurant_table_entities is not None:
settings.resos_restaurant_table_entities = update.resos_restaurant_table_entities
if update.resos_enable_manual_breakfast is not None:
settings.resos_enable_manual_breakfast = update.resos_enable_manual_breakfast
if update.resos_manual_breakfast_periods is not None:
settings.resos_manual_breakfast_periods = update.resos_manual_breakfast_periods
if update.resos_flag_icon_mapping is not None:
settings.resos_flag_icon_mapping = update.resos_flag_icon_mapping
if update.resos_arrival_widget_service_filter is not None:
settings.resos_arrival_widget_service_filter = update.resos_arrival_widget_service_filter
# Phase 8.1: GL codes for food/beverage spend split
if update.sambapos_food_gl_codes is not None:
settings.sambapos_food_gl_codes = update.sambapos_food_gl_codes
if update.sambapos_beverage_gl_codes is not None:
settings.sambapos_beverage_gl_codes = update.sambapos_beverage_gl_codes
await db.commit()
logger.info(f"[Resos PATCH] After commit - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
return {"message": "Settings updated successfully"}
@router.post("/test-connection")
async def test_resos_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test Resos API connection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one()
if not settings.resos_api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured")
async with ResosAPIClient(settings.resos_api_key) as client:
success = await client.test_connection()
if not success:
raise HTTPException(status_code=400, detail="Connection failed")
return {"message": "Connection successful"}
@router.get("/debug-upcoming-sync")
async def debug_upcoming_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to check upcoming sync settings directly"""
from sqlalchemy import text
# Get value via ORM
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one()
orm_value = settings.resos_upcoming_sync_enabled
# Get value via raw SQL
raw_result = await db.execute(
text("SELECT resos_upcoming_sync_enabled, resos_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"),
{"kid": current_user.kitchen_id}
)
raw_row = raw_result.fetchone()
return {
"orm_value": orm_value,
"raw_db_enabled": raw_row[0] if raw_row else None,
"raw_db_interval": raw_row[1] if raw_row else None,
"column_exists": raw_row is not None
}
@router.get("/custom-fields")
async def fetch_custom_fields(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch custom field definitions from Resos API (GET request only)"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one()
if not settings.resos_api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured")
async with ResosAPIClient(settings.resos_api_key) as client:
fields = await client.get_custom_field_definitions()
return {"custom_fields": fields}
@router.get("/opening-hours")
async def fetch_opening_hours(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch opening hours/service periods from Resos API (GET request only)"""
import logging
logger = logging.getLogger(__name__)
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one()
if not settings.resos_api_key:
raise HTTPException(status_code=400, detail="Resos API key not configured")
async with ResosAPIClient(settings.resos_api_key) as client:
hours = await client.get_opening_hours()
# Log the raw response to understand structure
logger.info(f"Raw opening hours from Resos API: {len(hours)} periods")
# Filter out special/one-off periods - only return regular service periods
# Filter on 'special' field: True = one-off events, False = recurring service periods
regular_hours = [h for h in hours if h.get('special') == False]
# Day of week mapping (Resos uses 1=Monday, 7=Sunday)
day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
# Transform time format: Resos uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00)
# Convert to 'startTime' and 'endTime' in HH:MM format for frontend
for hour in regular_hours:
# Add day of week name
day_num = hour.get('day', 0)
if 1 <= day_num <= 7:
hour['dayName'] = day_names[day_num]
else:
hour['dayName'] = 'Unknown'
if 'open' in hour:
open_val = hour['open']
hours_part = open_val // 100
mins_part = open_val % 100
hour['startTime'] = f"{hours_part:02d}:{mins_part:02d}"
if 'close' in hour:
close_val = hour['close']
hours_part = close_val // 100
mins_part = close_val % 100
hour['endTime'] = f"{hours_part:02d}:{mins_part:02d}"
# Auto-calculate actual end time by subtracting booking duration
# Resos extends close time to allow late bookings
seating = hour.get('seating', {})
duration = seating.get('duration', 0) # Duration in minutes
if duration > 0:
# Convert close time to minutes
close_minutes = hours_part * 60 + mins_part
# Subtract booking duration
actual_end_minutes = close_minutes - duration
# Convert back to HH:MM
actual_hours = actual_end_minutes // 60
actual_mins = actual_end_minutes % 60
hour['actualEnd'] = f"{actual_hours:02d}:{actual_mins:02d}"
hour['bookingDuration'] = duration
# Sort by day of week first, then by open time within each day
regular_hours.sort(key=lambda h: (h.get('day', 0), h.get('open', 0)))
logger.info(f"After filtering: {len(regular_hours)} regular periods (filtered out {len(hours) - len(regular_hours)} special periods)")
return {"opening_hours": regular_hours}
@router.get("/opening-hours/{date}")
async def get_opening_hours_for_date(
date: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get opening hours configuration for a specific date
Returns opening hour periods with their times and intervals.
Used by Gantt chart to determine time range and closed periods.
"""
import logging
logger = logging.getLogger(__name__)
# Parse date
try:
query_date = datetime.fromisoformat(date).date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
# Get kitchen settings
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
if not settings or not settings.resos_api_key:
logger.warning("No Resos API key configured")
return []
# Fetch opening hours from Resos API
async with ResosAPIClient(settings.resos_api_key) as client:
hours = await client.get_opening_hours()
# Filter to regular (non-special) hours and format for Gantt chart
day_of_week = query_date.isoweekday() # Monday=1, Sunday=7
formatted_hours = []
for hour in hours:
# Skip special/one-off periods
if hour.get('special') == True:
continue
# Check if this opening hour applies to the query date's day of week
hour_day = hour.get('day', 0)
if hour_day != day_of_week:
continue
# Get open and close times (already in HHMM format from API)
open_time = hour.get('open', 0)
close_time = hour.get('close', 0)
# Find service type from mapping
resos_id = hour.get('id', '')
service_type = None
if settings.resos_opening_hours_mapping:
for mapping in settings.resos_opening_hours_mapping:
if isinstance(mapping, dict) and mapping.get('resos_id') == resos_id:
service_type = mapping.get('service_type', '')
break
formatted_hours.append({
"name": hour.get('name', ''),
"service_type": service_type or hour.get('name', ''),
"open": open_time,
"close": close_time,
"is_special": False
})
logger.info(f"Found {len(formatted_hours)} opening hours for {query_date} (day {day_of_week})")
return formatted_hours
# ============ Sync Endpoints ============
@router.post("/sync/upcoming")
async def sync_upcoming(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Manual upcoming sync (next 7 days) - triggers immediately and updates last_upcoming_sync timestamp"""
sync_service = ResosSyncService(current_user.kitchen_id, db)
result = await sync_service.run_upcoming_sync()
logger.info(f"[Resos] Manual upcoming sync completed for kitchen {current_user.kitchen_id}")
return result
@router.post("/sync/forecast")
async def sync_forecast(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Manual forecast sync (next 60 days)"""
sync_service = ResosSyncService(current_user.kitchen_id, db)
today = date.today()
to_date = today + timedelta(days=60)
result = await sync_service.sync_bookings(today, to_date, is_forecast=True)
return result
@router.post("/sync/historical")
async def sync_historical(
from_date: date,
to_date: date,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Manual historical sync"""
sync_service = ResosSyncService(current_user.kitchen_id, db)
result = await sync_service.sync_bookings(from_date, to_date, is_forecast=False)
return result
@router.post("/sync/opening-hours")
async def sync_opening_hours(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Sync opening hours from Resos API to local database"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Starting opening hours sync for kitchen {current_user.kitchen_id}")
sync_service = ResosSyncService(current_user.kitchen_id, db)
count = await sync_service.sync_opening_hours()
logger.info(f"Successfully synced {count} opening hours to database")
return {
"message": f"Successfully synced {count} opening hours",
"count": count
}
# ============ Data Retrieval Endpoints ============
@router.get("/daily-stats")
async def get_daily_stats(
from_date: date,
to_date: date,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> list[DailyStatsResponse]:
"""Get daily stats for date range"""
result = await db.execute(
select(ResosDailyStats).where(
and_(
ResosDailyStats.kitchen_id == current_user.kitchen_id,
ResosDailyStats.date >= from_date,
ResosDailyStats.date <= to_date
)
).order_by(ResosDailyStats.date)
)
stats = result.scalars().all()
return [
DailyStatsResponse(
date=stat.date.isoformat(),
total_bookings=stat.total_bookings,
total_covers=stat.total_covers,
service_breakdown=stat.service_breakdown or [],
flagged_booking_count=stat.flagged_booking_count,
unique_flag_types=stat.unique_flag_types,
is_forecast=stat.is_forecast
)
for stat in stats
]
@router.get("/bookings/{booking_date}")
async def get_bookings_for_date(
booking_date: date,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> list[BookingResponse]:
"""Get all bookings for a specific date, excluding cancelled/deleted"""
excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected']
result = await db.execute(
select(ResosBooking).where(
and_(
ResosBooking.kitchen_id == current_user.kitchen_id,
ResosBooking.booking_date == booking_date,
~func.lower(ResosBooking.status).in_(excluded_statuses)
)
).order_by(ResosBooking.booking_time)
)
bookings = result.scalars().all()
return [
BookingResponse(
id=b.id,
resos_booking_id=b.resos_booking_id,
booking_date=b.booking_date.isoformat(),
booking_time=b.booking_time.isoformat(),
people=b.people,
status=b.status,
seating_area=b.seating_area,
hotel_booking_number=b.hotel_booking_number,
is_hotel_guest=b.is_hotel_guest,
is_dbb=b.is_dbb,
is_package=b.is_package,
allergies=b.allergies,
notes=b.notes,
opening_hour_name=b.opening_hour_name,
is_flagged=b.is_flagged,
flag_reasons=b.flag_reasons
)
for b in bookings
]
# ============ Dashboard Endpoint ============
@router.get("/dashboard/today-tomorrow")
async def get_dashboard_covers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> dict:
"""Get today, tomorrow, and day after tomorrow covers for dashboard"""
import logging
logger = logging.getLogger(__name__)
today = date.today()
tomorrow = today + timedelta(days=1)
day_after = today + timedelta(days=2)
# Day names for display
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
result = await db.execute(
select(ResosDailyStats).where(
and_(
ResosDailyStats.kitchen_id == current_user.kitchen_id,
ResosDailyStats.date.in_([today, tomorrow, day_after])
)
)
)
stats = {stat.date: stat for stat in result.scalars().all()}
def build_response(target_date: date, day_label: str) -> Optional[dict]:
if target_date not in stats:
return {
'date': target_date.isoformat(),
'day_label': day_label,
'total_bookings': 0,
'total_covers': 0,
'service_breakdown': [],
'has_flagged_bookings': False,
'unique_flag_types': []
}
stat = stats[target_date]
logger.info(f"Date {target_date}: unique_flag_types={stat.unique_flag_types}, type={type(stat.unique_flag_types)}, flagged_count={stat.flagged_booking_count}")
return {
'date': stat.date.isoformat(),
'day_label': day_label,
'total_bookings': stat.total_bookings,
'total_covers': stat.total_covers,
'service_breakdown': stat.service_breakdown or [],
'has_flagged_bookings': stat.flagged_booking_count > 0,
'unique_flag_types': stat.unique_flag_types or []
}
return {
'today': build_response(today, 'Today'),
'tomorrow': build_response(tomorrow, 'Tomorrow'),
'day_after': build_response(day_after, day_names[day_after.weekday()])
}
# ============ Stats Report Endpoint (Phase 8) ============
@router.get("/stats")
async def get_bookings_stats(
from_date: date,
to_date: date,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> dict:
"""
Get comprehensive booking statistics with spend analysis (Phase 8).
Calculates:
- Total bookings and covers
- Average lead time (days between booking and reservation)
- Spend analysis (food/beverage/total) matched from SambaPOS
- Resident vs non-resident split (by covers and spend)
- Daily breakdown
- Service period breakdown with avg spend per cover
"""
from services.resos_stats import ResosStatsService
# Get summary metrics from resos_bookings
result = await db.execute(
select(
func.count(ResosBooking.id).label('total_bookings'),
func.sum(ResosBooking.people).label('total_covers'),
func.avg(
ResosBooking.booking_date - func.cast(ResosBooking.booked_at, Date)
).label('avg_lead_time_days')
).where(
and_(
ResosBooking.kitchen_id == current_user.kitchen_id,
ResosBooking.booking_date >= from_date,
ResosBooking.booking_date <= to_date,
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved'])
)
)
)
summary = result.first()
total_bookings = summary.total_bookings or 0
total_covers = summary.total_covers or 0
avg_lead_time_days = float(summary.avg_lead_time_days) if summary.avg_lead_time_days else 0.0
# Get resident/non-resident cover counts
result = await db.execute(
select(
func.sum(case((ResosBooking.is_hotel_guest == True, ResosBooking.people), else_=0)).label('resident_covers'),
func.sum(case((ResosBooking.is_hotel_guest == False, ResosBooking.people), else_=0)).label('non_resident_covers')
).where(
and_(
ResosBooking.kitchen_id == current_user.kitchen_id,
ResosBooking.booking_date >= from_date,
ResosBooking.booking_date <= to_date,
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved'])
)
)
)
resident_split = result.first()
resident_covers_booking = resident_split.resident_covers or 0
non_resident_covers_booking = resident_split.non_resident_covers or 0
# Get spend statistics (Phase 8 integration)
stats_service = ResosStatsService(current_user.kitchen_id, db)
spend_stats = await stats_service.get_spend_statistics(from_date, to_date)
# Calculate resident percentages
resident_pct_covers = (resident_covers_booking / total_covers * 100) if total_covers > 0 else 0.0
total_spend = spend_stats['total_spend']
resident_pct_spend = (spend_stats['resident_spend'] / total_spend * 100) if total_spend > 0 else 0.0
# Combine booking data with spend data
return {
'summary': {
'total_bookings': total_bookings,
'total_covers': total_covers,
'avg_lead_time_days': round(avg_lead_time_days, 1),
'resident_covers': resident_covers_booking,
'non_resident_covers': non_resident_covers_booking,
'resident_pct_covers': round(resident_pct_covers, 1),
'resident_pct_spend': round(resident_pct_spend, 1)
},
'spend': {
'total_spend': spend_stats['total_spend'],
'food_spend': spend_stats['food_spend'],
'beverage_spend': spend_stats['beverage_spend'],
'resident_spend': spend_stats['resident_spend'],
'non_resident_spend': spend_stats['non_resident_spend'],
'total_tickets': spend_stats['total_tickets'],
'resident_tickets': spend_stats['resident_tickets'],
'non_resident_tickets': spend_stats['non_resident_tickets'],
'matched_to_resos': spend_stats['matched_to_resos'],
'unmatched_to_resos': spend_stats['unmatched_to_resos'],
'classification': spend_stats['classification']
},
'daily_breakdown': spend_stats['daily_breakdown'],
'service_period_breakdown': spend_stats['service_period_breakdown'],
'daily_service_breakdown': spend_stats['daily_service_breakdown']
}
# ============ Resident Covers for Budget ============
@router.get("/resident-covers")
async def get_resident_covers(
start_date: date,
end_date: date,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
) -> dict:
"""
Get per-date, per-service-period hotel guest (resident) cover counts.
Used by the Budget page to show 'inc Residents' row in the forecast table.
"""
excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected']
# Query bookings grouped by date and opening_hour
result = await db.execute(
select(
ResosBooking.booking_date,
ResosBooking.opening_hour_id,
ResosBooking.opening_hour_name,
func.sum(ResosBooking.people).label('resident_covers'),
func.count(ResosBooking.id).label('resident_bookings'),
).where(
and_(
ResosBooking.kitchen_id == current_user.kitchen_id,
ResosBooking.booking_date >= start_date,
ResosBooking.booking_date <= end_date,
ResosBooking.is_hotel_guest == True,
~func.lower(ResosBooking.status).in_(excluded_statuses)
)
).group_by(
ResosBooking.booking_date,
ResosBooking.opening_hour_id,
ResosBooking.opening_hour_name
)
)
# Get kitchen settings for service type mapping
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None
service_type_map = {}
if opening_hours_mapping:
for mapping in opening_hours_mapping:
if isinstance(mapping, dict):
resos_id = mapping.get('resos_id', '')
service_type = mapping.get('service_type', '')
if resos_id and service_type:
service_type_map[resos_id] = service_type
# Build per-date, per-period response with covers and booking counts
dates: dict[str, dict[str, dict[str, int]]] = {}
for row in result:
date_str = row.booking_date.isoformat()
if date_str not in dates:
dates[date_str] = {}
opening_hour_id = row.opening_hour_id
opening_hour_name = row.opening_hour_name or 'Unknown'
service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name
period = service_type.lower() if service_type else 'unknown'
# Accumulate in case multiple opening hours map to same period
if period not in dates[date_str]:
dates[date_str][period] = {"covers": 0, "bookings": 0}
dates[date_str][period]["covers"] += row.resident_covers or 0
dates[date_str][period]["bookings"] += row.resident_bookings or 0
return {"dates": dates}

View file

@ -1,690 +0,0 @@
"""
SambaPOS API Endpoints
Handles SambaPOS MSSQL configuration and category management.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from database import get_db
from models.user import User
from models.settings import KitchenSettings
from auth import get_current_user, require_cap
from services.sambapos_api import SambaPOSClient
router = APIRouter()
# ============ Pydantic Schemas ============
class SambaPOSSettingsResponse(BaseModel):
sambapos_db_host: str | None
sambapos_db_port: int | None
sambapos_db_name: str | None
sambapos_db_username: str | None
sambapos_db_password_set: bool
sambapos_tracked_categories: list[str]
sambapos_excluded_items: list[str]
class Config:
from_attributes = True
class SambaPOSSettingsUpdate(BaseModel):
sambapos_db_host: str | None = None
sambapos_db_port: int | None = None
sambapos_db_name: str | None = None
sambapos_db_username: str | None = None
sambapos_db_password: str | None = None
class CategoryResponse(BaseModel):
id: int
name: str
class MenuItemResponse(BaseModel):
name: str
category: str
class TrackedCategoriesUpdate(BaseModel):
categories: list[str]
class ExcludedItemsUpdate(BaseModel):
items: list[str]
# ============ Settings Endpoints ============
@router.get("/settings", response_model=SambaPOSSettingsResponse)
async def get_sambapos_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get SambaPOS connection settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse tracked categories from comma-separated string
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
# Parse excluded items from comma-separated string
excluded_items = []
if settings.sambapos_excluded_items:
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
return SambaPOSSettingsResponse(
sambapos_db_host=settings.sambapos_db_host,
sambapos_db_port=settings.sambapos_db_port,
sambapos_db_name=settings.sambapos_db_name,
sambapos_db_username=settings.sambapos_db_username,
sambapos_db_password_set=bool(settings.sambapos_db_password),
sambapos_tracked_categories=tracked_categories,
sambapos_excluded_items=excluded_items
)
@router.patch("/settings", response_model=SambaPOSSettingsResponse)
async def update_sambapos_settings(
update: SambaPOSSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update SambaPOS connection settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Update fields
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(settings, field, value)
await db.commit()
await db.refresh(settings)
# Parse tracked categories from comma-separated string
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
# Parse excluded items from pipe-separated string
excluded_items = []
if settings.sambapos_excluded_items:
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
return SambaPOSSettingsResponse(
sambapos_db_host=settings.sambapos_db_host,
sambapos_db_port=settings.sambapos_db_port,
sambapos_db_name=settings.sambapos_db_name,
sambapos_db_username=settings.sambapos_db_username,
sambapos_db_password_set=bool(settings.sambapos_db_password),
sambapos_tracked_categories=tracked_categories,
sambapos_excluded_items=excluded_items
)
@router.post("/test-connection")
async def test_sambapos_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test SambaPOS database connection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
result = await client.test_connection()
if result["success"]:
return {"status": "success", "message": "SambaPOS connection successful"}
else:
raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}")
# ============ Categories Endpoints ============
@router.get("/categories", response_model=list[CategoryResponse])
async def get_sambapos_categories(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all menu categories from SambaPOS database"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
categories = await client.get_categories()
return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}")
@router.get("/tracked-categories")
async def get_tracked_categories(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get list of category names enabled for top sellers"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse tracked categories from comma-separated string
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
return {"categories": tracked_categories}
@router.patch("/tracked-categories")
async def update_tracked_categories(
update: TrackedCategoriesUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update which categories are included in top sellers"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Store as comma-separated string
settings.sambapos_tracked_categories = ','.join(update.categories)
await db.commit()
return {"status": "success", "categories": update.categories}
@router.get("/debug/menuitems")
async def debug_menuitems(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to explore MenuItems table structure"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.sambapos_db_password:
raise HTTPException(status_code=400, detail="SambaPOS not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_menu_items()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
# ============ Menu Items Endpoints ============
@router.get("/menu-items", response_model=list[MenuItemResponse])
async def get_menu_items(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all unique menu item names with their categories for exclusion selection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
# Get tracked categories to filter menu items
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None)
return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}")
class MenuItemWithPortionResponse(BaseModel):
menu_item_name: str
portion_name: str
category: str
on_pos_menu: bool
@router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse])
async def get_menu_items_with_portions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all menu items with their portion names, grouped by Kitchen Course category."""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
# Get tracked categories to filter
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
items = await client.get_menu_items_with_portions(
categories=tracked_categories if tracked_categories else None
)
return [
MenuItemWithPortionResponse(
menu_item_name=item["menu_item_name"],
portion_name=item["portion_name"],
category=item["category"],
on_pos_menu=item.get("on_pos_menu", False)
)
for item in items
]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}")
@router.get("/excluded-items")
async def get_excluded_items(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get list of menu item names excluded from top sellers report"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse excluded items from pipe-separated string
excluded_items = []
if settings.sambapos_excluded_items:
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
return {"items": excluded_items}
@router.patch("/excluded-items")
async def update_excluded_items(
update: ExcludedItemsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update which menu item GroupCodes are excluded from top sellers report"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Store as pipe-separated string (to allow commas in names)
settings.sambapos_excluded_items = '|'.join(update.items)
await db.commit()
return {"status": "success", "items": update.items}
# ============ Group Codes Endpoints ============
class GroupCodeResponse(BaseModel):
name: str
@router.get("/group-codes", response_model=list[GroupCodeResponse])
async def get_group_codes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all distinct GroupCode values from MenuItems table for exclusion selection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
group_codes = await client.get_menu_group_codes()
return [GroupCodeResponse(name=gc["name"]) for gc in group_codes]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}")
# ============ GL Codes Endpoints (Phase 8) ============
class GLCodeResponse(BaseModel):
code: str
class GLCodesUpdate(BaseModel):
food_codes: list[str]
beverage_codes: list[str]
@router.get("/gl-codes", response_model=list[GLCodeResponse])
async def get_gl_codes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Fetch all unique GL codes from ProductTag custom tags for food/beverage classification"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
gl_codes = await client.get_gl_codes()
return [GLCodeResponse(code=gc["code"]) for gc in gl_codes]
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}")
@router.get("/gl-codes/selected")
async def get_selected_gl_codes(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get currently selected food and beverage GL codes"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Parse food GL codes from comma-separated string
food_codes = []
if settings.sambapos_food_gl_codes:
food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()]
# Parse beverage GL codes from comma-separated string
beverage_codes = []
if settings.sambapos_beverage_gl_codes:
beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()]
return {
"food_codes": food_codes,
"beverage_codes": beverage_codes
}
@router.patch("/gl-codes")
async def update_gl_codes(
update: GLCodesUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update food and beverage GL code selections"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
# Store as comma-separated strings
settings.sambapos_food_gl_codes = ','.join(update.food_codes)
settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes)
await db.commit()
return {
"status": "success",
"food_codes": update.food_codes,
"beverage_codes": update.beverage_codes
}
@router.get("/debug/custom-tags")
async def debug_custom_tags(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to see sample CustomTags from MenuItems"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_menu_items()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}")
@router.get("/debug/zero-price-order-states")
async def debug_zero_price_order_states(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Debug endpoint: returns sample OrderStates blobs for zero-priced orders.
Use this to discover the JSON structure and identify where original/package price is stored.
"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.sambapos_db_password:
raise HTTPException(status_code=400, detail="SambaPOS not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_zero_price_order_states()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/debug/table-schema")
async def debug_table_schema(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Debug endpoint to inspect column names in SambaPOS tables"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Settings not found")
if not all([
settings.sambapos_db_host,
settings.sambapos_db_name,
settings.sambapos_db_username,
settings.sambapos_db_password
]):
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
client = SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
try:
return await client.debug_table_schema()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}")

View file

@ -1,744 +0,0 @@
"""
Search API endpoints for searching invoices, line items, and product definitions.
"""
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_, desc
from pydantic import BaseModel
from database import get_db
from models.user import User
from models.invoice import Invoice, InvoiceStatus
from models.line_item import LineItem
from models.supplier import Supplier
from models.product_definition import ProductDefinition
from models.settings import KitchenSettings
from auth import get_current_user, require_cap
from services.price_history import PriceHistoryService
router = APIRouter(prefix="/api/search", tags=["search"])
# ============ Response Models ============
class GroupSummary(BaseModel):
name: str
count: int
total: Optional[Decimal] = None
class InvoiceSearchItem(BaseModel):
id: int
invoice_number: Optional[str]
invoice_date: Optional[date]
total: Optional[Decimal]
net_total: Optional[Decimal]
supplier_id: Optional[int]
supplier_name: Optional[str]
vendor_name: Optional[str]
status: str
document_type: Optional[str]
class Config:
from_attributes = True
class InvoiceSearchResponse(BaseModel):
items: List[InvoiceSearchItem]
total_count: int
grouped_by: Optional[str]
groups: Optional[List[GroupSummary]]
class LineItemSearchItem(BaseModel):
product_code: Optional[str]
description: Optional[str]
supplier_id: Optional[int]
supplier_name: Optional[str]
unit: Optional[str]
most_recent_price: Optional[Decimal]
earliest_price_in_period: Optional[Decimal]
price_change_percent: Optional[float]
price_change_status: str
total_quantity: Optional[Decimal]
occurrence_count: int
most_recent_invoice_id: Optional[int]
most_recent_invoice_number: Optional[str]
most_recent_date: Optional[date]
has_definition: bool
portions_per_unit: Optional[int]
pack_quantity: Optional[int]
most_recent_line_item_id: Optional[int] = None
most_recent_line_number: Optional[int] = None
most_recent_raw_content: Optional[str] = None
most_recent_pack_quantity: Optional[int] = None
most_recent_unit_size: Optional[Decimal] = None
most_recent_unit_size_type: Optional[str] = None
# Ingredient mapping info
ingredient_id: Optional[int] = None
ingredient_name: Optional[str] = None
ingredient_standard_unit: Optional[str] = None
price_per_std_unit: Optional[Decimal] = None
class Config:
from_attributes = True
class LineItemSearchResponse(BaseModel):
items: List[LineItemSearchItem]
total_count: int
grouped_by: Optional[str]
groups: Optional[List[GroupSummary]]
class DefinitionSearchItem(BaseModel):
id: int
product_code: Optional[str]
description_pattern: Optional[str]
supplier_id: Optional[int]
supplier_name: Optional[str]
pack_quantity: Optional[int]
unit_size: Optional[Decimal]
unit_size_type: Optional[str]
portions_per_unit: Optional[int]
portion_description: Optional[str]
source_invoice_id: Optional[int]
source_invoice_number: Optional[str]
most_recent_price: Optional[Decimal]
updated_at: datetime
class Config:
from_attributes = True
class DefinitionSearchResponse(BaseModel):
items: List[DefinitionSearchItem]
total_count: int
class PriceHistoryPointResponse(BaseModel):
date: date
price: Decimal
invoice_id: int
invoice_number: Optional[str]
quantity: Optional[Decimal]
class LineItemHistoryResponse(BaseModel):
product_code: Optional[str]
description: Optional[str]
supplier_id: int
supplier_name: Optional[str]
price_history: List[PriceHistoryPointResponse]
total_occurrences: int
total_quantity: Decimal
avg_qty_per_invoice: Decimal
avg_qty_per_week: Decimal
avg_qty_per_month: Decimal
current_price: Optional[Decimal]
price_change_status: str
class AcknowledgePriceRequest(BaseModel):
product_code: Optional[str] = None
description: Optional[str] = None
supplier_id: int
new_price: Decimal
source_invoice_id: Optional[int] = None
source_line_item_id: Optional[int] = None
class AcknowledgePriceResponse(BaseModel):
id: int
acknowledged_price: Decimal
acknowledged_at: datetime
# ============ Invoice Search ============
@router.get("/invoices", response_model=InvoiceSearchResponse)
async def search_invoices(
q: str = "",
include_line_items: bool = False,
supplier_id: Optional[int] = None,
status: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
group_by: Optional[str] = None,
limit: int = Query(default=100, le=500),
offset: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Search invoices with optional filters.
- q: Search term (invoice_number, vendor_name)
- include_line_items: Also search line item product_code/description
- supplier_id: Filter by supplier
- status: Filter by status (pending, confirmed, etc.)
- date_from/date_to: Date range (default: last 30 days)
- group_by: "supplier" or "month" for grouped results
"""
# Default date range: last 30 days
if date_to is None:
date_to = date.today()
if date_from is None:
date_from = date_to - timedelta(days=30)
# Build base conditions
conditions = [
Invoice.kitchen_id == current_user.kitchen_id,
Invoice.invoice_date >= date_from,
Invoice.invoice_date <= date_to,
]
if supplier_id:
conditions.append(Invoice.supplier_id == supplier_id)
if status:
conditions.append(Invoice.status == status)
# Search filter
if q:
search_pattern = f"%{q}%"
search_conditions = [
Invoice.invoice_number.ilike(search_pattern),
Invoice.vendor_name.ilike(search_pattern),
]
if include_line_items:
# Need to join line items and search there too
line_item_subquery = (
select(LineItem.invoice_id)
.where(or_(
LineItem.product_code.ilike(search_pattern),
LineItem.description.ilike(search_pattern)
))
.distinct()
)
search_conditions.append(Invoice.id.in_(line_item_subquery))
conditions.append(or_(*search_conditions))
# Get total count
count_query = select(func.count(Invoice.id)).where(and_(*conditions))
count_result = await db.execute(count_query)
total_count = count_result.scalar() or 0
# Get invoices with supplier name
query = (
select(Invoice, Supplier.name.label('supplier_name'))
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
.where(and_(*conditions))
.order_by(desc(Invoice.invoice_date))
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
rows = result.fetchall()
items = [
InvoiceSearchItem(
id=row.Invoice.id,
invoice_number=row.Invoice.invoice_number,
invoice_date=row.Invoice.invoice_date,
total=row.Invoice.total,
net_total=row.Invoice.net_total,
supplier_id=row.Invoice.supplier_id,
supplier_name=row.supplier_name,
vendor_name=row.Invoice.vendor_name,
status=row.Invoice.status.value if isinstance(row.Invoice.status, InvoiceStatus) else row.Invoice.status,
document_type=row.Invoice.document_type
)
for row in rows
]
# Handle grouping
groups = None
if group_by == "supplier":
group_query = (
select(
Supplier.name,
func.count(Invoice.id).label('count'),
func.sum(Invoice.net_total).label('total')
)
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
.where(and_(*conditions))
.group_by(Supplier.name)
.order_by(desc('total'))
)
group_result = await db.execute(group_query)
groups = [
GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2])
for row in group_result.fetchall()
]
elif group_by == "month":
group_query = (
select(
func.to_char(Invoice.invoice_date, 'YYYY-MM').label('month'),
func.count(Invoice.id).label('count'),
func.sum(Invoice.net_total).label('total')
)
.where(and_(*conditions))
.group_by(func.to_char(Invoice.invoice_date, 'YYYY-MM'))
.order_by(desc('month'))
)
group_result = await db.execute(group_query)
groups = [
GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2])
for row in group_result.fetchall()
]
return InvoiceSearchResponse(
items=items,
total_count=total_count,
grouped_by=group_by,
groups=groups
)
# ============ Line Items Search (Consolidated) ============
@router.get("/line-items", response_model=LineItemSearchResponse)
async def search_line_items(
q: str = "",
supplier_id: Optional[int] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
group_by: Optional[str] = None,
mapped: Optional[str] = Query(default=None, description="Filter by ingredient mapping: 'yes', 'no'"),
limit: int = Query(default=100, le=500),
offset: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Search line items with consolidation.
Returns DISTINCT line items by (product_code OR description + supplier),
with most recent price, price change status, total quantity, occurrence count.
"""
price_service = PriceHistoryService(db, current_user.kitchen_id)
items_data, total_count = await price_service.get_consolidated_line_items(
search_query=q if q else None,
supplier_id=supplier_id,
date_from=date_from,
date_to=date_to,
limit=limit,
offset=offset
)
items = [LineItemSearchItem(**item) for item in items_data]
# Filter by ingredient mapping status
if mapped == 'yes':
items = [i for i in items if i.ingredient_id is not None]
total_count = len(items)
elif mapped == 'no':
items = [i for i in items if i.ingredient_id is None]
total_count = len(items)
# Handle grouping (for UI display)
groups = None
if group_by == "supplier":
# Group items by supplier
supplier_groups = {}
for item in items:
name = item.supplier_name or "Unknown"
if name not in supplier_groups:
supplier_groups[name] = {"count": 0, "total": Decimal(0)}
supplier_groups[name]["count"] += item.occurrence_count
if item.total_quantity:
supplier_groups[name]["total"] += item.total_quantity
groups = [
GroupSummary(name=name, count=data["count"], total=data["total"])
for name, data in sorted(supplier_groups.items(), key=lambda x: -x[1]["count"])
]
return LineItemSearchResponse(
items=items,
total_count=total_count,
grouped_by=group_by,
groups=groups
)
# ============ Definitions Search ============
@router.get("/definitions", response_model=DefinitionSearchResponse)
async def search_definitions(
q: str = "",
supplier_id: Optional[int] = None,
has_portions: Optional[bool] = None,
limit: int = Query(default=100, le=500),
offset: int = 0,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Search product definitions.
- q: Search term (product_code, description_pattern)
- supplier_id: Filter by supplier
- has_portions: Filter by whether portions_per_unit is set
"""
conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id]
if supplier_id:
conditions.append(ProductDefinition.supplier_id == supplier_id)
if q:
search_pattern = f"%{q}%"
conditions.append(or_(
ProductDefinition.product_code.ilike(search_pattern),
ProductDefinition.description_pattern.ilike(search_pattern)
))
if has_portions is not None:
if has_portions:
conditions.append(ProductDefinition.portions_per_unit.isnot(None))
else:
conditions.append(ProductDefinition.portions_per_unit.is_(None))
# Get total count
count_query = select(func.count(ProductDefinition.id)).where(and_(*conditions))
count_result = await db.execute(count_query)
total_count = count_result.scalar() or 0
# Get definitions with supplier name and source invoice number
query = (
select(
ProductDefinition,
Supplier.name.label('supplier_name'),
Invoice.invoice_number.label('source_invoice_number')
)
.outerjoin(Supplier, ProductDefinition.supplier_id == Supplier.id)
.outerjoin(Invoice, ProductDefinition.source_invoice_id == Invoice.id)
.where(and_(*conditions))
.order_by(desc(ProductDefinition.updated_at))
.limit(limit)
.offset(offset)
)
result = await db.execute(query)
rows = result.fetchall()
items = []
for row in rows:
definition = row.ProductDefinition
# Get most recent price from matching line items
most_recent_price = None
price_conditions = [Invoice.kitchen_id == current_user.kitchen_id]
if definition.supplier_id:
price_conditions.append(Invoice.supplier_id == definition.supplier_id)
if definition.product_code:
price_conditions.append(LineItem.product_code == definition.product_code)
elif definition.description_pattern:
price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%"))
if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id
price_query = (
select(LineItem.unit_price)
.join(Invoice, LineItem.invoice_id == Invoice.id)
.where(and_(*price_conditions))
.order_by(desc(Invoice.invoice_date))
.limit(1)
)
price_result = await db.execute(price_query)
price_row = price_result.scalar_one_or_none()
if price_row is not None:
most_recent_price = price_row
items.append(DefinitionSearchItem(
id=definition.id,
product_code=definition.product_code,
description_pattern=definition.description_pattern,
supplier_id=definition.supplier_id,
supplier_name=row.supplier_name,
pack_quantity=definition.pack_quantity,
unit_size=definition.unit_size,
unit_size_type=definition.unit_size_type,
portions_per_unit=definition.portions_per_unit,
portion_description=definition.portion_description,
source_invoice_id=definition.source_invoice_id,
source_invoice_number=row.source_invoice_number,
most_recent_price=most_recent_price,
updated_at=definition.updated_at
))
return DefinitionSearchResponse(items=items, total_count=total_count)
# ============ Line Item History ============
@router.get("/line-items/history", response_model=LineItemHistoryResponse)
async def get_line_item_history(
supplier_id: int,
product_code: Optional[str] = None,
description: Optional[str] = None,
unit: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Get price and quantity history for a specific line item.
Used by the history modal to show price chart and stats.
"""
if not product_code and not description:
raise HTTPException(
status_code=400,
detail="Either product_code or description is required"
)
price_service = PriceHistoryService(db, current_user.kitchen_id)
history = await price_service.get_history(
supplier_id=supplier_id,
product_code=product_code,
description=description,
unit=unit,
date_from=date_from,
date_to=date_to
)
return LineItemHistoryResponse(
product_code=history.product_code,
description=history.description,
supplier_id=history.supplier_id,
supplier_name=history.supplier_name,
price_history=[
PriceHistoryPointResponse(
date=point.date,
price=point.price,
invoice_id=point.invoice_id,
invoice_number=point.invoice_number,
quantity=point.quantity
)
for point in history.price_history
],
total_occurrences=history.total_occurrences,
total_quantity=history.total_quantity,
avg_qty_per_invoice=history.avg_qty_per_invoice,
avg_qty_per_week=history.avg_qty_per_week,
avg_qty_per_month=history.avg_qty_per_month,
current_price=history.current_price,
price_change_status=history.price_change_status
)
# ============ Price Acknowledgement ============
@router.post("/line-items/acknowledge-price", response_model=AcknowledgePriceResponse)
async def acknowledge_price_change(
request: AcknowledgePriceRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Acknowledge a price change for a line item.
Creates or updates the AcknowledgedPrice record so the price
won't be flagged as changed in future.
"""
if not request.product_code and not request.description:
raise HTTPException(
status_code=400,
detail="Either product_code or description is required"
)
price_service = PriceHistoryService(db, current_user.kitchen_id)
acknowledged = await price_service.acknowledge_price(
user_id=current_user.id,
supplier_id=request.supplier_id,
product_code=request.product_code,
description=request.description,
new_price=request.new_price,
source_invoice_id=request.source_invoice_id,
source_line_item_id=request.source_line_item_id
)
return AcknowledgePriceResponse(
id=acknowledged.id,
acknowledged_price=acknowledged.acknowledged_price,
acknowledged_at=acknowledged.acknowledged_at
)
# ============ Search Settings ============
class SearchSettingsResponse(BaseModel):
price_change_lookback_days: int
price_change_amber_threshold: int
price_change_red_threshold: int
class SearchSettingsUpdate(BaseModel):
price_change_lookback_days: Optional[int] = None
price_change_amber_threshold: Optional[int] = None
price_change_red_threshold: Optional[int] = None
@router.get("/settings", response_model=SearchSettingsResponse)
async def get_search_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get search/price change settings."""
result = await db.execute(
select(KitchenSettings).where(
KitchenSettings.kitchen_id == current_user.kitchen_id
)
)
settings = result.scalar_one_or_none()
return SearchSettingsResponse(
price_change_lookback_days=settings.price_change_lookback_days if settings else 30,
price_change_amber_threshold=settings.price_change_amber_threshold if settings else 10,
price_change_red_threshold=settings.price_change_red_threshold if settings else 20
)
@router.patch("/settings", response_model=SearchSettingsResponse)
async def update_search_settings(
update: SearchSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update search/price change settings."""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
result = await db.execute(
select(KitchenSettings).where(
KitchenSettings.kitchen_id == current_user.kitchen_id
)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(settings, field, value)
await db.commit()
await db.refresh(settings)
return SearchSettingsResponse(
price_change_lookback_days=settings.price_change_lookback_days,
price_change_amber_threshold=settings.price_change_amber_threshold,
price_change_red_threshold=settings.price_change_red_threshold
)
# ============ Definition Update ============
class DefinitionUpdateRequest(BaseModel):
pack_quantity: Optional[int] = None
unit_size: Optional[Decimal] = None
unit_size_type: Optional[str] = None
portions_per_unit: Optional[int] = None
portion_description: Optional[str] = None
@router.patch("/definitions/{definition_id}", response_model=DefinitionSearchItem)
async def update_definition(
definition_id: int,
update: DefinitionUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update a product definition."""
# Fetch the definition
result = await db.execute(
select(ProductDefinition).where(
ProductDefinition.id == definition_id,
ProductDefinition.kitchen_id == current_user.kitchen_id
)
)
definition = result.scalar_one_or_none()
if not definition:
raise HTTPException(status_code=404, detail="Definition not found")
# Update fields
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(definition, field, value)
# Update saved_by metadata
definition.saved_by_user_id = current_user.id
await db.commit()
await db.refresh(definition)
# Get supplier name for response
supplier_name = None
if definition.supplier_id:
supplier_result = await db.execute(
select(Supplier.name).where(Supplier.id == definition.supplier_id)
)
supplier_name = supplier_result.scalar_one_or_none()
# Get source invoice number
source_invoice_number = None
if definition.source_invoice_id:
invoice_result = await db.execute(
select(Invoice.invoice_number).where(Invoice.id == definition.source_invoice_id)
)
source_invoice_number = invoice_result.scalar_one_or_none()
# Get most recent price
most_recent_price = None
price_conditions = [Invoice.kitchen_id == current_user.kitchen_id]
if definition.supplier_id:
price_conditions.append(Invoice.supplier_id == definition.supplier_id)
if definition.product_code:
price_conditions.append(LineItem.product_code == definition.product_code)
elif definition.description_pattern:
price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%"))
if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id
price_query = (
select(LineItem.unit_price)
.join(Invoice, LineItem.invoice_id == Invoice.id)
.where(and_(*price_conditions))
.order_by(desc(Invoice.invoice_date))
.limit(1)
)
price_result = await db.execute(price_query)
price_row = price_result.scalar_one_or_none()
if price_row is not None:
most_recent_price = price_row
return DefinitionSearchItem(
id=definition.id,
product_code=definition.product_code,
description_pattern=definition.description_pattern,
supplier_id=definition.supplier_id,
supplier_name=supplier_name,
pack_quantity=definition.pack_quantity,
unit_size=definition.unit_size,
unit_size_type=definition.unit_size_type,
portions_per_unit=definition.portions_per_unit,
portion_description=definition.portion_description,
source_invoice_id=definition.source_invoice_id,
source_invoice_number=source_invoice_number,
most_recent_price=most_recent_price,
updated_at=definition.updated_at
)

View file

@ -1,798 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from database import get_db
from models.user import User
from models.settings import KitchenSettings
from auth import get_current_user, require_cap
router = APIRouter()
class SettingsResponse(BaseModel):
azure_endpoint: str | None
azure_key_set: bool # Don't expose the actual key, just whether it's set
currency_symbol: str
date_format: str
high_quantity_threshold: int
# SMTP settings
smtp_host: str | None
smtp_port: int | None
smtp_username: str | None
smtp_password_set: bool # Don't expose the actual password
smtp_use_tls: bool
smtp_from_email: str | None
smtp_from_name: str | None
support_email: str | None
# Dext integration
dext_email: str | None
dext_include_notes: bool
dext_include_non_stock: bool
dext_auto_send_enabled: bool
dext_manual_send_enabled: bool
dext_include_annotations: bool
# PDF annotation settings
pdf_annotations_enabled: bool
pdf_preview_show_annotations: bool
# OCR post-processing options
ocr_clean_product_codes: bool
ocr_filter_subtotal_rows: bool
ocr_use_weight_as_quantity: bool
# Cost distribution settings
cost_distribution_max_days: int
# LLM settings — see LLM-MANIFEST.md for removal instructions
llm_enabled: bool = False
anthropic_api_key_set: bool = False # Don't expose the actual key
llm_model: str | None = None
llm_confidence_threshold: float | None = None
llm_monthly_token_limit: int = 500000
llm_features_enabled: dict | None = None
class Config:
from_attributes = True
class SettingsUpdate(BaseModel):
azure_endpoint: str | None = None
azure_key: str | None = None
currency_symbol: str | None = None
date_format: str | None = None
high_quantity_threshold: int | None = None
# SMTP settings
smtp_host: str | None = None
smtp_port: int | None = None
smtp_username: str | None = None
smtp_password: str | None = None # Only set if provided
smtp_use_tls: bool | None = None
smtp_from_email: str | None = None
smtp_from_name: str | None = None
support_email: str | None = None
# Dext integration
dext_email: str | None = None
dext_include_notes: bool | None = None
dext_include_non_stock: bool | None = None
dext_auto_send_enabled: bool | None = None
dext_manual_send_enabled: bool | None = None
dext_include_annotations: bool | None = None
# PDF annotation settings
pdf_annotations_enabled: bool | None = None
pdf_preview_show_annotations: bool | None = None
# OCR post-processing options
ocr_clean_product_codes: bool | None = None
ocr_filter_subtotal_rows: bool | None = None
ocr_use_weight_as_quantity: bool | None = None
# Cost distribution settings
cost_distribution_max_days: int | None = None
# LLM settings — see LLM-MANIFEST.md for removal instructions
llm_enabled: bool | None = None
anthropic_api_key: str | None = None # Only set if provided
llm_model: str | None = None
llm_confidence_threshold: float | None = None
llm_monthly_token_limit: int | None = None
llm_features_enabled: dict | None = None
@router.get("/", response_model=SettingsResponse)
async def get_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get current kitchen settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
# Create default settings if none exist
settings = KitchenSettings(
kitchen_id=current_user.kitchen_id,
currency_symbol="£",
date_format="DD/MM/YYYY"
)
db.add(settings)
await db.commit()
await db.refresh(settings)
return _build_settings_response(settings)
def _build_settings_response(settings: KitchenSettings) -> SettingsResponse:
"""Build SettingsResponse from a KitchenSettings model instance."""
return SettingsResponse(
azure_endpoint=settings.azure_endpoint,
azure_key_set=bool(settings.azure_key),
currency_symbol=settings.currency_symbol,
date_format=settings.date_format,
high_quantity_threshold=settings.high_quantity_threshold,
# SMTP settings
smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port,
smtp_username=settings.smtp_username,
smtp_password_set=bool(settings.smtp_password),
smtp_use_tls=settings.smtp_use_tls,
smtp_from_email=settings.smtp_from_email,
smtp_from_name=settings.smtp_from_name,
support_email=settings.support_email,
# Dext integration
dext_email=settings.dext_email,
dext_include_notes=settings.dext_include_notes,
dext_include_non_stock=settings.dext_include_non_stock,
dext_auto_send_enabled=settings.dext_auto_send_enabled,
dext_manual_send_enabled=settings.dext_manual_send_enabled,
dext_include_annotations=settings.dext_include_annotations,
# PDF annotation settings
pdf_annotations_enabled=settings.pdf_annotations_enabled,
pdf_preview_show_annotations=settings.pdf_preview_show_annotations,
# OCR post-processing options
ocr_clean_product_codes=settings.ocr_clean_product_codes,
ocr_filter_subtotal_rows=settings.ocr_filter_subtotal_rows,
ocr_use_weight_as_quantity=settings.ocr_use_weight_as_quantity,
cost_distribution_max_days=settings.cost_distribution_max_days,
# LLM settings — see LLM-MANIFEST.md for removal instructions
llm_enabled=settings.llm_enabled,
anthropic_api_key_set=bool(settings.anthropic_api_key),
llm_model=settings.llm_model,
llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None,
llm_monthly_token_limit=settings.llm_monthly_token_limit,
llm_features_enabled=settings.llm_features_enabled,
)
@router.patch("/", response_model=SettingsResponse)
async def update_settings(
update: SettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update kitchen settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
# Update fields
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
if value is not None:
setattr(settings, field, value)
await db.commit()
await db.refresh(settings)
return _build_settings_response(settings)
@router.post("/test-azure")
async def test_azure_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test Azure Document Intelligence connection"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.azure_endpoint or not settings.azure_key:
raise HTTPException(
status_code=400,
detail="Azure credentials not configured"
)
try:
from azure.ai.formrecognizer import DocumentAnalysisClient
from azure.core.credentials import AzureKeyCredential
client = DocumentAnalysisClient(
endpoint=settings.azure_endpoint,
credential=AzureKeyCredential(settings.azure_key)
)
# Simple connection test - this will validate credentials
# The actual analysis would happen during invoice processing
return {"status": "success", "message": "Azure connection successful"}
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Azure connection failed: {str(e)}"
)
@router.post("/test-smtp")
async def test_smtp_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test SMTP connection with current settings"""
from services.email_service import EmailService
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.smtp_host or not settings.smtp_from_email:
raise HTTPException(
status_code=400,
detail="SMTP not fully configured. Please set SMTP host and from email."
)
email_service = EmailService(settings)
success, message = email_service.test_connection()
if not success:
raise HTTPException(status_code=400, detail=message)
return {"status": "success", "message": message}
# ============ Kitchen Details Endpoints ============
class KitchenDetailsResponse(BaseModel):
kitchen_display_name: str | None = None
kitchen_address_line1: str | None = None
kitchen_address_line2: str | None = None
kitchen_city: str | None = None
kitchen_postcode: str | None = None
kitchen_phone: str | None = None
kitchen_email: str | None = None
class KitchenDetailsUpdate(BaseModel):
kitchen_display_name: str | None = None
kitchen_address_line1: str | None = None
kitchen_address_line2: str | None = None
kitchen_city: str | None = None
kitchen_postcode: str | None = None
kitchen_phone: str | None = None
kitchen_email: str | None = None
@router.get("/kitchen-details", response_model=KitchenDetailsResponse)
async def get_kitchen_details(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get kitchen details for PO letterhead"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
return KitchenDetailsResponse()
return KitchenDetailsResponse(
kitchen_display_name=settings.kitchen_display_name,
kitchen_address_line1=settings.kitchen_address_line1,
kitchen_address_line2=settings.kitchen_address_line2,
kitchen_city=settings.kitchen_city,
kitchen_postcode=settings.kitchen_postcode,
kitchen_phone=settings.kitchen_phone,
kitchen_email=settings.kitchen_email,
)
@router.patch("/kitchen-details", response_model=KitchenDetailsResponse)
async def update_kitchen_details(
update: KitchenDetailsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update kitchen details for PO letterhead"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(settings, field, value)
await db.commit()
await db.refresh(settings)
return KitchenDetailsResponse(
kitchen_display_name=settings.kitchen_display_name,
kitchen_address_line1=settings.kitchen_address_line1,
kitchen_address_line2=settings.kitchen_address_line2,
kitchen_city=settings.kitchen_city,
kitchen_postcode=settings.kitchen_postcode,
kitchen_phone=settings.kitchen_phone,
kitchen_email=settings.kitchen_email,
)
# ============ Page Restrictions Endpoints ============
class PageRestrictionsResponse(BaseModel):
restricted_pages: list[str]
class PageRestrictionsUpdate(BaseModel):
restricted_pages: list[str]
@router.get("/page-restrictions", response_model=PageRestrictionsResponse)
async def get_page_restrictions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get list of pages restricted to admin users only"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
return PageRestrictionsResponse(restricted_pages=[])
# Parse comma-separated list
restricted = []
if settings.admin_restricted_pages:
restricted = [p.strip() for p in settings.admin_restricted_pages.split(',') if p.strip()]
return PageRestrictionsResponse(restricted_pages=restricted)
@router.patch("/page-restrictions", response_model=PageRestrictionsResponse)
async def update_page_restrictions(
update: PageRestrictionsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update list of pages restricted to admin users only (admin only)"""
if not current_user.is_admin:
raise HTTPException(
status_code=403,
detail="Only admins can modify page restrictions"
)
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
# Store as comma-separated string
settings.admin_restricted_pages = ','.join(update.restricted_pages) if update.restricted_pages else None
await db.commit()
return PageRestrictionsResponse(restricted_pages=update.restricted_pages)
# ============ Nextcloud Settings Endpoints ============
class NextcloudSettingsResponse(BaseModel):
nextcloud_host: str | None
nextcloud_username: str | None
nextcloud_password_set: bool
nextcloud_base_path: str | None
nextcloud_enabled: bool
nextcloud_delete_local: bool
class Config:
from_attributes = True
class NextcloudSettingsUpdate(BaseModel):
nextcloud_host: str | None = None
nextcloud_username: str | None = None
nextcloud_password: str | None = None
nextcloud_base_path: str | None = None
nextcloud_enabled: bool | None = None
nextcloud_delete_local: bool | None = None
class NextcloudStatsResponse(BaseModel):
pending_count: int
archived_count: int
local_count: int
nextcloud_enabled: bool
nextcloud_configured: bool
class NextcloudArchiveResponse(BaseModel):
success_count: int
failed_count: int
errors: list[str]
@router.get("/nextcloud", response_model=NextcloudSettingsResponse)
async def get_nextcloud_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get Nextcloud settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
return NextcloudSettingsResponse(
nextcloud_host=None,
nextcloud_username=None,
nextcloud_password_set=False,
nextcloud_base_path="/Kitchen Invoices",
nextcloud_enabled=False,
nextcloud_delete_local=False
)
return NextcloudSettingsResponse(
nextcloud_host=settings.nextcloud_host,
nextcloud_username=settings.nextcloud_username,
nextcloud_password_set=bool(settings.nextcloud_password),
nextcloud_base_path=settings.nextcloud_base_path,
nextcloud_enabled=settings.nextcloud_enabled,
nextcloud_delete_local=settings.nextcloud_delete_local
)
@router.patch("/nextcloud", response_model=NextcloudSettingsResponse)
async def update_nextcloud_settings(
update: NextcloudSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update Nextcloud settings"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
# Map 'nextcloud_password' to the model field
if field == 'nextcloud_password' and value:
setattr(settings, field, value)
elif value is not None:
setattr(settings, field, value)
await db.commit()
await db.refresh(settings)
return NextcloudSettingsResponse(
nextcloud_host=settings.nextcloud_host,
nextcloud_username=settings.nextcloud_username,
nextcloud_password_set=bool(settings.nextcloud_password),
nextcloud_base_path=settings.nextcloud_base_path,
nextcloud_enabled=settings.nextcloud_enabled,
nextcloud_delete_local=settings.nextcloud_delete_local
)
@router.post("/nextcloud/test")
async def test_nextcloud_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test Nextcloud WebDAV connection"""
from services.nextcloud_service import NextcloudService
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
nc = NextcloudService(
settings.nextcloud_host,
settings.nextcloud_username,
settings.nextcloud_password,
settings.nextcloud_base_path
)
success, message = await nc.test_connection()
await nc.close()
if not success:
raise HTTPException(status_code=400, detail=message)
return {"status": "success", "message": message}
@router.get("/nextcloud/stats", response_model=NextcloudStatsResponse)
async def get_nextcloud_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get Nextcloud archive statistics"""
from services.file_archival_service import FileArchivalService
archival_service = FileArchivalService(db, current_user.kitchen_id)
stats = await archival_service.get_archive_stats()
return NextcloudStatsResponse(**stats)
@router.post("/nextcloud/archive-all", response_model=NextcloudArchiveResponse)
async def archive_all_pending(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Manually archive all pending invoices to Nextcloud"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
from services.file_archival_service import FileArchivalService
archival_service = FileArchivalService(db, current_user.kitchen_id)
success_count, failed_count, errors = await archival_service.archive_all_pending()
return NextcloudArchiveResponse(
success_count=success_count,
failed_count=failed_count,
errors=errors
)
@router.post("/nextcloud/archive/{invoice_id}")
async def archive_single_invoice(
invoice_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Archive a single invoice to Nextcloud (for testing)"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
from models.invoice import Invoice
from services.file_archival_service import FileArchivalService
# Get the invoice
result = await db.execute(
select(Invoice).where(
Invoice.id == invoice_id,
Invoice.kitchen_id == current_user.kitchen_id
)
)
invoice = result.scalar_one_or_none()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice.file_storage_location == "nextcloud":
return {"status": "skipped", "message": "Invoice already archived to Nextcloud"}
# Check Nextcloud config
settings_result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
archival_service = FileArchivalService(db, current_user.kitchen_id)
success, message = await archival_service.archive_invoice_file(invoice)
if not success:
raise HTTPException(status_code=500, detail=message)
return {"status": "success", "message": message, "nextcloud_path": invoice.nextcloud_path}
# ============ API Access Endpoints ============
class ApiAccessResponse(BaseModel):
api_key: str | None
api_key_enabled: bool
class ApiAccessUpdate(BaseModel):
api_key_enabled: bool
@router.get("/api-access", response_model=ApiAccessResponse)
async def get_api_access(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get API access settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
return ApiAccessResponse(api_key=None, api_key_enabled=False)
return ApiAccessResponse(
api_key=settings.api_key,
api_key_enabled=settings.api_key_enabled,
)
@router.patch("/api-access", response_model=ApiAccessResponse)
async def update_api_access(
update: ApiAccessUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update API access settings (enable/disable)"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
settings.api_key_enabled = update.api_key_enabled
await db.commit()
await db.refresh(settings)
return ApiAccessResponse(
api_key=settings.api_key,
api_key_enabled=settings.api_key_enabled,
)
@router.post("/api-access/regenerate")
async def regenerate_api_key(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Generate or regenerate the API key"""
import secrets
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin only")
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
db.add(settings)
new_key = secrets.token_urlsafe(32)
settings.api_key = new_key
settings.api_key_enabled = True
await db.commit()
await db.refresh(settings)
return {"api_key": new_key, "api_key_enabled": True}
# ============ LLM Usage Stats Endpoints ============
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
class LlmUsageStatsResponse(BaseModel):
total_calls: int = 0
successful_calls: int = 0
failed_calls: int = 0
total_input_tokens: int = 0
total_output_tokens: int = 0
total_tokens: int = 0
estimated_cost_usd: float = 0.0
cache_entries_this_month: int = 0
@router.get("/llm-usage", response_model=LlmUsageStatsResponse)
async def get_llm_usage(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get aggregated LLM usage stats for the current month"""
from services.llm_service import get_usage_stats
stats = await get_usage_stats(db, current_user.kitchen_id)
return LlmUsageStatsResponse(**stats)
@router.post("/test-llm")
async def test_llm_connection(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Test Anthropic API connection with current settings"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.anthropic_api_key:
raise HTTPException(
status_code=400,
detail="Anthropic API key not configured"
)
if not settings.llm_enabled:
raise HTTPException(
status_code=400,
detail="LLM features are disabled. Enable them in Settings first."
)
try:
import anthropic
from services.llm_service import DEFAULT_LLM_MODEL
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model=settings.llm_model or DEFAULT_LLM_MODEL,
max_tokens=10,
messages=[{"role": "user", "content": "Say 'ok'"}],
)
return {"status": "success", "message": f"Connection successful. Model: {response.model}"}
except anthropic.AuthenticationError:
raise HTTPException(status_code=400, detail="Authentication failed — check your API key")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}")
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
@router.get("/llm-models")
async def get_llm_models(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Fetch available models from Anthropic API."""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings or not settings.anthropic_api_key:
return {"models": [], "default": None, "error": "No API key configured"}
from services.llm_service import list_available_models, DEFAULT_LLM_MODEL
models = await list_available_models(settings.anthropic_api_key)
return {
"models": models,
"default": DEFAULT_LLM_MODEL,
"current": settings.llm_model or DEFAULT_LLM_MODEL,
}

View file

@ -1,343 +0,0 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from database import get_db, AsyncSessionLocal
from models.user import User
from models.supplier import Supplier
from models.invoice import Invoice
from auth import get_current_user, require_cap
from ocr.parser import identify_supplier
router = APIRouter()
async def rematch_unmatched_invoices(kitchen_id: int):
"""
Re-run supplier matching for all invoices without a supplier.
Called after supplier create/update to match previously unmatched invoices.
"""
async with AsyncSessionLocal() as db:
# Get all invoices without a supplier that have vendor_name from OCR
result = await db.execute(
select(Invoice).where(
Invoice.kitchen_id == kitchen_id,
Invoice.supplier_id == None,
Invoice.vendor_name != None
)
)
invoices = result.scalars().all()
for invoice in invoices:
if invoice.vendor_name:
supplier_id, match_type = await identify_supplier(
invoice.vendor_name, kitchen_id, db
)
if supplier_id:
invoice.supplier_id = supplier_id
invoice.supplier_match_type = match_type
await db.commit()
class SupplierCreate(BaseModel):
name: str
aliases: list[str] = []
template_config: dict = {}
identifier_config: dict = {}
skip_dext: bool = False
order_email: Optional[str] = None
account_number: Optional[str] = None
class SupplierUpdate(BaseModel):
name: Optional[str] = None
aliases: Optional[list[str]] = None
template_config: Optional[dict] = None
identifier_config: Optional[dict] = None
skip_dext: Optional[bool] = None
order_email: Optional[str] = None
account_number: Optional[str] = None
class SupplierResponse(BaseModel):
id: int
name: str
aliases: list[str]
template_config: dict
identifier_config: dict
skip_dext: bool
order_email: Optional[str] = None
account_number: Optional[str] = None
created_at: str
class Config:
from_attributes = True
@router.post("/", response_model=SupplierResponse)
async def create_supplier(
request: SupplierCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create a new supplier with extraction templates"""
supplier = Supplier(
kitchen_id=current_user.kitchen_id,
name=request.name,
aliases=request.aliases,
template_config=request.template_config,
identifier_config=request.identifier_config,
skip_dext=request.skip_dext,
order_email=request.order_email,
account_number=request.account_number,
)
db.add(supplier)
await db.commit()
await db.refresh(supplier)
# Rematch unmatched invoices in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.get("/", response_model=list[SupplierResponse])
async def list_suppliers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""List all suppliers for the current kitchen"""
result = await db.execute(
select(Supplier)
.where(Supplier.kitchen_id == current_user.kitchen_id)
.order_by(Supplier.name)
)
suppliers = result.scalars().all()
return [
SupplierResponse(
id=s.id,
name=s.name,
aliases=s.aliases or [],
template_config=s.template_config,
identifier_config=s.identifier_config,
skip_dext=s.skip_dext,
order_email=s.order_email,
account_number=s.account_number,
created_at=s.created_at.isoformat()
)
for s in suppliers
]
@router.get("/{supplier_id}", response_model=SupplierResponse)
async def get_supplier(
supplier_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get a supplier by ID"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.patch("/{supplier_id}", response_model=SupplierResponse)
async def update_supplier(
supplier_id: int,
update: SupplierUpdate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update a supplier's template configuration"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(supplier, field, value)
await db.commit()
await db.refresh(supplier)
# Rematch unmatched invoices in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.delete("/{supplier_id}")
async def delete_supplier(
supplier_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete a supplier"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
await db.delete(supplier)
await db.commit()
return {"message": "Supplier deleted"}
class AddAliasRequest(BaseModel):
alias: str
invoice_id: Optional[int] = None # If provided, update this invoice's match type to 'exact'
@router.post("/{supplier_id}/aliases", response_model=SupplierResponse)
async def add_supplier_alias(
supplier_id: int,
request: AddAliasRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Add an alias to a supplier for better matching"""
result = await db.execute(
select(Supplier).where(
Supplier.id == supplier_id,
Supplier.kitchen_id == current_user.kitchen_id
)
)
supplier = result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
alias = request.alias.strip()
if not alias:
raise HTTPException(status_code=400, detail="Alias cannot be empty")
# Add alias if not already present
# Create a new list to ensure SQLAlchemy detects the change (JSON columns don't detect in-place mutations)
current_aliases = list(supplier.aliases or [])
if alias not in current_aliases:
current_aliases.append(alias)
supplier.aliases = current_aliases
# If invoice_id provided, update that invoice's match type to 'exact'
if request.invoice_id:
inv_result = await db.execute(
select(Invoice).where(
Invoice.id == request.invoice_id,
Invoice.kitchen_id == current_user.kitchen_id
)
)
invoice = inv_result.scalar_one_or_none()
if invoice and invoice.supplier_match_type == 'fuzzy':
invoice.supplier_match_type = 'exact'
await db.commit()
await db.refresh(supplier)
# Rematch unmatched invoices in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return SupplierResponse(
id=supplier.id,
name=supplier.name,
aliases=supplier.aliases or [],
template_config=supplier.template_config,
identifier_config=supplier.identifier_config,
skip_dext=supplier.skip_dext,
order_email=supplier.order_email,
account_number=supplier.account_number,
created_at=supplier.created_at.isoformat()
)
@router.post("/rematch-fuzzy")
async def rematch_fuzzy_invoices(
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Clear all fuzzy-matched invoices and re-run supplier matching.
Use this after updating matching logic to fix incorrect fuzzy matches.
"""
# Count fuzzy matches before clearing
count_result = await db.execute(
select(Invoice).where(
Invoice.kitchen_id == current_user.kitchen_id,
Invoice.supplier_match_type == "fuzzy"
)
)
fuzzy_invoices = count_result.scalars().all()
count = len(fuzzy_invoices)
# Clear supplier assignment for all fuzzy matches
for invoice in fuzzy_invoices:
invoice.supplier_id = None
invoice.supplier_match_type = None
await db.commit()
# Re-run matching in background
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
return {"message": f"Cleared {count} fuzzy matches. Re-matching in background."}

View file

@ -1,204 +0,0 @@
"""
Support Request API
Handles user support requests with page screenshots.
"""
import base64
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from auth import get_current_user, require_cap
from models.user import User, Kitchen
from models.settings import KitchenSettings
from services.email_service import EmailService
from sqlalchemy import select
router = APIRouter()
logger = logging.getLogger(__name__)
class SupportRequest(BaseModel):
"""Support request payload"""
description: str
screenshot: str # Base64 encoded PNG
page_url: str
browser_info: str | None = None
class SupportResponse(BaseModel):
"""Support request response"""
success: bool
message: str
def generate_support_email_html(
user_name: str,
user_email: str,
kitchen_name: str,
description: str,
page_url: str,
browser_info: str | None,
timestamp: datetime
) -> str:
"""Generate HTML email body for support request"""
return f"""
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; color: #333; line-height: 1.6; }}
.header {{ background-color: #e94560; color: white; padding: 20px; }}
.content {{ padding: 20px; }}
.meta {{ background: #f5f5f5; padding: 15px; border-radius: 6px; margin-bottom: 20px; }}
.meta p {{ margin: 5px 0; }}
.label {{ font-weight: bold; color: #666; }}
.description {{ background: #fffbcc; padding: 15px; border-left: 4px solid #ffc107; margin: 20px 0; }}
.screenshot-note {{ color: #666; font-style: italic; margin-top: 20px; }}
</style>
</head>
<body>
<div class="header">
<h2>Support Request</h2>
</div>
<div class="content">
<div class="meta">
<p><span class="label">From:</span> {user_name} ({user_email})</p>
<p><span class="label">Kitchen:</span> {kitchen_name}</p>
<p><span class="label">Page URL:</span> {page_url}</p>
<p><span class="label">Timestamp:</span> {timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
{f'<p><span class="label">Browser:</span> {browser_info}</p>' if browser_info else ''}
</div>
<h3>Issue Description</h3>
<div class="description">
<p>{description.replace(chr(10), '<br>')}</p>
</div>
<p class="screenshot-note">A screenshot of the page is attached to this email.</p>
</div>
</body>
</html>
"""
@router.post("/support/request", response_model=SupportResponse)
async def submit_support_request(
request: SupportRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Submit a support request with page screenshot.
The screenshot is sent as an email attachment to the configured support email.
"""
# Get kitchen settings
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
if not settings:
raise HTTPException(status_code=404, detail="Kitchen settings not found")
# Check if support email is configured
if not settings.support_email:
raise HTTPException(
status_code=400,
detail="Support email not configured. Please contact your administrator."
)
# Check if SMTP is configured
if not settings.smtp_host or not settings.smtp_from_email:
raise HTTPException(
status_code=400,
detail="Email settings not configured. Please contact your administrator."
)
# Fetch kitchen name explicitly to avoid lazy loading issues
kitchen_result = await db.execute(
select(Kitchen).where(Kitchen.id == current_user.kitchen_id)
)
kitchen = kitchen_result.scalar_one_or_none()
kitchen_name = kitchen.name if kitchen else "Unknown Kitchen"
try:
# Decode screenshot from base64
# Remove data URL prefix if present
screenshot_data = request.screenshot
if screenshot_data.startswith('data:'):
screenshot_data = screenshot_data.split(',', 1)[1]
screenshot_bytes = base64.b64decode(screenshot_data)
# Generate email
timestamp = datetime.utcnow()
html_body = generate_support_email_html(
user_name=current_user.name,
user_email=current_user.email,
kitchen_name=kitchen_name,
description=request.description,
page_url=request.page_url,
browser_info=request.browser_info,
timestamp=timestamp
)
# Create email subject
subject = f"Support Request from {current_user.name} - {kitchen_name}"
# Send email with screenshot attachment
email_service = EmailService(settings)
filename = f"screenshot_{timestamp.strftime('%Y%m%d_%H%M%S')}.png"
success = email_service.send_email(
to_email=settings.support_email,
subject=subject,
html_body=html_body,
attachments=[(filename, screenshot_bytes)]
)
if success:
logger.info(f"Support request sent from {current_user.name} to {settings.support_email}")
return SupportResponse(
success=True,
message="Support request sent successfully. We'll get back to you soon."
)
else:
logger.error(f"Failed to send support request email")
raise HTTPException(
status_code=500,
detail="Failed to send support request. Please try again later."
)
except base64.binascii.Error:
raise HTTPException(status_code=400, detail="Invalid screenshot data")
except Exception as e:
logger.error(f"Support request error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/support/enabled")
async def check_support_enabled(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""
Check if support requests are enabled (support email configured).
Returns whether the support button should be shown.
"""
result = await db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
)
settings = result.scalar_one_or_none()
enabled = bool(
settings and
settings.support_email and
settings.smtp_host and
settings.smtp_from_email
)
return {"enabled": enabled}

Some files were not shown because too many files have changed in this diff Show more