Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
3
backend/api/__init__.py
Normal file
3
backend/api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import invoices, suppliers, reports, newbook, resos
|
||||
|
||||
__all__ = ["invoices", "suppliers", "reports", "newbook", "resos"]
|
||||
BIN
backend/api/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/backup.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/backup.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/budget.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/budget.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/calendar_events.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/calendar_events.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/cost_distributions.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/cost_distributions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/cover_overrides.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/cover_overrides.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/credit_notes.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/credit_notes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/disputes.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/disputes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/event_orders.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/event_orders.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/external.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/external.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/field_mappings.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/field_mappings.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/food_flags.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/food_flags.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/imap.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/imap.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/ingredients.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/ingredients.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/invoices.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/invoices.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/kds.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/kds.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/logbook.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/logbook.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/menus.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/menus.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/newbook.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/newbook.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/public.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/public.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/purchase_orders.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/purchase_orders.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/recipes.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/recipes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/reports.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/reports.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/residents_table_chart.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/residents_table_chart.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/resos.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/resos.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/sambapos.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/sambapos.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/search.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/search.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/settings.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/suppliers.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/suppliers.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/api/__pycache__/support.cpython-313.pyc
Normal file
BIN
backend/api/__pycache__/support.cpython-313.pyc
Normal file
Binary file not shown.
374
backend/api/backup.py
Normal file
374
backend/api/backup.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""
|
||||
Backup management API endpoints.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from models.backup import BackupHistory
|
||||
from auth import get_current_user, require_cap
|
||||
from services.backup_service import BackupService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Models ============
|
||||
|
||||
class BackupSettingsResponse(BaseModel):
|
||||
backup_frequency: str | None
|
||||
backup_retention_count: int
|
||||
backup_destination: str | None
|
||||
backup_time: str | None
|
||||
backup_nextcloud_path: str | None
|
||||
backup_smb_host: str | None
|
||||
backup_smb_share: str | None
|
||||
backup_smb_username: str | None
|
||||
backup_smb_password_set: bool
|
||||
backup_smb_path: str | None
|
||||
backup_last_run_at: str | None
|
||||
backup_last_status: str | None
|
||||
backup_last_error: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BackupSettingsUpdate(BaseModel):
|
||||
backup_frequency: str | None = None # "daily", "weekly", "manual"
|
||||
backup_retention_count: int | None = None
|
||||
backup_destination: str | None = None # "local", "nextcloud", "smb"
|
||||
backup_time: str | None = None # "HH:MM"
|
||||
backup_nextcloud_path: str | None = None
|
||||
backup_smb_host: str | None = None
|
||||
backup_smb_share: str | None = None
|
||||
backup_smb_username: str | None = None
|
||||
backup_smb_password: str | None = None
|
||||
backup_smb_path: str | None = None
|
||||
|
||||
|
||||
class BackupHistoryResponse(BaseModel):
|
||||
id: int
|
||||
backup_type: str
|
||||
destination: str
|
||||
status: str
|
||||
filename: str
|
||||
file_size_bytes: int | None
|
||||
invoice_count: int | None
|
||||
file_count: int | None
|
||||
started_at: str
|
||||
completed_at: str | None
|
||||
error_message: str | None
|
||||
triggered_by_username: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BackupCreateResponse(BaseModel):
|
||||
message: str
|
||||
status: str
|
||||
backup_id: int | None = None
|
||||
|
||||
|
||||
class BackupRestoreResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings", response_model=BackupSettingsResponse)
|
||||
async def get_backup_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get backup settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return BackupSettingsResponse(
|
||||
backup_frequency="manual",
|
||||
backup_retention_count=7,
|
||||
backup_destination="local",
|
||||
backup_time="03:00",
|
||||
backup_nextcloud_path="/Backups",
|
||||
backup_smb_host=None,
|
||||
backup_smb_share=None,
|
||||
backup_smb_username=None,
|
||||
backup_smb_password_set=False,
|
||||
backup_smb_path="/backups",
|
||||
backup_last_run_at=None,
|
||||
backup_last_status=None,
|
||||
backup_last_error=None
|
||||
)
|
||||
|
||||
return BackupSettingsResponse(
|
||||
backup_frequency=settings.backup_frequency,
|
||||
backup_retention_count=settings.backup_retention_count,
|
||||
backup_destination=settings.backup_destination,
|
||||
backup_time=settings.backup_time,
|
||||
backup_nextcloud_path=settings.backup_nextcloud_path,
|
||||
backup_smb_host=settings.backup_smb_host,
|
||||
backup_smb_share=settings.backup_smb_share,
|
||||
backup_smb_username=settings.backup_smb_username,
|
||||
backup_smb_password_set=bool(settings.backup_smb_password),
|
||||
backup_smb_path=settings.backup_smb_path,
|
||||
backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
|
||||
backup_last_status=settings.backup_last_status,
|
||||
backup_last_error=settings.backup_last_error
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=BackupSettingsResponse)
|
||||
async def update_backup_settings(
|
||||
update: BackupSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update backup settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == 'backup_smb_password' and value:
|
||||
setattr(settings, field, value)
|
||||
elif value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return BackupSettingsResponse(
|
||||
backup_frequency=settings.backup_frequency,
|
||||
backup_retention_count=settings.backup_retention_count,
|
||||
backup_destination=settings.backup_destination,
|
||||
backup_time=settings.backup_time,
|
||||
backup_nextcloud_path=settings.backup_nextcloud_path,
|
||||
backup_smb_host=settings.backup_smb_host,
|
||||
backup_smb_share=settings.backup_smb_share,
|
||||
backup_smb_username=settings.backup_smb_username,
|
||||
backup_smb_password_set=bool(settings.backup_smb_password),
|
||||
backup_smb_path=settings.backup_smb_path,
|
||||
backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
|
||||
backup_last_status=settings.backup_last_status,
|
||||
backup_last_error=settings.backup_last_error
|
||||
)
|
||||
|
||||
|
||||
# ============ Backup Operations ============
|
||||
|
||||
async def _run_backup_task(db: AsyncSession, kitchen_id: int, user_id: int):
|
||||
"""Background task to run backup"""
|
||||
backup_service = BackupService(db, kitchen_id)
|
||||
await backup_service.create_backup(user_id=user_id, backup_type="manual")
|
||||
|
||||
|
||||
@router.post("/create", response_model=BackupCreateResponse)
|
||||
async def create_backup(
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Trigger a manual backup"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
# Run backup synchronously for now to get immediate feedback
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message, backup = await backup_service.create_backup(
|
||||
user_id=current_user.id,
|
||||
backup_type="manual"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
return BackupCreateResponse(
|
||||
message=message,
|
||||
status="success",
|
||||
backup_id=backup.id if backup else None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[BackupHistoryResponse])
|
||||
async def list_backups(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List backup history"""
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
backups = await backup_service.list_backups()
|
||||
|
||||
return [
|
||||
BackupHistoryResponse(
|
||||
id=b.id,
|
||||
backup_type=b.backup_type,
|
||||
destination=b.destination,
|
||||
status=b.status,
|
||||
filename=b.filename,
|
||||
file_size_bytes=b.file_size_bytes,
|
||||
invoice_count=b.invoice_count,
|
||||
file_count=b.file_count,
|
||||
started_at=b.started_at.isoformat(),
|
||||
completed_at=b.completed_at.isoformat() if b.completed_at else None,
|
||||
error_message=b.error_message,
|
||||
triggered_by_username=b.triggered_by_user.name if b.triggered_by_user else None
|
||||
)
|
||||
for b in backups
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{backup_id}/restore", response_model=BackupRestoreResponse)
|
||||
async def restore_backup(
|
||||
backup_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Restore from a backup"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message = await backup_service.restore_backup(backup_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return BackupRestoreResponse(status="success", message=message)
|
||||
|
||||
|
||||
@router.delete("/{backup_id}")
|
||||
async def delete_backup(
|
||||
backup_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a backup"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message = await backup_service.delete_backup(backup_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.get("/{backup_id}/download")
|
||||
async def download_backup(
|
||||
backup_id: int,
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Download a backup file. Auth via token query param for direct browser downloads."""
|
||||
from auth import get_current_user, require_cap_from_token
|
||||
|
||||
current_user = await get_current_user_from_token(token, db)
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
backup = await backup_service.get_backup(backup_id)
|
||||
|
||||
if not backup:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
|
||||
if backup.status != "success":
|
||||
raise HTTPException(status_code=400, detail="Cannot download failed backup")
|
||||
|
||||
# Handle local backups
|
||||
if not backup.file_path.startswith("nextcloud:"):
|
||||
if not os.path.exists(backup.file_path):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=backup.file_path,
|
||||
filename=backup.filename,
|
||||
media_type="application/zip"
|
||||
)
|
||||
|
||||
# Handle Nextcloud backups - download to temp file
|
||||
from services.nextcloud_service import NextcloudService
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.nextcloud_host:
|
||||
raise HTTPException(status_code=400, detail="Nextcloud not configured")
|
||||
|
||||
nc_path = backup.file_path.replace("nextcloud:", "")
|
||||
nc = NextcloudService(
|
||||
settings.nextcloud_host,
|
||||
settings.nextcloud_username,
|
||||
settings.nextcloud_password,
|
||||
""
|
||||
)
|
||||
success, content = await nc.download_file(nc_path)
|
||||
await nc.close()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to download from Nextcloud: {content}")
|
||||
|
||||
# Write to temp file and return
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_path = os.path.join(temp_dir, backup.filename)
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
return FileResponse(
|
||||
path=temp_path,
|
||||
filename=backup.filename,
|
||||
media_type="application/zip",
|
||||
background=lambda: shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=BackupRestoreResponse)
|
||||
async def upload_and_restore_backup(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload a backup file and restore from it"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
if not file.filename.endswith('.zip'):
|
||||
raise HTTPException(status_code=400, detail="Only ZIP files are accepted")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message = await backup_service.restore_from_upload(file)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return BackupRestoreResponse(status="success", message=message)
|
||||
1121
backend/api/budget.py
Normal file
1121
backend/api/budget.py
Normal file
File diff suppressed because it is too large
Load diff
236
backend/api/calendar_events.py
Normal file
236
backend/api/calendar_events.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.calendar_events import CalendarEvent
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
class CalendarEventCreate(BaseModel):
|
||||
event_date: date
|
||||
event_type: str # reminder, event, note
|
||||
title: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CalendarEventUpdate(BaseModel):
|
||||
event_date: date | None = None
|
||||
event_type: str | None = None
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
id: int
|
||||
event_date: date
|
||||
event_type: str
|
||||
title: str
|
||||
description: str | None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Endpoints
|
||||
@router.get("/")
|
||||
async def list_events(
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[CalendarEventResponse]:
|
||||
"""List events for date range"""
|
||||
query = select(CalendarEvent).where(
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
|
||||
if from_date:
|
||||
query = query.where(CalendarEvent.event_date >= from_date)
|
||||
if to_date:
|
||||
query = query.where(CalendarEvent.event_date <= to_date)
|
||||
|
||||
query = query.order_by(CalendarEvent.event_date, CalendarEvent.created_at)
|
||||
result = await db.execute(query)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [
|
||||
CalendarEventResponse(
|
||||
id=e.id,
|
||||
event_date=e.event_date,
|
||||
event_type=e.event_type,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
created_at=e.created_at.isoformat()
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{date}")
|
||||
async def get_events_for_date(
|
||||
date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[CalendarEventResponse]:
|
||||
"""Get all events for a specific date"""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id,
|
||||
CalendarEvent.event_date == date
|
||||
)
|
||||
).order_by(CalendarEvent.created_at)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [
|
||||
CalendarEventResponse(
|
||||
id=e.id,
|
||||
event_date=e.event_date,
|
||||
event_type=e.event_type,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
created_at=e.created_at.isoformat()
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_event(
|
||||
event: CalendarEventCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> CalendarEventResponse:
|
||||
"""Create new event"""
|
||||
new_event = CalendarEvent(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
event_date=event.event_date,
|
||||
event_type=event.event_type,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
await db.refresh(new_event)
|
||||
|
||||
return CalendarEventResponse(
|
||||
id=new_event.id,
|
||||
event_date=new_event.event_date,
|
||||
event_type=new_event.event_type,
|
||||
title=new_event.title,
|
||||
description=new_event.description,
|
||||
created_at=new_event.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_event(
|
||||
id: int,
|
||||
update: CalendarEventUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update existing event"""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.id == id,
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
if update.event_date is not None:
|
||||
event.event_date = update.event_date
|
||||
if update.event_type is not None:
|
||||
event.event_type = update.event_type
|
||||
if update.title is not None:
|
||||
event.title = update.title
|
||||
if update.description is not None:
|
||||
event.description = update.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
return CalendarEventResponse(
|
||||
id=event.id,
|
||||
event_date=event.event_date,
|
||||
event_type=event.event_type,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
created_at=event.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_event(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete event"""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.id == id,
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
await db.delete(event)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Event deleted"}
|
||||
|
||||
|
||||
@router.get("/dashboard/upcoming")
|
||||
async def get_upcoming_events(
|
||||
limit: int = 3,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get next N upcoming events for dashboard widget"""
|
||||
today = date.today()
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id,
|
||||
CalendarEvent.event_date >= today
|
||||
)
|
||||
).order_by(CalendarEvent.event_date, CalendarEvent.created_at).limit(limit)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total_count": len(events),
|
||||
"upcoming_events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_date": e.event_date.isoformat(),
|
||||
"event_type": e.event_type,
|
||||
"title": e.title
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
}
|
||||
863
backend/api/cost_distributions.py
Normal file
863
backend/api/cost_distributions.py
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
"""
|
||||
Cost Distribution API endpoints — create, view, settle early, cancel,
|
||||
and weekly summaries for budget page integration.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, field_serializer
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.invoice import Invoice, InvoiceStatus
|
||||
from models.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.cost_distribution import (
|
||||
CostDistribution,
|
||||
CostDistributionLineSelection,
|
||||
CostDistributionEntry,
|
||||
DistributionStatus,
|
||||
DistributionMethod,
|
||||
)
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineSelectionIn(BaseModel):
|
||||
line_item_id: int
|
||||
selected_quantity: Decimal
|
||||
|
||||
|
||||
class CostDistributionCreate(BaseModel):
|
||||
invoice_id: int
|
||||
method: str # "OFFSET" or "DISTRIBUTE"
|
||||
notes: Optional[str] = None
|
||||
line_selections: list[LineSelectionIn]
|
||||
# For OFFSET
|
||||
target_date: Optional[date] = None
|
||||
# For DISTRIBUTE
|
||||
days_of_week: Optional[list[int]] = None # 0=Mon, 6=Sun (Python weekday())
|
||||
num_weeks: Optional[int] = None
|
||||
start_date: Optional[date] = None
|
||||
|
||||
|
||||
class CostDistributionUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SettleEarlyRequest(BaseModel):
|
||||
entry_date: date
|
||||
amount: Optional[Decimal] = None # null = settle all
|
||||
|
||||
|
||||
# ── Response schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineSelectionOut(BaseModel):
|
||||
id: int
|
||||
line_item_id: int
|
||||
description: Optional[str] = None
|
||||
original_quantity: Optional[Decimal] = None
|
||||
selected_quantity: Decimal
|
||||
unit_price: Decimal
|
||||
distributed_value: Decimal
|
||||
|
||||
@field_serializer('selected_quantity', 'unit_price', 'distributed_value', 'original_quantity')
|
||||
def ser(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
id: int
|
||||
entry_date: date
|
||||
amount: Decimal
|
||||
is_source_offset: bool
|
||||
is_overpay: bool
|
||||
|
||||
@field_serializer('amount')
|
||||
def ser_amount(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class CostDistributionOut(BaseModel):
|
||||
id: int
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str] = None
|
||||
invoice_date: Optional[date] = None
|
||||
supplier_name: Optional[str] = None
|
||||
status: str
|
||||
method: str
|
||||
notes: Optional[str]
|
||||
total_distributed_value: Decimal
|
||||
remaining_balance: Decimal
|
||||
source_date: date
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: str
|
||||
line_selections: list[LineSelectionOut] = []
|
||||
entries: list[EntryOut] = []
|
||||
|
||||
@field_serializer('total_distributed_value', 'remaining_balance')
|
||||
def ser_dec(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class LineItemAvailability(BaseModel):
|
||||
id: int
|
||||
description: Optional[str]
|
||||
unit: Optional[str]
|
||||
quantity: Optional[Decimal]
|
||||
unit_price: Optional[Decimal]
|
||||
amount: Optional[Decimal]
|
||||
is_non_stock: bool
|
||||
already_distributed_qty: Decimal
|
||||
available_qty: Decimal
|
||||
|
||||
@field_serializer('quantity', 'unit_price', 'amount', 'already_distributed_qty', 'available_qty')
|
||||
def ser(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class InvoiceAvailabilityOut(BaseModel):
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
invoice_date: Optional[date]
|
||||
supplier_name: Optional[str]
|
||||
line_items: list[LineItemAvailability] = []
|
||||
|
||||
|
||||
class WeeklyDistributionRow(BaseModel):
|
||||
distribution_id: int
|
||||
title: str # Compact: "dd/mm/yy - Supplier"
|
||||
supplier_name: Optional[str] = None
|
||||
invoice_number: Optional[str] = None
|
||||
source_date_str: str = "" # dd/mm/yy
|
||||
summary: str = "" # e.g. "£200 laid away to 28/02/26" or "£200 distributed over 4 weeks"
|
||||
notes: Optional[str] = None
|
||||
invoice_id: int
|
||||
entries_by_date: dict[str, float]
|
||||
total_distributed_value: float
|
||||
remaining_balance: float
|
||||
bf_balance: float # Outstanding at start of this week
|
||||
cf_balance: float # Outstanding at end of this week
|
||||
status: str
|
||||
|
||||
|
||||
class WeeklyDistributionsOut(BaseModel):
|
||||
week_start: date
|
||||
week_end: date
|
||||
distributions: list[WeeklyDistributionRow] = []
|
||||
daily_totals: dict[str, float] = {}
|
||||
bf_balance: float
|
||||
cf_balance: float
|
||||
week_total: float
|
||||
|
||||
|
||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _generate_target_dates(
|
||||
method: str,
|
||||
target_date: Optional[date],
|
||||
days_of_week: Optional[list[int]],
|
||||
num_weeks: Optional[int],
|
||||
start_date: Optional[date],
|
||||
) -> list[date]:
|
||||
"""Generate target dates based on distribution method."""
|
||||
if method == DistributionMethod.OFFSET.value:
|
||||
if not target_date:
|
||||
raise HTTPException(400, "target_date required for OFFSET method")
|
||||
return [target_date]
|
||||
|
||||
if method == DistributionMethod.DISTRIBUTE.value:
|
||||
if not days_of_week or not num_weeks or not start_date:
|
||||
raise HTTPException(400, "days_of_week, num_weeks, and start_date required for DISTRIBUTE method")
|
||||
if not days_of_week:
|
||||
raise HTTPException(400, "At least one day of week must be selected")
|
||||
if num_weeks < 1:
|
||||
raise HTTPException(400, "num_weeks must be at least 1")
|
||||
|
||||
dates = []
|
||||
for week in range(num_weeks):
|
||||
week_start = start_date + timedelta(weeks=week)
|
||||
for day_offset in range(7):
|
||||
d = week_start + timedelta(days=day_offset)
|
||||
if d.weekday() in days_of_week:
|
||||
dates.append(d)
|
||||
|
||||
# Deduplicate and sort (in case start_date is mid-week)
|
||||
dates = sorted(set(dates))
|
||||
if not dates:
|
||||
raise HTTPException(400, "No target dates generated from the selected days and weeks")
|
||||
return dates
|
||||
|
||||
raise HTTPException(400, f"Invalid method: {method}")
|
||||
|
||||
|
||||
def _distribute_amount(total: Decimal, count: int) -> list[Decimal]:
|
||||
"""Distribute a total amount evenly across count entries. Last entry absorbs rounding."""
|
||||
if count == 0:
|
||||
return []
|
||||
per_entry = (total / count).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
amounts = [per_entry] * count
|
||||
# Adjust last entry to absorb rounding difference
|
||||
distributed_sum = per_entry * (count - 1)
|
||||
amounts[-1] = total - distributed_sum
|
||||
return amounts
|
||||
|
||||
|
||||
async def _get_already_distributed_qty(
|
||||
db: AsyncSession, line_item_id: int, exclude_distribution_id: Optional[int] = None
|
||||
) -> Decimal:
|
||||
"""Get total quantity already distributed for a line item from ACTIVE distributions."""
|
||||
query = (
|
||||
select(func.coalesce(func.sum(CostDistributionLineSelection.selected_quantity), 0))
|
||||
.join(CostDistribution, CostDistributionLineSelection.distribution_id == CostDistribution.id)
|
||||
.where(
|
||||
CostDistributionLineSelection.line_item_id == line_item_id,
|
||||
CostDistribution.status == DistributionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
if exclude_distribution_id:
|
||||
query = query.where(CostDistribution.id != exclude_distribution_id)
|
||||
result = await db.execute(query)
|
||||
return Decimal(str(result.scalar() or 0))
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/invoice/{invoice_id}/availability", response_model=InvoiceAvailabilityOut)
|
||||
async def get_invoice_availability(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get line items available for distribution from an invoice."""
|
||||
invoice = await db.execute(
|
||||
select(Invoice)
|
||||
.options(selectinload(Invoice.line_items))
|
||||
.where(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if invoice.status != InvoiceStatus.CONFIRMED:
|
||||
raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
|
||||
if invoice.document_type == "credit_note":
|
||||
raise HTTPException(400, "Credit notes cannot be distributed")
|
||||
|
||||
supplier_name = None
|
||||
if invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
|
||||
items = []
|
||||
for li in invoice.line_items:
|
||||
already = await _get_already_distributed_qty(db, li.id)
|
||||
original_qty = li.quantity or Decimal("0")
|
||||
available = max(Decimal("0"), original_qty - already)
|
||||
items.append(LineItemAvailability(
|
||||
id=li.id,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
quantity=li.quantity,
|
||||
unit_price=li.unit_price,
|
||||
amount=li.amount,
|
||||
is_non_stock=li.is_non_stock or False,
|
||||
already_distributed_qty=already,
|
||||
available_qty=available,
|
||||
))
|
||||
|
||||
return InvoiceAvailabilityOut(
|
||||
invoice_id=invoice.id,
|
||||
invoice_number=invoice.invoice_number,
|
||||
invoice_date=invoice.invoice_date,
|
||||
supplier_name=supplier_name or invoice.vendor_name,
|
||||
line_items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=CostDistributionOut)
|
||||
async def create_cost_distribution(
|
||||
data: CostDistributionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new cost distribution."""
|
||||
# Validate invoice
|
||||
invoice = await db.execute(
|
||||
select(Invoice)
|
||||
.options(selectinload(Invoice.line_items))
|
||||
.where(
|
||||
Invoice.id == data.invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if invoice.status != InvoiceStatus.CONFIRMED:
|
||||
raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
|
||||
if invoice.document_type == "credit_note":
|
||||
raise HTTPException(400, "Credit notes cannot be distributed")
|
||||
if not invoice.invoice_date:
|
||||
raise HTTPException(400, "Invoice must have a date to be distributed")
|
||||
|
||||
# Validate method
|
||||
if data.method not in [m.value for m in DistributionMethod]:
|
||||
raise HTTPException(400, f"Invalid method: {data.method}")
|
||||
|
||||
# Get settings for max_days validation
|
||||
settings = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings.scalar_one_or_none()
|
||||
max_days = settings.cost_distribution_max_days if settings else 90
|
||||
|
||||
# Generate target dates
|
||||
target_dates = _generate_target_dates(
|
||||
data.method, data.target_date, data.days_of_week, data.num_weeks, data.start_date
|
||||
)
|
||||
|
||||
# Validate max days
|
||||
today = date.today()
|
||||
max_allowed_date = today + timedelta(days=max_days)
|
||||
for td in target_dates:
|
||||
if td > max_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Target date {td} exceeds maximum of {max_days} days into the future ({max_allowed_date})"
|
||||
)
|
||||
|
||||
# Build line item map
|
||||
line_item_map = {li.id: li for li in invoice.line_items}
|
||||
|
||||
# Validate line selections and calculate totals
|
||||
if not data.line_selections:
|
||||
raise HTTPException(400, "At least one line item must be selected")
|
||||
|
||||
total_distributed_value = Decimal("0")
|
||||
selections_data = []
|
||||
|
||||
for sel in data.line_selections:
|
||||
li = line_item_map.get(sel.line_item_id)
|
||||
if not li:
|
||||
raise HTTPException(400, f"Line item {sel.line_item_id} not found on invoice")
|
||||
if li.is_non_stock:
|
||||
raise HTTPException(400, f"Non-stock item '{li.description}' cannot be distributed")
|
||||
if sel.selected_quantity <= 0:
|
||||
raise HTTPException(400, "Selected quantity must be greater than 0")
|
||||
|
||||
# Check available quantity
|
||||
already = await _get_already_distributed_qty(db, li.id)
|
||||
original_qty = li.quantity or Decimal("0")
|
||||
available = original_qty - already
|
||||
if sel.selected_quantity > available:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Requested qty {sel.selected_quantity} exceeds available {available} for '{li.description}'"
|
||||
)
|
||||
|
||||
unit_price = li.unit_price or Decimal("0")
|
||||
distributed_value = (sel.selected_quantity * unit_price).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
total_distributed_value += distributed_value
|
||||
selections_data.append({
|
||||
"line_item_id": li.id,
|
||||
"selected_quantity": sel.selected_quantity,
|
||||
"unit_price": unit_price,
|
||||
"distributed_value": distributed_value,
|
||||
})
|
||||
|
||||
if total_distributed_value <= 0:
|
||||
raise HTTPException(400, "Total distributed value must be greater than 0")
|
||||
|
||||
# Create the distribution header
|
||||
distribution = CostDistribution(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
invoice_id=invoice.id,
|
||||
status=DistributionStatus.ACTIVE.value,
|
||||
method=data.method,
|
||||
notes=data.notes,
|
||||
total_distributed_value=total_distributed_value,
|
||||
remaining_balance=total_distributed_value,
|
||||
source_date=invoice.invoice_date,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(distribution)
|
||||
await db.flush() # Get the distribution ID
|
||||
|
||||
# Create line selections
|
||||
for sel_data in selections_data:
|
||||
selection = CostDistributionLineSelection(
|
||||
distribution_id=distribution.id,
|
||||
**sel_data,
|
||||
)
|
||||
db.add(selection)
|
||||
|
||||
# Create source offset entry (negative on invoice date)
|
||||
source_entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=invoice.invoice_date,
|
||||
amount=-total_distributed_value,
|
||||
is_source_offset=True,
|
||||
is_overpay=False,
|
||||
)
|
||||
db.add(source_entry)
|
||||
|
||||
# Create target entries
|
||||
entry_amounts = _distribute_amount(total_distributed_value, len(target_dates))
|
||||
for td, amt in zip(target_dates, entry_amounts):
|
||||
entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=td,
|
||||
amount=amt,
|
||||
is_source_offset=False,
|
||||
is_overpay=False,
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.get("/weekly", response_model=WeeklyDistributionsOut)
|
||||
async def get_weekly_distributions(
|
||||
week_start: date,
|
||||
week_end: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all cost distributions relevant to a week view for the budget page."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
|
||||
# Get ALL active distributions (even if no entries this week) to prevent gaming
|
||||
# Also include COMPLETED distributions that have entries in this week period
|
||||
active_dists = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.entries),
|
||||
selectinload(CostDistribution.invoice),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.kitchen_id == kitchen_id,
|
||||
CostDistribution.status == DistributionStatus.ACTIVE.value,
|
||||
)
|
||||
.order_by(CostDistribution.id)
|
||||
)
|
||||
active_dists = active_dists.scalars().all()
|
||||
|
||||
# Completed distributions that have entries within the viewed week
|
||||
completed_dists = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.entries),
|
||||
selectinload(CostDistribution.invoice),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.kitchen_id == kitchen_id,
|
||||
CostDistribution.status == DistributionStatus.COMPLETED.value,
|
||||
CostDistribution.id.in_(
|
||||
select(CostDistributionEntry.distribution_id)
|
||||
.where(
|
||||
CostDistributionEntry.kitchen_id == kitchen_id,
|
||||
CostDistributionEntry.entry_date >= week_start,
|
||||
CostDistributionEntry.entry_date <= week_end,
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(CostDistribution.id)
|
||||
)
|
||||
completed_dists = completed_dists.scalars().all()
|
||||
|
||||
# Merge — active first, then completed with entries in period
|
||||
seen_ids = {d.id for d in active_dists}
|
||||
distributions = list(active_dists)
|
||||
for d in completed_dists:
|
||||
if d.id not in seen_ids:
|
||||
distributions.append(d)
|
||||
|
||||
rows = []
|
||||
daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||||
bf_balance = Decimal("0")
|
||||
week_total = Decimal("0")
|
||||
|
||||
for dist in distributions:
|
||||
# Get supplier name
|
||||
supplier_name = None
|
||||
if dist.invoice:
|
||||
if dist.invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == dist.invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
if not supplier_name:
|
||||
supplier_name = dist.invoice.vendor_name
|
||||
|
||||
invoice_num = dist.invoice.invoice_number if dist.invoice else None
|
||||
source_date_short = dist.source_date.strftime("%d/%m/%y") if dist.source_date else ""
|
||||
title = f"{source_date_short} - {supplier_name or 'Unknown'}"
|
||||
|
||||
# Build summary text
|
||||
total_val = f"£{float(dist.total_distributed_value):.2f}"
|
||||
target_entries = [e for e in dist.entries if not e.is_source_offset and not e.is_overpay]
|
||||
if dist.method == "OFFSET" and target_entries:
|
||||
target_date = target_entries[0].entry_date.strftime("%d/%m/%y")
|
||||
summary = f"{total_val} laid away to {target_date}"
|
||||
elif target_entries:
|
||||
# DISTRIBUTE: count unique days of week and number of weeks
|
||||
target_dates = sorted(set(e.entry_date for e in target_entries))
|
||||
if len(target_dates) > 1:
|
||||
first, last = target_dates[0], target_dates[-1]
|
||||
num_weeks = max(1, ((last - first).days // 7) + 1)
|
||||
dow_set = set(d.strftime("%a") for d in target_dates)
|
||||
days_str = ",".join(sorted(dow_set, key=lambda x: ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].index(x)))
|
||||
summary = f"{total_val} distributed {days_str} over {num_weeks} weeks"
|
||||
else:
|
||||
summary = f"{total_val} distributed to {target_dates[0].strftime('%d/%m/%y')}"
|
||||
else:
|
||||
summary = f"{total_val} distributed"
|
||||
|
||||
# Get entries for this week (including source offset so user sees the deduction)
|
||||
entries_by_date: dict[str, float] = {}
|
||||
for entry in dist.entries:
|
||||
if week_start <= entry.entry_date <= week_end:
|
||||
date_key = entry.entry_date.isoformat()
|
||||
entries_by_date[date_key] = entries_by_date.get(date_key, 0) + float(entry.amount)
|
||||
daily_totals[date_key] += entry.amount
|
||||
week_total += entry.amount
|
||||
|
||||
# Per-distribution BF/CF as net running balance:
|
||||
# BF = sum of ALL entries (including source offset) before this week
|
||||
# CF = BF + sum of ALL entries within this week
|
||||
# e.g. new distribution: BF=0, source deducts -£200, CF=-£200
|
||||
# next week: BF=-£200, positives bring it toward 0
|
||||
dist_bf = sum(
|
||||
e.amount for e in dist.entries
|
||||
if e.entry_date < week_start
|
||||
)
|
||||
entries_this_week = sum(
|
||||
e.amount for e in dist.entries
|
||||
if week_start <= e.entry_date <= week_end
|
||||
)
|
||||
dist_cf = dist_bf + entries_this_week
|
||||
|
||||
# Only include distributions that are relevant to this period:
|
||||
# has a non-zero BF or CF, or has entries in this week
|
||||
if dist_bf == 0 and dist_cf == 0 and not entries_by_date:
|
||||
continue
|
||||
|
||||
bf_balance += dist_bf
|
||||
|
||||
rows.append(WeeklyDistributionRow(
|
||||
distribution_id=dist.id,
|
||||
title=title,
|
||||
supplier_name=supplier_name,
|
||||
invoice_number=invoice_num,
|
||||
source_date_str=source_date_short,
|
||||
summary=summary,
|
||||
notes=dist.notes,
|
||||
invoice_id=dist.invoice_id,
|
||||
entries_by_date=entries_by_date,
|
||||
total_distributed_value=float(dist.total_distributed_value),
|
||||
remaining_balance=float(dist.remaining_balance),
|
||||
bf_balance=float(dist_bf),
|
||||
cf_balance=float(dist_cf),
|
||||
status=dist.status,
|
||||
))
|
||||
|
||||
cf_balance = bf_balance + week_total
|
||||
|
||||
return WeeklyDistributionsOut(
|
||||
week_start=week_start,
|
||||
week_end=week_end,
|
||||
distributions=rows,
|
||||
daily_totals={k: float(v) for k, v in daily_totals.items()},
|
||||
bf_balance=float(bf_balance),
|
||||
cf_balance=float(cf_balance),
|
||||
week_total=float(week_total),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{distribution_id}", response_model=CostDistributionOut)
|
||||
async def get_cost_distribution(
|
||||
distribution_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single cost distribution with line selections and entries."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.put("/{distribution_id}", response_model=CostDistributionOut)
|
||||
async def update_cost_distribution(
|
||||
distribution_id: int,
|
||||
data: CostDistributionUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update notes on an existing distribution."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status != DistributionStatus.ACTIVE.value:
|
||||
raise HTTPException(400, "Can only update ACTIVE distributions")
|
||||
|
||||
if data.notes is not None:
|
||||
distribution.notes = data.notes
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.delete("/{distribution_id}")
|
||||
async def cancel_cost_distribution(
|
||||
distribution_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a cost distribution. Non-admin restricted to distributions
|
||||
where the source (invoice) date is within 14 days."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution).where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status == DistributionStatus.CANCELLED.value:
|
||||
raise HTTPException(400, "Distribution is already cancelled")
|
||||
|
||||
# Anti-gaming: non-admin cannot cancel distributions where the source
|
||||
# date is more than 14 days in the past (cost would silently revert to
|
||||
# the old invoice date and could be overlooked)
|
||||
if not current_user.is_admin:
|
||||
min_allowed_date = date.today() - timedelta(days=14)
|
||||
if distribution.source_date < min_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot cancel — invoice date {distribution.source_date} is more than 14 days ago. Ask an admin."
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
distribution.status = DistributionStatus.CANCELLED.value
|
||||
distribution.cancelled_by = current_user.id
|
||||
distribution.cancelled_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Cost distribution cancelled", "id": distribution_id}
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/settle-early", response_model=CostDistributionOut)
|
||||
async def settle_early(
|
||||
distribution_id: int,
|
||||
data: SettleEarlyRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Settle a distribution early by moving remaining balance to a chosen date."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status != DistributionStatus.ACTIVE.value:
|
||||
raise HTTPException(400, "Can only settle ACTIVE distributions")
|
||||
|
||||
today = date.today()
|
||||
|
||||
# Anti-gaming: non-admin cannot settle more than 14 days in the past
|
||||
if not current_user.is_admin:
|
||||
min_allowed_date = today - timedelta(days=14)
|
||||
if data.entry_date < min_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot settle more than 14 days in the past. Earliest allowed: {min_allowed_date}"
|
||||
)
|
||||
|
||||
# Calculate entries from the settle date onwards — these get replaced by the single
|
||||
# settle entry. Using entry_date (not today) so that settling for yesterday correctly
|
||||
# consolidates yesterday's entry + all future entries into the chosen date.
|
||||
settable_entries = [
|
||||
e for e in distribution.entries
|
||||
if not e.is_source_offset and not e.is_overpay and e.entry_date >= data.entry_date
|
||||
]
|
||||
settable_total = sum(e.amount for e in settable_entries)
|
||||
|
||||
if settable_total <= 0:
|
||||
raise HTTPException(400, "No entries to settle from the chosen date onwards")
|
||||
|
||||
settle_amount = data.amount if data.amount is not None else settable_total
|
||||
if settle_amount <= 0:
|
||||
raise HTTPException(400, "Settle amount must be greater than 0")
|
||||
if settle_amount > settable_total:
|
||||
raise HTTPException(400, f"Settle amount {settle_amount} exceeds settable entries total {settable_total}")
|
||||
|
||||
# Delete all entries from the settle date onwards
|
||||
for entry in settable_entries:
|
||||
await db.delete(entry)
|
||||
|
||||
# Create settle entry on the chosen date
|
||||
overpay_entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=data.entry_date,
|
||||
amount=settle_amount,
|
||||
is_source_offset=False,
|
||||
is_overpay=True,
|
||||
)
|
||||
db.add(overpay_entry)
|
||||
|
||||
# Update remaining_balance: what's left unaccounted
|
||||
new_remaining = settable_total - settle_amount
|
||||
distribution.remaining_balance = new_remaining
|
||||
|
||||
# If fully settled, mark as completed
|
||||
if new_remaining <= 0:
|
||||
distribution.status = DistributionStatus.COMPLETED.value
|
||||
distribution.remaining_balance = Decimal("0")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
# ── Response builder helper ───────────────────────────────────────────────────
|
||||
|
||||
async def _build_distribution_response(
|
||||
db: AsyncSession, distribution: CostDistribution
|
||||
) -> CostDistributionOut:
|
||||
"""Build the full response object for a cost distribution."""
|
||||
# Get invoice info
|
||||
invoice = await db.execute(
|
||||
select(Invoice).where(Invoice.id == distribution.invoice_id)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
|
||||
supplier_name = None
|
||||
if invoice and invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
if not supplier_name and invoice:
|
||||
supplier_name = invoice.vendor_name
|
||||
|
||||
# Get creator name
|
||||
creator = await db.execute(
|
||||
select(User).where(User.id == distribution.created_by)
|
||||
)
|
||||
creator = creator.scalar_one_or_none()
|
||||
|
||||
# Build line selections with line item details
|
||||
line_selections = []
|
||||
for sel in distribution.line_selections:
|
||||
li = sel.line_item if hasattr(sel, 'line_item') and sel.line_item else None
|
||||
if not li:
|
||||
li_result = await db.execute(select(LineItem).where(LineItem.id == sel.line_item_id))
|
||||
li = li_result.scalar_one_or_none()
|
||||
|
||||
line_selections.append(LineSelectionOut(
|
||||
id=sel.id,
|
||||
line_item_id=sel.line_item_id,
|
||||
description=li.description if li else None,
|
||||
original_quantity=li.quantity if li else None,
|
||||
selected_quantity=sel.selected_quantity,
|
||||
unit_price=sel.unit_price,
|
||||
distributed_value=sel.distributed_value,
|
||||
))
|
||||
|
||||
entries = [
|
||||
EntryOut(
|
||||
id=e.id,
|
||||
entry_date=e.entry_date,
|
||||
amount=e.amount,
|
||||
is_source_offset=e.is_source_offset,
|
||||
is_overpay=e.is_overpay,
|
||||
)
|
||||
for e in distribution.entries
|
||||
]
|
||||
|
||||
return CostDistributionOut(
|
||||
id=distribution.id,
|
||||
invoice_id=distribution.invoice_id,
|
||||
invoice_number=invoice.invoice_number if invoice else None,
|
||||
invoice_date=invoice.invoice_date if invoice else None,
|
||||
supplier_name=supplier_name,
|
||||
status=distribution.status,
|
||||
method=distribution.method,
|
||||
notes=distribution.notes,
|
||||
total_distributed_value=distribution.total_distributed_value,
|
||||
remaining_balance=distribution.remaining_balance,
|
||||
source_date=distribution.source_date,
|
||||
created_by_name=creator.name if creator else None,
|
||||
created_at=distribution.created_at.isoformat() if distribution.created_at else "",
|
||||
line_selections=line_selections,
|
||||
entries=entries,
|
||||
)
|
||||
648
backend/api/cover_overrides.py
Normal file
648
backend/api/cover_overrides.py
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
"""
|
||||
Cover Override API endpoints.
|
||||
|
||||
Handles forecast snapshots, cover overrides, and spend rate overrides
|
||||
for the Spend Budget feature.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text, select, delete
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
from services.forecast_api import ForecastAPIClient, ForecastAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
class SnapshotRequest(BaseModel):
|
||||
week_offset: int = 0
|
||||
|
||||
|
||||
class CoverOverrideRequest(BaseModel):
|
||||
override_date: str # YYYY-MM-DD
|
||||
period: str # 'lunch' or 'dinner'
|
||||
override_covers: int # target total covers
|
||||
|
||||
|
||||
class SpendRateOverrideRequest(BaseModel):
|
||||
week_offset: int = 0
|
||||
period: str # 'breakfast', 'lunch', or 'dinner'
|
||||
food_spend: Optional[float] = None
|
||||
drinks_spend: Optional[float] = None
|
||||
|
||||
|
||||
class SnapshotData(BaseModel):
|
||||
date: str
|
||||
period: str
|
||||
forecast_covers: int
|
||||
otb_covers: int
|
||||
food_spend: Optional[float]
|
||||
drinks_spend: Optional[float]
|
||||
forecast_dry_revenue: Optional[float]
|
||||
|
||||
|
||||
class OverrideData(BaseModel):
|
||||
id: int
|
||||
override_date: str
|
||||
period: str
|
||||
override_covers: int
|
||||
original_forecast: Optional[int]
|
||||
original_otb: Optional[int]
|
||||
|
||||
|
||||
class SpendRateData(BaseModel):
|
||||
period: str
|
||||
food_spend_api: Optional[float]
|
||||
drinks_spend_api: Optional[float]
|
||||
food_spend_snapshot: Optional[float]
|
||||
drinks_spend_snapshot: Optional[float]
|
||||
food_spend_override: Optional[float]
|
||||
drinks_spend_override: Optional[float]
|
||||
food_spend_effective: float
|
||||
drinks_spend_effective: float
|
||||
|
||||
|
||||
class RecalcDay(BaseModel):
|
||||
date: str
|
||||
day_name: str
|
||||
is_past: bool
|
||||
periods: dict # period -> {actual, otb, pickup, effective, override, snapshot, variance}
|
||||
day_revenue: float
|
||||
|
||||
|
||||
class WeeklyOverrideResponse(BaseModel):
|
||||
week_start: str
|
||||
week_end: str
|
||||
has_snapshot: bool
|
||||
vat_rate: float = 1.20
|
||||
snapshot_revenue: Optional[float] = None
|
||||
adjusted_revenue: Optional[float] = None
|
||||
snapshots: list[SnapshotData] = []
|
||||
overrides: list[OverrideData] = []
|
||||
spend_rates: list[SpendRateData] = []
|
||||
recalc_days: list[RecalcDay] = []
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def get_week_dates(week_offset: int = 0) -> tuple[date, date, list[date]]:
|
||||
today = date.today()
|
||||
current_monday = today - timedelta(days=today.weekday())
|
||||
week_start = current_monday + timedelta(weeks=week_offset)
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_dates = [week_start + timedelta(days=i) for i in range(7)]
|
||||
return week_start, week_end, week_dates
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings:
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Kitchen settings not found")
|
||||
return settings
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/weekly", response_model=WeeklyOverrideResponse)
|
||||
async def get_weekly_overrides(
|
||||
week_offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get snapshot, overrides, spend rates, and recalculated breakdown for a week."""
|
||||
week_start, week_end, week_dates = get_week_dates(week_offset)
|
||||
kitchen_id = current_user.kitchen_id
|
||||
today = date.today()
|
||||
|
||||
# Check if snapshot exists
|
||||
snap_result = await db.execute(text("""
|
||||
SELECT snapshot_date, period, forecast_covers, otb_covers,
|
||||
food_spend, drinks_spend, forecast_dry_revenue
|
||||
FROM forecast_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
ORDER BY snapshot_date, period
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
snap_rows = snap_result.fetchall()
|
||||
|
||||
has_snapshot = len(snap_rows) > 0
|
||||
snapshots = []
|
||||
snap_lookup = {} # (date_str, period) -> row
|
||||
|
||||
for row in snap_rows:
|
||||
date_str = row.snapshot_date.isoformat() if hasattr(row.snapshot_date, 'isoformat') else str(row.snapshot_date)
|
||||
snapshots.append(SnapshotData(
|
||||
date=date_str,
|
||||
period=row.period,
|
||||
forecast_covers=row.forecast_covers,
|
||||
otb_covers=row.otb_covers,
|
||||
food_spend=float(row.food_spend) if row.food_spend else None,
|
||||
drinks_spend=float(row.drinks_spend) if row.drinks_spend else None,
|
||||
forecast_dry_revenue=float(row.forecast_dry_revenue) if row.forecast_dry_revenue else None,
|
||||
))
|
||||
snap_lookup[(date_str, row.period)] = row
|
||||
|
||||
# Get week snapshot for revenue totals
|
||||
week_snap_result = await db.execute(text("""
|
||||
SELECT total_forecast_revenue, total_otb_revenue, gp_target
|
||||
FROM forecast_week_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
week_snap = week_snap_result.fetchone()
|
||||
snapshot_revenue = float(week_snap.total_forecast_revenue) if week_snap and week_snap.total_forecast_revenue else None
|
||||
|
||||
# Get overrides
|
||||
override_result = await db.execute(text("""
|
||||
SELECT id, override_date, period, override_covers, original_forecast, original_otb
|
||||
FROM cover_overrides
|
||||
WHERE kitchen_id = :kid
|
||||
AND override_date >= :ws AND override_date <= :we
|
||||
ORDER BY override_date, period
|
||||
"""), {"kid": kitchen_id, "ws": week_start, "we": week_end})
|
||||
override_rows = override_result.fetchall()
|
||||
|
||||
overrides = []
|
||||
override_lookup = {} # (date_str, period) -> row
|
||||
for row in override_rows:
|
||||
date_str = row.override_date.isoformat() if hasattr(row.override_date, 'isoformat') else str(row.override_date)
|
||||
overrides.append(OverrideData(
|
||||
id=row.id,
|
||||
override_date=date_str,
|
||||
period=row.period,
|
||||
override_covers=row.override_covers,
|
||||
original_forecast=row.original_forecast,
|
||||
original_otb=row.original_otb,
|
||||
))
|
||||
override_lookup[(date_str, row.period)] = row
|
||||
|
||||
# Get spend rate overrides
|
||||
spend_override_result = await db.execute(text("""
|
||||
SELECT id, period, food_spend, drinks_spend
|
||||
FROM spend_rate_overrides
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
spend_override_rows = spend_override_result.fetchall()
|
||||
spend_override_lookup = {row.period: row for row in spend_override_rows}
|
||||
|
||||
# Fetch live forecast data + spend rates from API
|
||||
settings = await get_settings(db, kitchen_id)
|
||||
api_spend_rates = {}
|
||||
api_vat_rate = 1.20
|
||||
covers_data = []
|
||||
revenue_data = []
|
||||
|
||||
if settings.forecast_api_url and settings.forecast_api_key:
|
||||
try:
|
||||
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
||||
try:
|
||||
sr = await client.get_spend_rates()
|
||||
api_spend_rates = sr.get("periods", {})
|
||||
api_vat_rate = sr.get("vat_rate", 1.20)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch spend rates: {e}")
|
||||
|
||||
try:
|
||||
covers_data = await client.get_covers_forecast(week_start, days=7)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch covers: {e}")
|
||||
|
||||
try:
|
||||
revenue_data = await client.get_revenue_forecast(week_start, days=7)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch revenue: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to forecast API: {e}")
|
||||
|
||||
# Build spend rates response (per period)
|
||||
spend_rates = []
|
||||
spend_effective = {} # period -> {food, drinks}
|
||||
for period in ("breakfast", "lunch", "dinner"):
|
||||
api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0)
|
||||
api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0)
|
||||
|
||||
# Get snapshot values (from first day of snapshot, same for all days in a period)
|
||||
snap_food = None
|
||||
snap_drinks = None
|
||||
for d in week_dates:
|
||||
key = (d.isoformat(), period)
|
||||
if key in snap_lookup:
|
||||
snap_food = float(snap_lookup[key].food_spend) if snap_lookup[key].food_spend else None
|
||||
snap_drinks = float(snap_lookup[key].drinks_spend) if snap_lookup[key].drinks_spend else None
|
||||
break
|
||||
|
||||
# Get override values
|
||||
ovr = spend_override_lookup.get(period)
|
||||
ovr_food = float(ovr.food_spend) if ovr and ovr.food_spend else None
|
||||
ovr_drinks = float(ovr.drinks_spend) if ovr and ovr.drinks_spend else None
|
||||
|
||||
# Resolve effective: override > snapshot > API
|
||||
eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food)
|
||||
eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks)
|
||||
|
||||
spend_effective[period] = {"food": eff_food, "drinks": eff_drinks}
|
||||
spend_rates.append(SpendRateData(
|
||||
period=period,
|
||||
food_spend_api=api_food,
|
||||
drinks_spend_api=api_drinks,
|
||||
food_spend_snapshot=snap_food,
|
||||
drinks_spend_snapshot=snap_drinks,
|
||||
food_spend_override=ovr_food,
|
||||
drinks_spend_override=ovr_drinks,
|
||||
food_spend_effective=eff_food,
|
||||
drinks_spend_effective=eff_drinks,
|
||||
))
|
||||
|
||||
# Build recalculated days
|
||||
covers_by_date = {d.get("date", ""): d for d in covers_data}
|
||||
revenue_by_date = {d.get("date", ""): d for d in revenue_data}
|
||||
recalc_days = []
|
||||
total_adjusted_revenue = Decimal("0")
|
||||
|
||||
for d in week_dates:
|
||||
date_str = d.isoformat()
|
||||
is_past = d < today
|
||||
day_covers = covers_by_date.get(date_str, {})
|
||||
day_revenue = revenue_by_date.get(date_str, {})
|
||||
day_name = day_covers.get("day", d.strftime("%a"))
|
||||
|
||||
periods_data = {}
|
||||
day_rev = Decimal("0")
|
||||
|
||||
for period in ("breakfast", "lunch", "dinner"):
|
||||
p_covers = day_covers.get(period, {})
|
||||
otb = p_covers.get("otb", 0) or 0
|
||||
forecast = p_covers.get("forecast", 0) or 0
|
||||
pickup = forecast - otb
|
||||
|
||||
# Get snapshot and override for this day/period
|
||||
snap = snap_lookup.get((date_str, period))
|
||||
ovr = override_lookup.get((date_str, period))
|
||||
|
||||
snap_forecast = snap.forecast_covers if snap else None
|
||||
override_val = ovr.override_covers if ovr else None
|
||||
|
||||
if is_past:
|
||||
# Past day: use actual (forecast value from API is actual for past dates)
|
||||
effective = forecast
|
||||
actual = forecast
|
||||
# Calculate variance vs override or snapshot
|
||||
variance = None
|
||||
if override_val is not None:
|
||||
diff = actual - override_val
|
||||
if diff != 0:
|
||||
variance = diff
|
||||
elif snap_forecast is not None:
|
||||
diff = actual - snap_forecast
|
||||
if diff != 0:
|
||||
variance = diff
|
||||
|
||||
# Past revenue from API (dry revenue)
|
||||
dry = day_revenue.get("dry", {})
|
||||
period_rev = Decimal(str(dry.get("forecast", 0) or 0)) if period == "breakfast" else Decimal("0")
|
||||
# For past days, use actual total revenue from the API (proportioned by period isn't available,
|
||||
# so we'll use effective_covers * spend_rates as approximation, but actual total from revenue API)
|
||||
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
||||
period_rev = Decimal(str(effective)) * eff_food
|
||||
|
||||
periods_data[period] = {
|
||||
"actual": actual,
|
||||
"otb": otb,
|
||||
"pickup": 0,
|
||||
"effective": effective,
|
||||
"override": override_val,
|
||||
"snapshot": snap_forecast,
|
||||
"variance": variance,
|
||||
"is_overridden": False,
|
||||
}
|
||||
else:
|
||||
# Today/future: apply override logic
|
||||
if override_val is not None:
|
||||
if otb >= override_val:
|
||||
effective = otb # OTB supersedes upward
|
||||
adj_pickup = 0
|
||||
else:
|
||||
effective = override_val
|
||||
adj_pickup = override_val - otb
|
||||
is_overridden = True
|
||||
else:
|
||||
effective = forecast
|
||||
adj_pickup = pickup
|
||||
is_overridden = False
|
||||
|
||||
# Calculate revenue for this period (dry/food only - budget tracks food revenue)
|
||||
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
||||
period_rev = Decimal(str(effective)) * eff_food
|
||||
|
||||
periods_data[period] = {
|
||||
"actual": None,
|
||||
"otb": otb,
|
||||
"pickup": adj_pickup,
|
||||
"effective": effective,
|
||||
"override": override_val,
|
||||
"snapshot": snap_forecast,
|
||||
"variance": None,
|
||||
"is_overridden": is_overridden,
|
||||
}
|
||||
|
||||
day_rev += period_rev
|
||||
|
||||
# For past days, use actual revenue from API if available
|
||||
if is_past and day_revenue:
|
||||
dry = day_revenue.get("dry", {})
|
||||
actual_dry_rev = Decimal(str(dry.get("forecast", 0) or 0))
|
||||
if actual_dry_rev > 0:
|
||||
day_rev = actual_dry_rev
|
||||
|
||||
total_adjusted_revenue += day_rev
|
||||
|
||||
recalc_days.append(RecalcDay(
|
||||
date=date_str,
|
||||
day_name=day_name,
|
||||
is_past=is_past,
|
||||
periods=periods_data,
|
||||
day_revenue=float(round(day_rev, 2)),
|
||||
))
|
||||
|
||||
return WeeklyOverrideResponse(
|
||||
week_start=week_start.isoformat(),
|
||||
week_end=week_end.isoformat(),
|
||||
has_snapshot=has_snapshot,
|
||||
vat_rate=api_vat_rate,
|
||||
snapshot_revenue=snapshot_revenue,
|
||||
adjusted_revenue=float(round(total_adjusted_revenue, 2)),
|
||||
snapshots=snapshots,
|
||||
overrides=overrides,
|
||||
spend_rates=spend_rates,
|
||||
recalc_days=recalc_days,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/snapshot")
|
||||
async def create_snapshot(
|
||||
req: SnapshotRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Take a snapshot of the current forecast for a week (all periods, all days)."""
|
||||
week_start, week_end, week_dates = get_week_dates(req.week_offset)
|
||||
kitchen_id = current_user.kitchen_id
|
||||
settings = await get_settings(db, kitchen_id)
|
||||
|
||||
if not settings.forecast_api_url or not settings.forecast_api_key:
|
||||
raise HTTPException(status_code=400, detail="Forecast API not configured")
|
||||
|
||||
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
||||
covers_data = await client.get_covers_forecast(week_start, days=7)
|
||||
revenue_data = await client.get_revenue_forecast(week_start, days=7)
|
||||
spend_rates_response = await client.get_spend_rates()
|
||||
|
||||
api_spend = spend_rates_response.get("periods", {})
|
||||
vat_rate = spend_rates_response.get("vat_rate", 1.20)
|
||||
|
||||
covers_by_date = {d.get("date", ""): d for d in covers_data}
|
||||
revenue_by_date = {d.get("date", ""): d for d in revenue_data}
|
||||
|
||||
# Delete existing snapshots for this week (re-snapshot)
|
||||
await db.execute(text("""
|
||||
DELETE FROM forecast_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
|
||||
# Insert snapshot for each day/period
|
||||
total_forecast_rev = Decimal("0")
|
||||
total_otb_rev = Decimal("0")
|
||||
|
||||
for d in week_dates:
|
||||
date_str = d.isoformat()
|
||||
day_covers = covers_by_date.get(date_str, {})
|
||||
day_revenue = revenue_by_date.get(date_str, {})
|
||||
|
||||
for period in ("breakfast", "lunch", "dinner"):
|
||||
p = day_covers.get(period, {})
|
||||
otb = p.get("otb", 0) or 0
|
||||
forecast = p.get("forecast", 0) or 0
|
||||
|
||||
# Spend rates (net, ex VAT)
|
||||
food_net = api_spend.get(period, {}).get("food_spend_net", 0)
|
||||
drinks_net = api_spend.get(period, {}).get("drinks_spend_net", 0)
|
||||
|
||||
# Calculate dry revenue for this period/day
|
||||
dry_rev = Decimal(str(forecast)) * Decimal(str(food_net))
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO forecast_snapshots
|
||||
(kitchen_id, snapshot_date, period, forecast_covers, otb_covers,
|
||||
food_spend, drinks_spend, forecast_dry_revenue, week_start)
|
||||
VALUES (:kid, :sd, :period, :fc, :oc, :fs, :ds, :dr, :ws)
|
||||
"""), {
|
||||
"kid": kitchen_id, "sd": d, "period": period,
|
||||
"fc": forecast, "oc": otb,
|
||||
"fs": food_net, "ds": drinks_net,
|
||||
"dr": float(round(dry_rev, 2)),
|
||||
"ws": week_start,
|
||||
})
|
||||
|
||||
# Accumulate weekly totals from revenue API
|
||||
dry = day_revenue.get("dry", {})
|
||||
total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
|
||||
total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
|
||||
|
||||
# Divide by 3 since we're iterating 3 periods but revenue data is per-day total
|
||||
# Actually, the revenue API returns per-day totals, not per-period.
|
||||
# We accumulated 3x per day. Let's recalculate from revenue_data directly.
|
||||
total_forecast_rev = Decimal("0")
|
||||
total_otb_rev = Decimal("0")
|
||||
for d in revenue_data:
|
||||
dry = d.get("dry", {})
|
||||
total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
|
||||
total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
|
||||
|
||||
# Upsert week snapshot
|
||||
gp_target = float(settings.budget_gp_target) if settings.budget_gp_target else 65.0
|
||||
|
||||
await db.execute(text("""
|
||||
DELETE FROM forecast_week_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO forecast_week_snapshots
|
||||
(kitchen_id, week_start, total_forecast_revenue, total_otb_revenue, gp_target)
|
||||
VALUES (:kid, :ws, :tfr, :tor, :gp)
|
||||
"""), {
|
||||
"kid": kitchen_id, "ws": week_start,
|
||||
"tfr": float(round(total_forecast_rev, 2)),
|
||||
"tor": float(round(total_otb_rev, 2)),
|
||||
"gp": gp_target,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Forecast snapshot created",
|
||||
"week_start": week_start.isoformat(),
|
||||
"total_forecast_revenue": float(round(total_forecast_rev, 2)),
|
||||
"total_otb_revenue": float(round(total_otb_rev, 2)),
|
||||
"days_snapshotted": len(week_dates),
|
||||
"periods_per_day": 3,
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def upsert_cover_override(
|
||||
req: CoverOverrideRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Set or update a cover override for a specific date+period."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
|
||||
if req.period not in ("lunch", "dinner"):
|
||||
raise HTTPException(status_code=400, detail="Period must be 'lunch' or 'dinner'")
|
||||
|
||||
if req.override_covers < 0:
|
||||
raise HTTPException(status_code=400, detail="Override covers cannot be negative")
|
||||
|
||||
override_date = date.fromisoformat(req.override_date)
|
||||
|
||||
# Check existing
|
||||
existing = await db.execute(text("""
|
||||
SELECT id FROM cover_overrides
|
||||
WHERE kitchen_id = :kid AND override_date = :od AND period = :p
|
||||
"""), {"kid": kitchen_id, "od": override_date, "p": req.period})
|
||||
row = existing.fetchone()
|
||||
|
||||
if row:
|
||||
await db.execute(text("""
|
||||
UPDATE cover_overrides
|
||||
SET override_covers = :oc, updated_by = :uid, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""), {"oc": req.override_covers, "uid": current_user.id, "id": row.id})
|
||||
else:
|
||||
# Get current forecast for snapshot
|
||||
settings = await get_settings(db, kitchen_id)
|
||||
original_forecast = None
|
||||
original_otb = None
|
||||
|
||||
if settings.forecast_api_url and settings.forecast_api_key:
|
||||
try:
|
||||
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
||||
covers = await client.get_covers_forecast(override_date, days=1)
|
||||
if covers:
|
||||
p = covers[0].get(req.period, {})
|
||||
original_forecast = p.get("forecast", 0) or 0
|
||||
original_otb = p.get("otb", 0) or 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get forecast for snapshot: {e}")
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO cover_overrides
|
||||
(kitchen_id, override_date, period, override_covers,
|
||||
original_forecast, original_otb, created_by, updated_by)
|
||||
VALUES (:kid, :od, :p, :oc, :of, :oo, :uid, :uid)
|
||||
"""), {
|
||||
"kid": kitchen_id, "od": override_date, "p": req.period,
|
||||
"oc": req.override_covers, "of": original_forecast, "oo": original_otb,
|
||||
"uid": current_user.id,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "date": req.override_date, "period": req.period, "covers": req.override_covers}
|
||||
|
||||
|
||||
@router.delete("/{override_id}")
|
||||
async def delete_cover_override(
|
||||
override_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove a cover override (revert to forecast)."""
|
||||
result = await db.execute(text("""
|
||||
DELETE FROM cover_overrides
|
||||
WHERE id = :id AND kitchen_id = :kid
|
||||
RETURNING id
|
||||
"""), {"id": override_id, "kid": current_user.kitchen_id})
|
||||
deleted = result.fetchone()
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Override not found")
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "deleted_id": override_id}
|
||||
|
||||
|
||||
@router.put("/spend-rates")
|
||||
async def upsert_spend_rate_override(
|
||||
req: SpendRateOverrideRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Set or update a spend rate override for a week+period."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
week_start, _, _ = get_week_dates(req.week_offset)
|
||||
|
||||
if req.period not in ("breakfast", "lunch", "dinner"):
|
||||
raise HTTPException(status_code=400, detail="Period must be 'breakfast', 'lunch', or 'dinner'")
|
||||
|
||||
# Check existing
|
||||
existing = await db.execute(text("""
|
||||
SELECT id FROM spend_rate_overrides
|
||||
WHERE kitchen_id = :kid AND week_start = :ws AND period = :p
|
||||
"""), {"kid": kitchen_id, "ws": week_start, "p": req.period})
|
||||
row = existing.fetchone()
|
||||
|
||||
if row:
|
||||
await db.execute(text("""
|
||||
UPDATE spend_rate_overrides
|
||||
SET food_spend = :fs, drinks_spend = :ds, updated_by = :uid, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""), {"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id, "id": row.id})
|
||||
else:
|
||||
await db.execute(text("""
|
||||
INSERT INTO spend_rate_overrides
|
||||
(kitchen_id, week_start, period, food_spend, drinks_spend, created_by, updated_by)
|
||||
VALUES (:kid, :ws, :p, :fs, :ds, :uid, :uid)
|
||||
"""), {
|
||||
"kid": kitchen_id, "ws": week_start, "p": req.period,
|
||||
"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "week_start": week_start.isoformat(), "period": req.period}
|
||||
|
||||
|
||||
@router.delete("/spend-rates/{override_id}")
|
||||
async def delete_spend_rate_override(
|
||||
override_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove a spend rate override (revert to snapshot/API value)."""
|
||||
result = await db.execute(text("""
|
||||
DELETE FROM spend_rate_overrides
|
||||
WHERE id = :id AND kitchen_id = :kid
|
||||
RETURNING id
|
||||
"""), {"id": override_id, "kid": current_user.kitchen_id})
|
||||
deleted = result.fetchone()
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Spend rate override not found")
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "deleted_id": override_id}
|
||||
327
backend/api/credit_notes.py
Normal file
327
backend/api/credit_notes.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""
|
||||
API endpoints for credit note management.
|
||||
|
||||
Handles:
|
||||
- Credit note upload
|
||||
- Credit note download
|
||||
- Credit note CRUD operations
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import date as date_type
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.dispute import CreditNote, InvoiceDispute, DisputeStatus, DisputeActivity
|
||||
from models.invoice import Invoice
|
||||
from models.supplier import Supplier
|
||||
from services.dispute_archival_service import DisputeArchivalService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic Schemas
|
||||
|
||||
class CreditNoteResponse(BaseModel):
|
||||
id: int
|
||||
invoice_id: int
|
||||
supplier_id: int
|
||||
supplier_name: str
|
||||
credit_note_number: str
|
||||
credit_date: str
|
||||
credit_amount: float
|
||||
reason: Optional[str]
|
||||
notes: Optional[str]
|
||||
file_storage_location: str
|
||||
created_at: str
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_credit_note(
|
||||
file: UploadFile = File(...),
|
||||
invoice_id: int = Form(...),
|
||||
credit_note_number: str = Form(...),
|
||||
credit_date: str = Form(...),
|
||||
credit_amount: float = Form(...),
|
||||
dispute_id: Optional[int] = Form(None),
|
||||
reason: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload credit note PDF"""
|
||||
|
||||
# Verify invoice exists and belongs to kitchen
|
||||
result = await db.execute(
|
||||
select(Invoice).where(
|
||||
and_(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
invoice = result.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
# Parse credit date
|
||||
try:
|
||||
credit_date_obj = date_type.fromisoformat(credit_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
|
||||
# Validate file type (should be PDF)
|
||||
if file.content_type and "pdf" not in file.content_type.lower():
|
||||
raise HTTPException(status_code=400, detail="Only PDF files are supported for credit notes")
|
||||
|
||||
# Save file
|
||||
archival_service = DisputeArchivalService(db, current_user.kitchen_id)
|
||||
success, file_path = await archival_service.save_credit_note(
|
||||
invoice,
|
||||
file_content,
|
||||
file.filename or "credit_note.pdf"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}")
|
||||
|
||||
# Create credit note record
|
||||
credit_note = CreditNote(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
invoice_id=invoice_id,
|
||||
supplier_id=invoice.supplier_id or 0, # Use 0 if no supplier (will need to handle)
|
||||
credit_note_number=credit_note_number,
|
||||
credit_date=credit_date_obj,
|
||||
credit_amount=Decimal(str(credit_amount)),
|
||||
reason=reason,
|
||||
notes=notes,
|
||||
file_path=file_path,
|
||||
file_storage_location="local",
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(credit_note)
|
||||
await db.flush() # Get credit_note.id
|
||||
|
||||
# If linked to dispute, update dispute
|
||||
if dispute_id:
|
||||
result = await db.execute(
|
||||
select(InvoiceDispute).where(
|
||||
and_(
|
||||
InvoiceDispute.id == dispute_id,
|
||||
InvoiceDispute.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if dispute:
|
||||
# Link credit note to dispute
|
||||
dispute.credit_note_id = credit_note.id
|
||||
dispute.resolved_amount = credit_note.credit_amount
|
||||
|
||||
# Update dispute status if still open/in_progress
|
||||
if dispute.status in [DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS]:
|
||||
dispute.status = DisputeStatus.AWAITING_CREDIT
|
||||
|
||||
# Log activity
|
||||
activity = DisputeActivity(
|
||||
dispute_id=dispute_id,
|
||||
activity_type="credit_note_added",
|
||||
description=f"Credit note {credit_note_number} (£{credit_amount:.2f}) linked to dispute",
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(credit_note)
|
||||
|
||||
# Archive to Nextcloud if enabled
|
||||
success, result = await archival_service.archive_credit_note(credit_note)
|
||||
if success:
|
||||
await db.commit() # Update archived status
|
||||
|
||||
return {
|
||||
"id": credit_note.id,
|
||||
"credit_note_number": credit_note_number,
|
||||
"credit_amount": credit_amount,
|
||||
"archived": success
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{credit_note_id}")
|
||||
async def get_credit_note(
|
||||
credit_note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> CreditNoteResponse:
|
||||
"""Get credit note details"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).options(
|
||||
selectinload(CreditNote.supplier),
|
||||
selectinload(CreditNote.created_by_user)
|
||||
).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
return CreditNoteResponse(
|
||||
id=credit_note.id,
|
||||
invoice_id=credit_note.invoice_id,
|
||||
supplier_id=credit_note.supplier_id,
|
||||
supplier_name=credit_note.supplier.name if credit_note.supplier else "Unknown",
|
||||
credit_note_number=credit_note.credit_note_number,
|
||||
credit_date=credit_note.credit_date.isoformat(),
|
||||
credit_amount=float(credit_note.credit_amount),
|
||||
reason=credit_note.reason,
|
||||
notes=credit_note.notes,
|
||||
file_storage_location=credit_note.file_storage_location,
|
||||
created_at=credit_note.created_at.isoformat(),
|
||||
created_by=credit_note.created_by_user.name if credit_note.created_by_user else "Unknown"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{credit_note_id}/download")
|
||||
async def download_credit_note(
|
||||
credit_note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Download credit note PDF"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
# Get file content
|
||||
archival_service = DisputeArchivalService(db, current_user.kitchen_id)
|
||||
success, content = await archival_service.get_credit_note_content(credit_note)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{credit_note.credit_note_number}.pdf"'}
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{credit_note_id}")
|
||||
async def update_credit_note(
|
||||
credit_note_id: int,
|
||||
credit_note_number: Optional[str] = None,
|
||||
credit_date: Optional[str] = None,
|
||||
credit_amount: Optional[float] = None,
|
||||
reason: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update credit note details"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
if credit_note_number:
|
||||
credit_note.credit_note_number = credit_note_number
|
||||
|
||||
if credit_date:
|
||||
try:
|
||||
credit_note.credit_date = date_type.fromisoformat(credit_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if credit_amount is not None:
|
||||
credit_note.credit_amount = Decimal(str(credit_amount))
|
||||
|
||||
if reason is not None:
|
||||
credit_note.reason = reason
|
||||
|
||||
if notes is not None:
|
||||
credit_note.notes = notes
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/{credit_note_id}")
|
||||
async def delete_credit_note(
|
||||
credit_note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete credit note"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
# Check if linked to dispute
|
||||
result = await db.execute(
|
||||
select(InvoiceDispute).where(InvoiceDispute.credit_note_id == credit_note_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if dispute:
|
||||
# Unlink from dispute
|
||||
dispute.credit_note_id = None
|
||||
dispute.resolved_amount = None
|
||||
|
||||
await db.delete(credit_note)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted"}
|
||||
1125
backend/api/disputes.py
Normal file
1125
backend/api/disputes.py
Normal file
File diff suppressed because it is too large
Load diff
694
backend/api/event_orders.py
Normal file
694
backend/api/event_orders.py
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
"""
|
||||
Event Order API — create event orders, add recipes × quantities,
|
||||
generate aggregated shopping lists, and optionally create purchase orders.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.event_order import EventOrder, EventOrderItem
|
||||
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe
|
||||
from models.ingredient import Ingredient, IngredientSource, IngredientCategory
|
||||
from models.supplier import Supplier
|
||||
from auth import get_current_user, require_cap
|
||||
from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
class EventOrderCreate(BaseModel):
|
||||
name: str
|
||||
event_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class EventOrderUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
event_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class EventOrderItemAdd(BaseModel):
|
||||
recipe_id: int
|
||||
quantity: int
|
||||
notes: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
class EventOrderItemUpdate(BaseModel):
|
||||
quantity: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
class BulkItemEntry(BaseModel):
|
||||
recipe_id: int
|
||||
quantity: int
|
||||
notes: Optional[str] = None
|
||||
|
||||
class EventOrderBulkAdd(BaseModel):
|
||||
items: list[BulkItemEntry]
|
||||
|
||||
class EventOrderResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
event_date: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str
|
||||
item_count: int = 0
|
||||
estimated_cost: Optional[float] = None
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
class EventOrderItemResponse(BaseModel):
|
||||
id: int
|
||||
recipe_id: int
|
||||
recipe_name: str = ""
|
||||
recipe_type: str = ""
|
||||
batch_portions: int = 1
|
||||
quantity: int
|
||||
cost_per_portion: Optional[float] = None
|
||||
subtotal: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
# ── Event Order CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_event_orders(
|
||||
status: Optional[str] = Query(None),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = (
|
||||
select(EventOrder)
|
||||
.options(selectinload(EventOrder.items))
|
||||
.where(EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
if status:
|
||||
query = query.where(EventOrder.status == status)
|
||||
|
||||
result = await db.execute(query.order_by(EventOrder.event_date.desc().nullslast(), EventOrder.created_at.desc()))
|
||||
orders = result.scalars().all()
|
||||
|
||||
responses = []
|
||||
for o in orders:
|
||||
responses.append(EventOrderResponse(
|
||||
id=o.id,
|
||||
name=o.name,
|
||||
event_date=str(o.event_date) if o.event_date else None,
|
||||
notes=o.notes,
|
||||
status=o.status,
|
||||
item_count=len(o.items) if o.items else 0,
|
||||
created_at=str(o.created_at) if o.created_at else "",
|
||||
updated_at=str(o.updated_at) if o.updated_at else "",
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_event_order(
|
||||
data: EventOrderCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order = EventOrder(
|
||||
kitchen_id=user.kitchen_id,
|
||||
name=data.name,
|
||||
event_date=data.event_date,
|
||||
notes=data.notes,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return {"id": order.id, "name": order.name}
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
async def get_event_order(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventOrder)
|
||||
.options(selectinload(EventOrder.items).selectinload(EventOrderItem.recipe))
|
||||
.where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
|
||||
items = []
|
||||
for item in sorted(order.items, key=lambda x: x.sort_order):
|
||||
recipe = item.recipe
|
||||
# Get cost per portion from latest snapshot
|
||||
from models.recipe import RecipeCostSnapshot
|
||||
snap_result = await db.execute(
|
||||
select(RecipeCostSnapshot)
|
||||
.where(RecipeCostSnapshot.recipe_id == item.recipe_id)
|
||||
.order_by(RecipeCostSnapshot.snapshot_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
snap = snap_result.scalar_one_or_none()
|
||||
cpp = float(snap.cost_per_portion) if snap else None
|
||||
subtotal = cpp * item.quantity if cpp else None
|
||||
|
||||
items.append(EventOrderItemResponse(
|
||||
id=item.id,
|
||||
recipe_id=item.recipe_id,
|
||||
recipe_name=recipe.name if recipe else "",
|
||||
recipe_type=recipe.recipe_type if recipe else "",
|
||||
batch_portions=recipe.batch_portions if recipe else 1,
|
||||
quantity=item.quantity,
|
||||
cost_per_portion=cpp,
|
||||
subtotal=round(subtotal, 2) if subtotal else None,
|
||||
notes=item.notes,
|
||||
sort_order=item.sort_order,
|
||||
))
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"name": order.name,
|
||||
"event_date": str(order.event_date) if order.event_date else None,
|
||||
"notes": order.notes,
|
||||
"status": order.status,
|
||||
"items": [i.model_dump() for i in items],
|
||||
"created_at": str(order.created_at) if order.created_at else "",
|
||||
"updated_at": str(order.updated_at) if order.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{order_id}")
|
||||
async def update_event_order(
|
||||
order_id: int,
|
||||
data: EventOrderUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
if data.name is not None:
|
||||
order.name = data.name
|
||||
if data.event_date is not None:
|
||||
order.event_date = data.event_date
|
||||
if data.notes is not None:
|
||||
order.notes = data.notes
|
||||
if data.status is not None:
|
||||
order.status = data.status
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
async def delete_event_order(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
if order.status != "DRAFT":
|
||||
raise HTTPException(400, "Only DRAFT orders can be deleted")
|
||||
await db.delete(order)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Event Order Items ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/items")
|
||||
async def add_item(
|
||||
order_id: int,
|
||||
data: EventOrderItemAdd,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
recipe = await db.execute(
|
||||
select(Recipe).where(Recipe.id == data.recipe_id, Recipe.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
if not recipe.scalar_one_or_none():
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
item = EventOrderItem(
|
||||
event_order_id=order_id,
|
||||
recipe_id=data.recipe_id,
|
||||
quantity=data.quantity,
|
||||
notes=data.notes,
|
||||
sort_order=data.sort_order,
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return {"id": item.id}
|
||||
|
||||
|
||||
@router.post("/{order_id}/items/bulk")
|
||||
async def add_items_bulk(
|
||||
order_id: int,
|
||||
data: EventOrderBulkAdd,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add multiple recipes to an event order at once (e.g. from a menu)."""
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
|
||||
# Get existing recipe_ids on this order to detect duplicates
|
||||
existing_result = await db.execute(
|
||||
select(EventOrderItem.recipe_id).where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
existing_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
# Get max sort_order
|
||||
max_sort_result = await db.execute(
|
||||
select(func.coalesce(func.max(EventOrderItem.sort_order), -1))
|
||||
.where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
next_sort = (max_sort_result.scalar() or 0) + 1
|
||||
|
||||
# Validate all recipe_ids belong to this kitchen
|
||||
recipe_ids = [entry.recipe_id for entry in data.items]
|
||||
valid_result = await db.execute(
|
||||
select(Recipe.id).where(Recipe.id.in_(recipe_ids), Recipe.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
valid_ids = {r[0] for r in valid_result.all()}
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
for entry in data.items:
|
||||
if entry.recipe_id not in valid_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
if entry.recipe_id in existing_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
item = EventOrderItem(
|
||||
event_order_id=order_id,
|
||||
recipe_id=entry.recipe_id,
|
||||
quantity=entry.quantity,
|
||||
notes=entry.notes,
|
||||
sort_order=next_sort,
|
||||
)
|
||||
db.add(item)
|
||||
existing_ids.add(entry.recipe_id)
|
||||
next_sort += 1
|
||||
added += 1
|
||||
|
||||
await db.commit()
|
||||
return {"added": added, "skipped": skipped}
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}")
|
||||
async def update_item(
|
||||
item_id: int,
|
||||
data: EventOrderItemUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item not found")
|
||||
if data.quantity is not None:
|
||||
item.quantity = data.quantity
|
||||
if data.notes is not None:
|
||||
item.notes = data.notes
|
||||
if data.sort_order is not None:
|
||||
item.sort_order = data.sort_order
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}")
|
||||
async def delete_item(
|
||||
item_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item not found")
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Shopping List ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _collect_ingredients_for_recipe(
|
||||
recipe_id: int,
|
||||
multiplier: float,
|
||||
db: AsyncSession,
|
||||
depth: int = 0,
|
||||
) -> dict[int, float]:
|
||||
"""Recursively collect ingredient quantities for a recipe × multiplier.
|
||||
Returns {ingredient_id: total_quantity_in_standard_unit}."""
|
||||
if depth > 5:
|
||||
return {}
|
||||
|
||||
result: dict[int, float] = defaultdict(float)
|
||||
|
||||
# Direct ingredients (yield-adjusted: divide by yield to get required purchase qty)
|
||||
ri_result = await db.execute(
|
||||
select(RecipeIngredient)
|
||||
.options(selectinload(RecipeIngredient.ingredient))
|
||||
.where(RecipeIngredient.recipe_id == recipe_id)
|
||||
)
|
||||
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
|
||||
for ri in ri_result.scalars().all():
|
||||
yld = float(ri.yield_percent) if ri.yield_percent else 100.0
|
||||
raw_qty = float(ri.quantity) * multiplier
|
||||
# Convert from display unit to standard unit if needed
|
||||
if ri.unit and ri.ingredient and ri.unit != ri.ingredient.standard_unit:
|
||||
from_base = _bases.get(ri.unit)
|
||||
to_base = _bases.get(ri.ingredient.standard_unit)
|
||||
if from_base and to_base:
|
||||
raw_qty = raw_qty * from_base / to_base
|
||||
adjusted_qty = raw_qty / (yld / 100) if yld > 0 else raw_qty
|
||||
result[ri.ingredient_id] += adjusted_qty
|
||||
|
||||
# Sub-recipe ingredients
|
||||
sr_result = await db.execute(
|
||||
select(RecipeSubRecipe).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
|
||||
)
|
||||
for sr in sr_result.scalars().all():
|
||||
child_result = await db.execute(
|
||||
select(Recipe).where(Recipe.id == sr.child_recipe_id)
|
||||
)
|
||||
child_recipe = child_result.scalar_one_or_none()
|
||||
if not child_recipe:
|
||||
continue
|
||||
# Use unified output qty (handles both portioned and bulk)
|
||||
child_output_qty = float(child_recipe.batch_yield_qty) if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_qty else (child_recipe.batch_portions or 1)
|
||||
child_output_unit = child_recipe.batch_yield_unit if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_unit else "portion"
|
||||
# Convert portions_needed to child output unit if different unit was used
|
||||
needed = float(sr.portions_needed)
|
||||
needed_unit = sr.portions_needed_unit or child_output_unit
|
||||
if needed_unit != child_output_unit:
|
||||
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
|
||||
if needed_unit in _bases and child_output_unit in _bases:
|
||||
needed = needed * _bases[needed_unit] / _bases[child_output_unit]
|
||||
child_multiplier = multiplier * (needed / child_output_qty)
|
||||
child_ings = await _collect_ingredients_for_recipe(sr.child_recipe_id, child_multiplier, db, depth + 1)
|
||||
for ing_id, qty in child_ings.items():
|
||||
result[ing_id] += qty
|
||||
|
||||
return dict(result)
|
||||
|
||||
|
||||
@router.get("/{order_id}/shopping-list")
|
||||
async def get_shopping_list(
|
||||
order_id: int,
|
||||
group_by_supplier: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Aggregated ingredient shopping list across all event order items."""
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
|
||||
# Get all items
|
||||
items_result = await db.execute(
|
||||
select(EventOrderItem)
|
||||
.options(selectinload(EventOrderItem.recipe))
|
||||
.where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
# Aggregate ingredient quantities across all recipes
|
||||
total_ingredients: dict[int, float] = defaultdict(float)
|
||||
recipe_breakdown: dict[int, list] = defaultdict(list) # ingredient_id -> [{recipe, qty}]
|
||||
|
||||
for item in items:
|
||||
recipe = item.recipe
|
||||
if not recipe:
|
||||
continue
|
||||
|
||||
# For dish: multiplier = quantity (servings) / output_qty
|
||||
# For component: multiplier = quantity (batches)
|
||||
if recipe.recipe_type == "component":
|
||||
multiplier = float(item.quantity) # each item.quantity = number of batches
|
||||
else:
|
||||
# Dishes are always portioned with batch_portions=1
|
||||
multiplier = float(item.quantity) / (recipe.batch_portions or 1)
|
||||
|
||||
ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
|
||||
for ing_id, qty in ings.items():
|
||||
total_ingredients[ing_id] += qty
|
||||
recipe_breakdown[ing_id].append({
|
||||
"recipe_name": recipe.name,
|
||||
"quantity": round(qty, 3),
|
||||
})
|
||||
|
||||
if not total_ingredients:
|
||||
return {"items": [], "by_supplier": {}}
|
||||
|
||||
# Load ingredient details and sources
|
||||
ing_ids = list(total_ingredients.keys())
|
||||
ing_result = await db.execute(
|
||||
select(Ingredient)
|
||||
.options(
|
||||
selectinload(Ingredient.category),
|
||||
selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
|
||||
)
|
||||
.where(Ingredient.id.in_(ing_ids))
|
||||
)
|
||||
ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
|
||||
|
||||
shopping_items = []
|
||||
by_supplier: dict[str, list] = defaultdict(list)
|
||||
|
||||
for ing_id, total_qty in sorted(total_ingredients.items(), key=lambda x: x[0]):
|
||||
ing = ingredients.get(ing_id)
|
||||
if not ing:
|
||||
continue
|
||||
|
||||
# Quantity already yield-adjusted during collection
|
||||
adjusted_qty = total_qty
|
||||
|
||||
# Source info
|
||||
sources = []
|
||||
for src in (ing.sources or []):
|
||||
pack_total = None
|
||||
suggested_packs = None
|
||||
cost_per_pack = None
|
||||
|
||||
if src.pack_quantity and src.unit_size and src.unit_size_type:
|
||||
pack_in_std = convert_to_standard(
|
||||
Decimal(str(src.pack_quantity)) * src.unit_size,
|
||||
src.unit_size_type,
|
||||
ing.standard_unit,
|
||||
)
|
||||
if pack_in_std and float(pack_in_std) > 0:
|
||||
pack_total = float(pack_in_std)
|
||||
suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
|
||||
if src.latest_unit_price:
|
||||
cost_per_pack = float(src.latest_unit_price)
|
||||
|
||||
source_info = {
|
||||
"supplier_id": src.supplier_id,
|
||||
"supplier_name": src.supplier.name if src.supplier else "",
|
||||
"product_code": src.product_code,
|
||||
"pack_description": f"{src.pack_quantity}×{src.unit_size}{src.unit_size_type}" if src.pack_quantity and src.unit_size else None,
|
||||
"pack_total_std_unit": pack_total,
|
||||
"suggested_packs": suggested_packs,
|
||||
"cost_per_pack": cost_per_pack,
|
||||
"subtotal": round(cost_per_pack * suggested_packs, 2) if cost_per_pack and suggested_packs else None,
|
||||
}
|
||||
sources.append(source_info)
|
||||
|
||||
if group_by_supplier:
|
||||
supplier_name = src.supplier.name if src.supplier else "Unknown"
|
||||
by_supplier[supplier_name].append({
|
||||
"ingredient_name": ing.name,
|
||||
"quantity_needed": round(adjusted_qty, 3),
|
||||
"unit": ing.standard_unit,
|
||||
**source_info,
|
||||
})
|
||||
|
||||
item_data = {
|
||||
"ingredient_id": ing.id,
|
||||
"ingredient_name": ing.name,
|
||||
"category": ing.category.name if ing.category else "Other",
|
||||
"total_quantity": round(total_qty, 3),
|
||||
"adjusted_quantity": round(adjusted_qty, 3),
|
||||
"unit": ing.standard_unit,
|
||||
"sources": sources,
|
||||
"recipe_breakdown": recipe_breakdown.get(ing_id, []),
|
||||
}
|
||||
shopping_items.append(item_data)
|
||||
|
||||
# Sort by category
|
||||
shopping_items.sort(key=lambda x: (x["category"], x["ingredient_name"]))
|
||||
|
||||
return {"items": shopping_items, "by_supplier": dict(by_supplier) if group_by_supplier else {}}
|
||||
|
||||
|
||||
# ── Generate Purchase Orders ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/generate-po")
|
||||
async def generate_purchase_orders(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate purchase orders from shopping list, grouped by supplier."""
|
||||
from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
|
||||
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
|
||||
# Get shopping list grouped by supplier
|
||||
items_result = await db.execute(
|
||||
select(EventOrderItem)
|
||||
.options(selectinload(EventOrderItem.recipe))
|
||||
.where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
# Aggregate ingredient quantities
|
||||
total_ingredients: dict[int, float] = defaultdict(float)
|
||||
for item in items:
|
||||
recipe = item.recipe
|
||||
if not recipe:
|
||||
continue
|
||||
if recipe.recipe_type == "component":
|
||||
multiplier = float(item.quantity)
|
||||
else:
|
||||
multiplier = float(item.quantity) / (recipe.batch_portions or 1)
|
||||
ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
|
||||
for ing_id, qty in ings.items():
|
||||
total_ingredients[ing_id] += qty
|
||||
|
||||
if not total_ingredients:
|
||||
raise HTTPException(400, "No ingredients to order")
|
||||
|
||||
# Load ingredients with sources
|
||||
from sqlalchemy.orm import selectinload as si
|
||||
ing_result = await db.execute(
|
||||
select(Ingredient)
|
||||
.options(
|
||||
selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
|
||||
)
|
||||
.where(Ingredient.id.in_(list(total_ingredients.keys())))
|
||||
)
|
||||
ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
|
||||
|
||||
# Group by supplier: {supplier_id: [{ingredient, qty, source}]}
|
||||
supplier_lines: dict[int, list] = defaultdict(list)
|
||||
unmapped = []
|
||||
|
||||
for ing_id, total_qty in total_ingredients.items():
|
||||
ing = ingredients.get(ing_id)
|
||||
if not ing:
|
||||
continue
|
||||
|
||||
# Quantity already yield-adjusted during collection
|
||||
adjusted_qty = total_qty
|
||||
|
||||
# Pick the most recent source (by latest_invoice_date)
|
||||
best_source = None
|
||||
for src in (ing.sources or []):
|
||||
if best_source is None or (src.latest_invoice_date and (
|
||||
not best_source.latest_invoice_date or src.latest_invoice_date > best_source.latest_invoice_date
|
||||
)):
|
||||
best_source = src
|
||||
|
||||
if best_source and best_source.supplier_id:
|
||||
pack_total = None
|
||||
suggested_packs = 1
|
||||
if best_source.pack_quantity and best_source.unit_size and best_source.unit_size_type:
|
||||
pack_in_std = convert_to_standard(
|
||||
Decimal(str(best_source.pack_quantity)) * best_source.unit_size,
|
||||
best_source.unit_size_type,
|
||||
ing.standard_unit,
|
||||
)
|
||||
if pack_in_std and float(pack_in_std) > 0:
|
||||
pack_total = float(pack_in_std)
|
||||
suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
|
||||
|
||||
supplier_lines[best_source.supplier_id].append({
|
||||
"ingredient": ing,
|
||||
"source": best_source,
|
||||
"quantity": max(suggested_packs, 1),
|
||||
"unit_price": float(best_source.latest_unit_price) if best_source.latest_unit_price else 0,
|
||||
})
|
||||
else:
|
||||
unmapped.append(ing.name)
|
||||
|
||||
# Create one PO per supplier
|
||||
created_pos = []
|
||||
for supplier_id, lines in supplier_lines.items():
|
||||
total = sum(l["quantity"] * l["unit_price"] for l in lines)
|
||||
po = PurchaseOrder(
|
||||
kitchen_id=user.kitchen_id,
|
||||
supplier_id=supplier_id,
|
||||
order_date=order.event_date or date.today(),
|
||||
order_type="itemised",
|
||||
status="DRAFT",
|
||||
total_amount=Decimal(str(round(total, 2))),
|
||||
notes=f"Auto-generated from event order: {order.name}",
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(po)
|
||||
await db.flush()
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
line_total = round(line["quantity"] * line["unit_price"], 2)
|
||||
po_line = PurchaseOrderLineItem(
|
||||
purchase_order_id=po.id,
|
||||
kitchen_id=user.kitchen_id,
|
||||
product_code=line["source"].product_code,
|
||||
description=line["ingredient"].name,
|
||||
unit=line["ingredient"].standard_unit,
|
||||
unit_price=Decimal(str(line["unit_price"])),
|
||||
quantity=Decimal(str(line["quantity"])),
|
||||
total=Decimal(str(line_total)),
|
||||
line_number=idx + 1,
|
||||
source="event_order",
|
||||
)
|
||||
db.add(po_line)
|
||||
|
||||
created_pos.append({"id": po.id, "supplier_id": supplier_id})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"created": len(created_pos),
|
||||
"purchase_orders": created_pos,
|
||||
"unmapped_ingredients": unmapped,
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_order(order_id: int, kitchen_id: int, db: AsyncSession) -> EventOrder:
|
||||
result = await db.execute(
|
||||
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
return order
|
||||
497
backend/api/external.py
Normal file
497
backend/api/external.py
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
"""
|
||||
Internal API for in-house apps — API key authentication, dish recipe data,
|
||||
food flag listings, menus. Prefix: /api/external/
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_db
|
||||
from models.settings import KitchenSettings
|
||||
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeImage
|
||||
from models.menu import Menu, MenuDivision, MenuItem
|
||||
from models.ingredient import Ingredient
|
||||
from models.food_flag import FoodFlagCategory, FoodFlag
|
||||
from api.food_flags import compute_recipe_flags
|
||||
from api.menus import _compute_staleness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def get_kitchen_from_api_key(
|
||||
x_api_key: str = Header(..., alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KitchenSettings:
|
||||
"""Authenticate via API key and return the kitchen settings."""
|
||||
if not x_api_key:
|
||||
raise HTTPException(401, "Missing X-API-Key header")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.api_key == x_api_key,
|
||||
KitchenSettings.api_key_enabled == True,
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
if not settings:
|
||||
raise HTTPException(401, "Invalid or disabled API key")
|
||||
return settings
|
||||
|
||||
|
||||
@router.get("/recipes/dishes")
|
||||
async def list_dish_recipes(
|
||||
include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
|
||||
include_costs: bool = Query(False),
|
||||
exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List non-archived dish recipes for external consumption."""
|
||||
query = (
|
||||
select(Recipe)
|
||||
.options(
|
||||
selectinload(Recipe.menu_section),
|
||||
selectinload(Recipe.images),
|
||||
)
|
||||
.where(
|
||||
Recipe.kitchen_id == kitchen.kitchen_id,
|
||||
Recipe.recipe_type == "dish",
|
||||
Recipe.is_archived == False,
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
recipes = result.scalars().all()
|
||||
|
||||
exclude_flag_ids = set()
|
||||
if exclude_flags:
|
||||
exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
|
||||
|
||||
items = []
|
||||
for r in recipes:
|
||||
# Get flags
|
||||
flags = await compute_recipe_flags(r.id, kitchen.kitchen_id, db)
|
||||
active_flags = [f for f in flags if f.is_active]
|
||||
|
||||
# Check exclude filter
|
||||
if exclude_flag_ids:
|
||||
recipe_flag_ids = {f.food_flag_id for f in active_flags}
|
||||
if recipe_flag_ids & exclude_flag_ids:
|
||||
continue
|
||||
|
||||
flag_data = [
|
||||
{
|
||||
"id": f.food_flag_id,
|
||||
"name": f.flag_name,
|
||||
"code": f.flag_code,
|
||||
"icon": f.flag_icon,
|
||||
"category": f.category_name,
|
||||
"propagation": f.propagation_type,
|
||||
"excludable": f.excludable_on_request,
|
||||
}
|
||||
for f in active_flags
|
||||
]
|
||||
|
||||
item = {
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"menu_section": r.menu_section.name if r.menu_section else None,
|
||||
"prep_time_minutes": r.prep_time_minutes,
|
||||
"cook_time_minutes": r.cook_time_minutes,
|
||||
"flags": flag_data,
|
||||
"images": [
|
||||
{"id": img.id, "caption": img.caption, "image_type": img.image_type}
|
||||
for img in (r.images or [])
|
||||
],
|
||||
}
|
||||
|
||||
# Include costs if requested
|
||||
if include_costs:
|
||||
from api.recipes import _calc_recipe_cost
|
||||
cost_data = await _calc_recipe_cost(r.id, db)
|
||||
item["cost_per_portion"] = cost_data.get("cost_per_portion")
|
||||
item["total_cost"] = cost_data.get("total_cost_recent")
|
||||
|
||||
# Include ingredients if requested
|
||||
if include_ingredients != "none":
|
||||
item["ingredients"] = await _get_recipe_ingredients(r.id, kitchen.kitchen_id, db, include_ingredients)
|
||||
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/recipes/plated")
|
||||
async def list_plated_recipes_compat(
|
||||
include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
|
||||
include_costs: bool = Query(False),
|
||||
exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Backward-compatible alias for /recipes/dishes."""
|
||||
return await list_dish_recipes(include_ingredients, include_costs, exclude_flags, kitchen, db)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}")
|
||||
async def get_dish_recipe(
|
||||
recipe_id: int,
|
||||
include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
|
||||
include_costs: bool = Query(False),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single dish recipe for external consumption."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.options(selectinload(Recipe.menu_section), selectinload(Recipe.images))
|
||||
.where(
|
||||
Recipe.id == recipe_id,
|
||||
Recipe.kitchen_id == kitchen.kitchen_id,
|
||||
Recipe.recipe_type == "dish",
|
||||
Recipe.is_archived == False,
|
||||
)
|
||||
)
|
||||
recipe = result.scalar_one_or_none()
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
flags = await compute_recipe_flags(recipe.id, kitchen.kitchen_id, db)
|
||||
active_flags = [f for f in flags if f.is_active]
|
||||
|
||||
item = {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"description": recipe.description,
|
||||
"menu_section": recipe.menu_section.name if recipe.menu_section else None,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"flags": [
|
||||
{
|
||||
"id": f.food_flag_id,
|
||||
"name": f.flag_name,
|
||||
"code": f.flag_code,
|
||||
"icon": f.flag_icon,
|
||||
"category": f.category_name,
|
||||
"propagation": f.propagation_type,
|
||||
"excludable": f.excludable_on_request,
|
||||
}
|
||||
for f in active_flags
|
||||
],
|
||||
"images": [
|
||||
{"id": img.id, "caption": img.caption, "image_type": img.image_type}
|
||||
for img in (recipe.images or [])
|
||||
],
|
||||
}
|
||||
|
||||
if include_costs:
|
||||
from api.recipes import _calc_recipe_cost
|
||||
cost_data = await _calc_recipe_cost(recipe.id, db)
|
||||
item["cost_per_portion"] = cost_data.get("cost_per_portion")
|
||||
item["total_cost"] = cost_data.get("total_cost_recent")
|
||||
|
||||
if include_ingredients != "none":
|
||||
item["ingredients"] = await _get_recipe_ingredients(recipe.id, kitchen.kitchen_id, db, include_ingredients)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/food-flags")
|
||||
async def list_food_flags(
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all flag categories and flags for external apps."""
|
||||
result = await db.execute(
|
||||
select(FoodFlagCategory)
|
||||
.options(selectinload(FoodFlagCategory.flags))
|
||||
.where(FoodFlagCategory.kitchen_id == kitchen.kitchen_id)
|
||||
.order_by(FoodFlagCategory.sort_order)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": cat.id,
|
||||
"name": cat.name,
|
||||
"propagation_type": cat.propagation_type,
|
||||
"flags": [
|
||||
{"id": f.id, "name": f.name, "code": f.code, "icon": f.icon}
|
||||
for f in sorted(cat.flags, key=lambda x: x.sort_order)
|
||||
],
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
|
||||
|
||||
async def _get_recipe_ingredients(recipe_id: int, kitchen_id: int, db: AsyncSession, mode: str) -> list:
|
||||
"""Get ingredient list for external API — flat (consolidated) or nested (sub-recipe breakdown)."""
|
||||
if mode == "flat":
|
||||
# Consolidated list
|
||||
from api.event_orders import _collect_ingredients_for_recipe
|
||||
ing_qtys = await _collect_ingredients_for_recipe(recipe_id, 1.0, db)
|
||||
if not ing_qtys:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Ingredient).where(Ingredient.id.in_(list(ing_qtys.keys())))
|
||||
)
|
||||
ingredients = {ing.id: ing for ing in result.scalars().all()}
|
||||
|
||||
return [
|
||||
{
|
||||
"ingredient_id": ing_id,
|
||||
"name": ingredients[ing_id].name if ing_id in ingredients else "?",
|
||||
"quantity": round(qty, 3),
|
||||
"unit": ingredients[ing_id].standard_unit if ing_id in ingredients else "",
|
||||
}
|
||||
for ing_id, qty in sorted(ing_qtys.items())
|
||||
]
|
||||
|
||||
elif mode == "nested":
|
||||
# Show sub-recipe breakdown
|
||||
ri_result = await db.execute(
|
||||
select(RecipeIngredient)
|
||||
.options(selectinload(RecipeIngredient.ingredient))
|
||||
.where(RecipeIngredient.recipe_id == recipe_id)
|
||||
.order_by(RecipeIngredient.sort_order)
|
||||
)
|
||||
direct = [
|
||||
{
|
||||
"ingredient_id": ri.ingredient_id,
|
||||
"name": ri.ingredient.name if ri.ingredient else "?",
|
||||
"quantity": float(ri.quantity),
|
||||
"unit": ri.ingredient.standard_unit if ri.ingredient else "",
|
||||
"source": "direct",
|
||||
}
|
||||
for ri in ri_result.scalars().all()
|
||||
]
|
||||
|
||||
sr_result = await db.execute(
|
||||
select(RecipeSubRecipe)
|
||||
.options(selectinload(RecipeSubRecipe.child_recipe))
|
||||
.where(RecipeSubRecipe.parent_recipe_id == recipe_id)
|
||||
)
|
||||
sub_recipe_ings = []
|
||||
for sr in sr_result.scalars().all():
|
||||
child = sr.child_recipe
|
||||
if not child:
|
||||
continue
|
||||
cri_result = await db.execute(
|
||||
select(RecipeIngredient)
|
||||
.options(selectinload(RecipeIngredient.ingredient))
|
||||
.where(RecipeIngredient.recipe_id == child.id)
|
||||
)
|
||||
# Use unified output qty for bulk/portioned child recipes
|
||||
child_output_qty = float(child.batch_yield_qty) if child.batch_output_type == "bulk" and child.batch_yield_qty else (child.batch_portions or 1)
|
||||
child_output_unit = child.batch_yield_unit if child.batch_output_type == "bulk" and child.batch_yield_unit else "portion"
|
||||
# Convert portions_needed to child output unit if different unit was used
|
||||
needed = float(sr.portions_needed)
|
||||
needed_unit = sr.portions_needed_unit or child_output_unit
|
||||
if needed_unit != child_output_unit:
|
||||
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
|
||||
if needed_unit in _bases and child_output_unit in _bases:
|
||||
needed = needed * _bases[needed_unit] / _bases[child_output_unit]
|
||||
scale = needed / child_output_qty
|
||||
for cri in cri_result.scalars().all():
|
||||
sub_recipe_ings.append({
|
||||
"ingredient_id": cri.ingredient_id,
|
||||
"name": cri.ingredient.name if cri.ingredient else "?",
|
||||
"quantity": round(float(cri.quantity) * scale, 3),
|
||||
"unit": cri.ingredient.standard_unit if cri.ingredient else "",
|
||||
"source": f"sub-recipe: {child.name}",
|
||||
})
|
||||
|
||||
return direct + sub_recipe_ings
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ── Menu Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/menus")
|
||||
async def list_menus_external(
|
||||
exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude items containing those allergens"),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List active menus with divisions and items served from snapshots."""
|
||||
result = await db.execute(
|
||||
select(Menu)
|
||||
.options(
|
||||
selectinload(Menu.divisions).selectinload(MenuDivision.items),
|
||||
selectinload(Menu.items),
|
||||
)
|
||||
.where(Menu.kitchen_id == kitchen.kitchen_id, Menu.is_active == True)
|
||||
.order_by(Menu.sort_order)
|
||||
)
|
||||
menus = result.scalars().all()
|
||||
|
||||
exclude_flag_ids = set()
|
||||
if exclude_flags:
|
||||
exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
|
||||
|
||||
menus_data = []
|
||||
for menu in menus:
|
||||
all_items = [i for i in (menu.items or []) if i.recipe_id is not None]
|
||||
staleness = await _compute_staleness(all_items, db)
|
||||
|
||||
divisions_data = []
|
||||
for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
|
||||
div_items = sorted(
|
||||
[i for i in all_items if i.division_id == div.id],
|
||||
key=lambda i: i.sort_order,
|
||||
)
|
||||
items_data = []
|
||||
for item in div_items:
|
||||
snapshot = item.snapshot_json or {}
|
||||
confirmed_flags = snapshot.get("confirmed_flags", [])
|
||||
|
||||
# Apply exclude filter
|
||||
if exclude_flag_ids:
|
||||
item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")}
|
||||
if item_flag_ids & exclude_flag_ids:
|
||||
continue
|
||||
|
||||
stale_info = staleness.get(item.id, {"is_stale": False})
|
||||
|
||||
items_data.append({
|
||||
"id": item.id,
|
||||
"display_name": snapshot.get("display_name", item.display_name),
|
||||
"description": snapshot.get("description", item.description),
|
||||
"price": snapshot.get("price", str(item.price) if item.price else None),
|
||||
"flags": [
|
||||
{"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"),
|
||||
"category": f.get("category"), "excludable": f.get("excludable", False)}
|
||||
for f in confirmed_flags
|
||||
],
|
||||
"is_stale": stale_info.get("is_stale", False),
|
||||
"has_image": bool(item.image_path),
|
||||
})
|
||||
|
||||
if items_data:
|
||||
divisions_data.append({
|
||||
"name": div.name,
|
||||
"items": items_data,
|
||||
})
|
||||
|
||||
menus_data.append({
|
||||
"id": menu.id,
|
||||
"name": menu.name,
|
||||
"description": menu.description,
|
||||
"divisions": divisions_data,
|
||||
})
|
||||
|
||||
return menus_data
|
||||
|
||||
|
||||
@router.get("/menus/{menu_id}")
|
||||
async def get_menu_external(
|
||||
menu_id: int,
|
||||
exclude_flags: Optional[str] = Query(None),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Single menu detail for external consumption."""
|
||||
result = await db.execute(
|
||||
select(Menu)
|
||||
.options(
|
||||
selectinload(Menu.divisions).selectinload(MenuDivision.items),
|
||||
selectinload(Menu.items),
|
||||
)
|
||||
.where(
|
||||
Menu.id == menu_id,
|
||||
Menu.kitchen_id == kitchen.kitchen_id,
|
||||
Menu.is_active == True,
|
||||
)
|
||||
)
|
||||
menu = result.scalar_one_or_none()
|
||||
if not menu:
|
||||
raise HTTPException(404, "Menu not found")
|
||||
|
||||
exclude_flag_ids = set()
|
||||
if exclude_flags:
|
||||
exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
|
||||
|
||||
all_items = [i for i in (menu.items or []) if i.recipe_id is not None]
|
||||
staleness = await _compute_staleness(all_items, db)
|
||||
|
||||
divisions_data = []
|
||||
for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
|
||||
div_items = sorted(
|
||||
[i for i in all_items if i.division_id == div.id],
|
||||
key=lambda i: i.sort_order,
|
||||
)
|
||||
items_data = []
|
||||
for item in div_items:
|
||||
snapshot = item.snapshot_json or {}
|
||||
confirmed_flags = snapshot.get("confirmed_flags", [])
|
||||
|
||||
if exclude_flag_ids:
|
||||
item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")}
|
||||
if item_flag_ids & exclude_flag_ids:
|
||||
continue
|
||||
|
||||
stale_info = staleness.get(item.id, {"is_stale": False})
|
||||
|
||||
items_data.append({
|
||||
"id": item.id,
|
||||
"display_name": snapshot.get("display_name", item.display_name),
|
||||
"description": snapshot.get("description", item.description),
|
||||
"price": snapshot.get("price", str(item.price) if item.price else None),
|
||||
"flags": [
|
||||
{"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"),
|
||||
"category": f.get("category"), "excludable": f.get("excludable", False)}
|
||||
for f in confirmed_flags
|
||||
],
|
||||
"is_stale": stale_info.get("is_stale", False),
|
||||
"has_image": bool(item.image_path),
|
||||
})
|
||||
|
||||
if items_data:
|
||||
divisions_data.append({
|
||||
"name": div.name,
|
||||
"items": items_data,
|
||||
})
|
||||
|
||||
return {
|
||||
"id": menu.id,
|
||||
"name": menu.name,
|
||||
"description": menu.description,
|
||||
"divisions": divisions_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/menus/{menu_id}/items/{item_id}/image")
|
||||
async def serve_menu_item_image_external(
|
||||
menu_id: int,
|
||||
item_id: int,
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Serve a menu item image via API key authentication."""
|
||||
result = await db.execute(
|
||||
select(MenuItem)
|
||||
.join(Menu, MenuItem.menu_id == Menu.id)
|
||||
.where(
|
||||
MenuItem.id == item_id,
|
||||
MenuItem.menu_id == menu_id,
|
||||
Menu.kitchen_id == kitchen.kitchen_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item or not item.image_path:
|
||||
raise HTTPException(404, "Image not found")
|
||||
|
||||
if not os.path.exists(item.image_path):
|
||||
raise HTTPException(404, "Image file not found")
|
||||
|
||||
return FileResponse(item.image_path)
|
||||
205
backend/api/field_mappings.py
Normal file
205
backend/api/field_mappings.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.field_mapping import (
|
||||
FieldMapping,
|
||||
AZURE_INVOICE_FIELDS,
|
||||
AZURE_LINE_ITEM_FIELDS,
|
||||
TARGET_INVOICE_FIELDS,
|
||||
TARGET_LINE_ITEM_FIELDS
|
||||
)
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FieldMappingResponse(BaseModel):
|
||||
id: int
|
||||
supplier_id: int | None
|
||||
source_field: str
|
||||
target_field: str
|
||||
field_type: str
|
||||
transform: str
|
||||
priority: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FieldMappingCreate(BaseModel):
|
||||
supplier_id: Optional[int] = None
|
||||
source_field: str
|
||||
target_field: str
|
||||
field_type: str = "invoice"
|
||||
transform: str = "direct"
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class FieldMappingUpdate(BaseModel):
|
||||
source_field: Optional[str] = None
|
||||
target_field: Optional[str] = None
|
||||
field_type: Optional[str] = None
|
||||
transform: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
|
||||
|
||||
class FieldOptionsResponse(BaseModel):
|
||||
azure_invoice_fields: list[str]
|
||||
azure_line_item_fields: list[str]
|
||||
target_invoice_fields: list[str]
|
||||
target_line_item_fields: list[str]
|
||||
|
||||
|
||||
@router.get("/options", response_model=FieldOptionsResponse)
|
||||
async def get_field_options():
|
||||
"""Get available field names for creating mappings"""
|
||||
return FieldOptionsResponse(
|
||||
azure_invoice_fields=AZURE_INVOICE_FIELDS,
|
||||
azure_line_item_fields=AZURE_LINE_ITEM_FIELDS,
|
||||
target_invoice_fields=TARGET_INVOICE_FIELDS,
|
||||
target_line_item_fields=TARGET_LINE_ITEM_FIELDS
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FieldMappingResponse])
|
||||
async def list_field_mappings(
|
||||
supplier_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List field mappings for the current kitchen, optionally filtered by supplier"""
|
||||
query = select(FieldMapping).where(
|
||||
FieldMapping.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
|
||||
if supplier_id is not None:
|
||||
# Get mappings for specific supplier OR global (supplier_id=null)
|
||||
query = query.where(
|
||||
(FieldMapping.supplier_id == supplier_id) |
|
||||
(FieldMapping.supplier_id.is_(None))
|
||||
)
|
||||
|
||||
query = query.order_by(FieldMapping.priority.desc(), FieldMapping.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
mappings = result.scalars().all()
|
||||
|
||||
return [
|
||||
FieldMappingResponse(
|
||||
id=m.id,
|
||||
supplier_id=m.supplier_id,
|
||||
source_field=m.source_field,
|
||||
target_field=m.target_field,
|
||||
field_type=m.field_type,
|
||||
transform=m.transform,
|
||||
priority=m.priority
|
||||
)
|
||||
for m in mappings
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=FieldMappingResponse)
|
||||
async def create_field_mapping(
|
||||
mapping: FieldMappingCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new field mapping"""
|
||||
# Validate target field
|
||||
valid_targets = (
|
||||
TARGET_INVOICE_FIELDS if mapping.field_type == "invoice"
|
||||
else TARGET_LINE_ITEM_FIELDS
|
||||
)
|
||||
if mapping.target_field not in valid_targets:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid target field. Valid options: {valid_targets}"
|
||||
)
|
||||
|
||||
new_mapping = FieldMapping(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
supplier_id=mapping.supplier_id,
|
||||
source_field=mapping.source_field,
|
||||
target_field=mapping.target_field,
|
||||
field_type=mapping.field_type,
|
||||
transform=mapping.transform,
|
||||
priority=mapping.priority
|
||||
)
|
||||
|
||||
db.add(new_mapping)
|
||||
await db.commit()
|
||||
await db.refresh(new_mapping)
|
||||
|
||||
return FieldMappingResponse(
|
||||
id=new_mapping.id,
|
||||
supplier_id=new_mapping.supplier_id,
|
||||
source_field=new_mapping.source_field,
|
||||
target_field=new_mapping.target_field,
|
||||
field_type=new_mapping.field_type,
|
||||
transform=new_mapping.transform,
|
||||
priority=new_mapping.priority
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{mapping_id}", response_model=FieldMappingResponse)
|
||||
async def update_field_mapping(
|
||||
mapping_id: int,
|
||||
update: FieldMappingUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a field mapping"""
|
||||
result = await db.execute(
|
||||
select(FieldMapping).where(
|
||||
FieldMapping.id == mapping_id,
|
||||
FieldMapping.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Field mapping not found")
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(mapping, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mapping)
|
||||
|
||||
return FieldMappingResponse(
|
||||
id=mapping.id,
|
||||
supplier_id=mapping.supplier_id,
|
||||
source_field=mapping.source_field,
|
||||
target_field=mapping.target_field,
|
||||
field_type=mapping.field_type,
|
||||
transform=mapping.transform,
|
||||
priority=mapping.priority
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{mapping_id}")
|
||||
async def delete_field_mapping(
|
||||
mapping_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a field mapping"""
|
||||
result = await db.execute(
|
||||
select(FieldMapping).where(
|
||||
FieldMapping.id == mapping_id,
|
||||
FieldMapping.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Field mapping not found")
|
||||
|
||||
await db.delete(mapping)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Field mapping deleted"}
|
||||
2309
backend/api/food_flags.py
Normal file
2309
backend/api/food_flags.py
Normal file
File diff suppressed because it is too large
Load diff
341
backend/api/imap.py
Normal file
341
backend/api/imap.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""
|
||||
IMAP Email Inbox API endpoints for settings and sync control.
|
||||
"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from models.email_processing import EmailProcessingLog
|
||||
from services.imap_sync import ImapSyncService
|
||||
|
||||
router = APIRouter(prefix="/imap", tags=["IMAP"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class ImapSettingsResponse(BaseModel):
|
||||
imap_host: Optional[str]
|
||||
imap_port: Optional[int]
|
||||
imap_use_ssl: bool
|
||||
imap_username: Optional[str]
|
||||
imap_password_set: bool # Don't expose actual password
|
||||
imap_folder: Optional[str]
|
||||
imap_poll_interval: int
|
||||
imap_enabled: bool
|
||||
imap_confidence_threshold: Optional[float]
|
||||
imap_last_sync: Optional[str]
|
||||
|
||||
|
||||
class ImapSettingsUpdate(BaseModel):
|
||||
imap_host: Optional[str] = None
|
||||
imap_port: Optional[int] = None
|
||||
imap_use_ssl: Optional[bool] = None
|
||||
imap_username: Optional[str] = None
|
||||
imap_password: Optional[str] = None # Only set if provided
|
||||
imap_folder: Optional[str] = None
|
||||
imap_poll_interval: Optional[int] = None
|
||||
imap_enabled: Optional[bool] = None
|
||||
imap_confidence_threshold: Optional[float] = None
|
||||
|
||||
|
||||
class ImapTestRequest(BaseModel):
|
||||
imap_host: Optional[str] = None
|
||||
imap_port: Optional[int] = None
|
||||
imap_use_ssl: Optional[bool] = None
|
||||
imap_username: Optional[str] = None
|
||||
imap_password: Optional[str] = None
|
||||
|
||||
|
||||
class EmailLogResponse(BaseModel):
|
||||
id: int
|
||||
message_id: str
|
||||
email_subject: Optional[str]
|
||||
email_from: Optional[str]
|
||||
email_date: Optional[str]
|
||||
attachments_count: int
|
||||
invoices_created: int
|
||||
confident_invoices: int
|
||||
marked_as_read: bool
|
||||
processing_status: str
|
||||
error_message: Optional[str]
|
||||
invoice_ids: Optional[list[int]]
|
||||
processed_at: str
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings", response_model=ImapSettingsResponse)
|
||||
async def get_imap_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get IMAP settings (password masked)"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
return ImapSettingsResponse(
|
||||
imap_host=settings.imap_host,
|
||||
imap_port=settings.imap_port,
|
||||
imap_use_ssl=settings.imap_use_ssl,
|
||||
imap_username=settings.imap_username,
|
||||
imap_password_set=bool(settings.imap_password),
|
||||
imap_folder=settings.imap_folder,
|
||||
imap_poll_interval=settings.imap_poll_interval,
|
||||
imap_enabled=settings.imap_enabled,
|
||||
imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None,
|
||||
imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=ImapSettingsResponse)
|
||||
async def update_imap_settings(
|
||||
update: ImapSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update IMAP settings (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Update only provided fields
|
||||
if update.imap_host is not None:
|
||||
settings.imap_host = update.imap_host
|
||||
if update.imap_port is not None:
|
||||
settings.imap_port = update.imap_port
|
||||
if update.imap_use_ssl is not None:
|
||||
settings.imap_use_ssl = update.imap_use_ssl
|
||||
if update.imap_username is not None:
|
||||
settings.imap_username = update.imap_username
|
||||
if update.imap_password is not None and update.imap_password:
|
||||
settings.imap_password = update.imap_password
|
||||
if update.imap_folder is not None:
|
||||
settings.imap_folder = update.imap_folder
|
||||
if update.imap_poll_interval is not None:
|
||||
settings.imap_poll_interval = update.imap_poll_interval
|
||||
if update.imap_enabled is not None:
|
||||
settings.imap_enabled = update.imap_enabled
|
||||
if update.imap_confidence_threshold is not None:
|
||||
settings.imap_confidence_threshold = Decimal(str(update.imap_confidence_threshold))
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ImapSettingsResponse(
|
||||
imap_host=settings.imap_host,
|
||||
imap_port=settings.imap_port,
|
||||
imap_use_ssl=settings.imap_use_ssl,
|
||||
imap_username=settings.imap_username,
|
||||
imap_password_set=bool(settings.imap_password),
|
||||
imap_folder=settings.imap_folder,
|
||||
imap_poll_interval=settings.imap_poll_interval,
|
||||
imap_enabled=settings.imap_enabled,
|
||||
imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None,
|
||||
imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_imap_connection(
|
||||
request: ImapTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test IMAP connection with provided or saved settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Get current settings
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Use provided values or fall back to saved settings
|
||||
test_settings = KitchenSettings(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
imap_host=request.imap_host or settings.imap_host,
|
||||
imap_port=request.imap_port or settings.imap_port or 993,
|
||||
imap_use_ssl=request.imap_use_ssl if request.imap_use_ssl is not None else settings.imap_use_ssl,
|
||||
imap_username=request.imap_username or settings.imap_username,
|
||||
imap_password=request.imap_password or settings.imap_password
|
||||
)
|
||||
|
||||
if not test_settings.imap_host or not test_settings.imap_password:
|
||||
return {"success": False, "error": "IMAP host and password are required"}
|
||||
|
||||
# Test connection
|
||||
sync_service = ImapSyncService(current_user.kitchen_id, db)
|
||||
sync_service._settings = test_settings
|
||||
result = await sync_service.test_connection()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync-now")
|
||||
async def trigger_manual_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manually trigger inbox sync (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Get settings
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not settings.imap_host or not settings.imap_password:
|
||||
raise HTTPException(status_code=400, detail="IMAP settings not configured")
|
||||
|
||||
# Run sync
|
||||
try:
|
||||
sync_service = ImapSyncService(current_user.kitchen_id, db)
|
||||
results = await sync_service.process_inbox()
|
||||
return {
|
||||
"success": True,
|
||||
"results": results
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Manual IMAP sync failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# ============ Log Endpoints ============
|
||||
|
||||
@router.get("/logs", response_model=list[EmailLogResponse])
|
||||
async def get_processing_logs(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get recent email processing logs"""
|
||||
result = await db.execute(
|
||||
select(EmailProcessingLog)
|
||||
.where(EmailProcessingLog.kitchen_id == current_user.kitchen_id)
|
||||
.order_by(desc(EmailProcessingLog.processed_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
EmailLogResponse(
|
||||
id=log.id,
|
||||
message_id=log.message_id,
|
||||
email_subject=log.email_subject,
|
||||
email_from=log.email_from,
|
||||
email_date=log.email_date.isoformat() if log.email_date else None,
|
||||
attachments_count=log.attachments_count,
|
||||
invoices_created=log.invoices_created,
|
||||
confident_invoices=log.confident_invoices,
|
||||
marked_as_read=log.marked_as_read,
|
||||
processing_status=log.processing_status,
|
||||
error_message=log.error_message,
|
||||
invoice_ids=log.invoice_ids,
|
||||
processed_at=log.processed_at.isoformat()
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/logs/stats")
|
||||
async def get_sync_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get sync statistics"""
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Last 24 hours
|
||||
result_24h = await db.execute(
|
||||
select(
|
||||
func.count(EmailProcessingLog.id),
|
||||
func.sum(EmailProcessingLog.invoices_created),
|
||||
func.sum(EmailProcessingLog.confident_invoices)
|
||||
).where(
|
||||
EmailProcessingLog.kitchen_id == current_user.kitchen_id,
|
||||
EmailProcessingLog.processed_at >= now - timedelta(hours=24)
|
||||
)
|
||||
)
|
||||
stats_24h = result_24h.one()
|
||||
|
||||
# Last 7 days
|
||||
result_7d = await db.execute(
|
||||
select(
|
||||
func.count(EmailProcessingLog.id),
|
||||
func.sum(EmailProcessingLog.invoices_created),
|
||||
func.sum(EmailProcessingLog.confident_invoices)
|
||||
).where(
|
||||
EmailProcessingLog.kitchen_id == current_user.kitchen_id,
|
||||
EmailProcessingLog.processed_at >= now - timedelta(days=7)
|
||||
)
|
||||
)
|
||||
stats_7d = result_7d.one()
|
||||
|
||||
# Last 30 days
|
||||
result_30d = await db.execute(
|
||||
select(
|
||||
func.count(EmailProcessingLog.id),
|
||||
func.sum(EmailProcessingLog.invoices_created),
|
||||
func.sum(EmailProcessingLog.confident_invoices)
|
||||
).where(
|
||||
EmailProcessingLog.kitchen_id == current_user.kitchen_id,
|
||||
EmailProcessingLog.processed_at >= now - timedelta(days=30)
|
||||
)
|
||||
)
|
||||
stats_30d = result_30d.one()
|
||||
|
||||
return {
|
||||
"last_24h": {
|
||||
"emails_processed": stats_24h[0] or 0,
|
||||
"invoices_created": int(stats_24h[1] or 0),
|
||||
"confident_invoices": int(stats_24h[2] or 0)
|
||||
},
|
||||
"last_7d": {
|
||||
"emails_processed": stats_7d[0] or 0,
|
||||
"invoices_created": int(stats_7d[1] or 0),
|
||||
"confident_invoices": int(stats_7d[2] or 0)
|
||||
},
|
||||
"last_30d": {
|
||||
"emails_processed": stats_30d[0] or 0,
|
||||
"invoices_created": int(stats_30d[1] or 0),
|
||||
"confident_invoices": int(stats_30d[2] or 0)
|
||||
}
|
||||
}
|
||||
2013
backend/api/ingredients.py
Normal file
2013
backend/api/ingredients.py
Normal file
File diff suppressed because it is too large
Load diff
64
backend/api/internal.py
Normal file
64
backend/api/internal.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Internal API — endpoints consumed by other stack apps (not public).
|
||||
nginx denies /kitchen/api/internal/ from the public side; these endpoints
|
||||
are called directly on the backend port (8000) from within the docker bridge.
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from database import get_db
|
||||
from auth import verify_internal_secret
|
||||
from models.resos import ResosBooking
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/internal/resos/bookings")
|
||||
async def internal_resos_bookings(
|
||||
booking_date: Optional[str] = Query(None, alias="date"),
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cached ResOS bookings for a given date — consumed by the KDS app bookings screen.
|
||||
KDS calls: GET http://10.10.10.110:8000/api/internal/resos/bookings?date=YYYY-MM-DD
|
||||
Authorization: Bearer {STACK_INTERNAL_SECRET}
|
||||
"""
|
||||
try:
|
||||
target_date = date.fromisoformat(booking_date) if booking_date else date.today()
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD")
|
||||
|
||||
result = await db.execute(
|
||||
select(ResosBooking)
|
||||
.where(
|
||||
ResosBooking.kitchen_id == 1,
|
||||
ResosBooking.booking_date == target_date,
|
||||
)
|
||||
.order_by(ResosBooking.booking_time)
|
||||
)
|
||||
bookings = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": b.id,
|
||||
"resos_booking_id": b.resos_booking_id,
|
||||
"booking_date": b.booking_date.isoformat(),
|
||||
"booking_time": b.booking_time.strftime("%H:%M"),
|
||||
"people": b.people,
|
||||
"status": b.status,
|
||||
"seating_area": b.seating_area,
|
||||
"table_name": b.table_name,
|
||||
"hotel_booking_number": b.hotel_booking_number,
|
||||
"is_hotel_guest": b.is_hotel_guest,
|
||||
"is_dbb": b.is_dbb,
|
||||
"allergies": b.allergies,
|
||||
"notes": b.notes,
|
||||
"opening_hour_name": b.opening_hour_name,
|
||||
}
|
||||
for b in bookings
|
||||
]
|
||||
4077
backend/api/invoices.py
Normal file
4077
backend/api/invoices.py
Normal file
File diff suppressed because it is too large
Load diff
745
backend/api/logbook.py
Normal file
745
backend/api/logbook.py
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
"""
|
||||
Logbook API for wastage, transfers, staff food, and manual adjustments.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
import os
|
||||
import logging
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.logbook import (
|
||||
LogbookEntry, LogbookLineItem, LogbookAttachment,
|
||||
EntryType, WastageReason, TransferStatus
|
||||
)
|
||||
# from models.products import Product # TODO: Add Product model
|
||||
|
||||
router = APIRouter(prefix="/logbook", tags=["Logbook"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class LineItemInput(BaseModel):
|
||||
product_id: Optional[int] = None
|
||||
product_name: str
|
||||
product_code: Optional[str] = None
|
||||
supplier_name: Optional[str] = None
|
||||
quantity: float
|
||||
unit: Optional[str] = None
|
||||
unit_price: Optional[float] = None
|
||||
total_cost: float
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WastageEntryInput(BaseModel):
|
||||
entry_date: date
|
||||
reason: WastageReason
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
reference_number: Optional[str] = None
|
||||
|
||||
|
||||
class TransferEntryInput(BaseModel):
|
||||
entry_date: date
|
||||
destination_kitchen_id: int
|
||||
status: TransferStatus = TransferStatus.PENDING
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
reference_number: Optional[str] = None
|
||||
|
||||
|
||||
class StaffFoodEntryInput(BaseModel):
|
||||
entry_date: date
|
||||
meal_type: str # breakfast, lunch, dinner, snack
|
||||
staff_count: Optional[int] = None
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ManualAdjustmentInput(BaseModel):
|
||||
entry_date: date
|
||||
adjustment_reason: str
|
||||
original_invoice_id: Optional[int] = None
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
reference_number: Optional[str] = None
|
||||
|
||||
|
||||
class LineItemResponse(BaseModel):
|
||||
id: int
|
||||
product_id: Optional[int]
|
||||
product_name: str
|
||||
product_code: Optional[str]
|
||||
supplier_name: Optional[str]
|
||||
quantity: float
|
||||
unit: Optional[str]
|
||||
unit_price: Optional[float]
|
||||
total_cost: float
|
||||
notes: Optional[str]
|
||||
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
id: int
|
||||
file_name: str
|
||||
file_path: str
|
||||
file_type: str
|
||||
file_size_bytes: int
|
||||
description: Optional[str]
|
||||
uploaded_at: str
|
||||
|
||||
|
||||
class LogbookEntryResponse(BaseModel):
|
||||
id: int
|
||||
entry_type: str
|
||||
entry_date: str
|
||||
reference_number: Optional[str]
|
||||
total_cost: float
|
||||
notes: Optional[str]
|
||||
type_data: dict
|
||||
created_by: int
|
||||
created_by_name: Optional[str]
|
||||
created_at: str
|
||||
line_items: List[LineItemResponse]
|
||||
attachments: List[AttachmentResponse]
|
||||
|
||||
|
||||
class LogbookSummary(BaseModel):
|
||||
total_entries: int
|
||||
total_cost: float
|
||||
by_type: dict
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def build_entry_response(entry: LogbookEntry) -> LogbookEntryResponse:
|
||||
"""Convert LogbookEntry model to response"""
|
||||
return LogbookEntryResponse(
|
||||
id=entry.id,
|
||||
entry_type=entry.entry_type.value,
|
||||
entry_date=entry.entry_date.isoformat(),
|
||||
reference_number=entry.reference_number,
|
||||
total_cost=float(entry.total_cost),
|
||||
notes=entry.notes,
|
||||
type_data=entry.type_data or {},
|
||||
created_by=entry.created_by,
|
||||
created_by_name=entry.created_by_user.name if entry.created_by_user else None,
|
||||
created_at=entry.created_at.isoformat(),
|
||||
line_items=[
|
||||
LineItemResponse(
|
||||
id=item.id,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product_name,
|
||||
product_code=item.product_code,
|
||||
supplier_name=item.supplier_name,
|
||||
quantity=float(item.quantity),
|
||||
unit=item.unit,
|
||||
unit_price=float(item.unit_price) if item.unit_price else None,
|
||||
total_cost=float(item.total_cost),
|
||||
notes=item.notes
|
||||
)
|
||||
for item in entry.line_items
|
||||
],
|
||||
attachments=[
|
||||
AttachmentResponse(
|
||||
id=att.id,
|
||||
file_name=att.file_name,
|
||||
file_path=att.file_path,
|
||||
file_type=att.file_type,
|
||||
file_size_bytes=att.file_size_bytes,
|
||||
description=att.description,
|
||||
uploaded_at=att.uploaded_at.isoformat()
|
||||
)
|
||||
for att in entry.attachments
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def create_line_items(
|
||||
db: AsyncSession,
|
||||
entry_id: int,
|
||||
kitchen_id: int,
|
||||
items: List[LineItemInput]
|
||||
) -> Decimal:
|
||||
"""Create line items for an entry and return total cost"""
|
||||
total_cost = Decimal(0)
|
||||
|
||||
for item_input in items:
|
||||
line_item = LogbookLineItem(
|
||||
entry_id=entry_id,
|
||||
kitchen_id=kitchen_id,
|
||||
product_id=item_input.product_id,
|
||||
product_name=item_input.product_name,
|
||||
product_code=item_input.product_code,
|
||||
supplier_name=item_input.supplier_name,
|
||||
quantity=Decimal(str(item_input.quantity)),
|
||||
unit=item_input.unit,
|
||||
unit_price=Decimal(str(item_input.unit_price)) if item_input.unit_price else None,
|
||||
total_cost=Decimal(str(item_input.total_cost)),
|
||||
notes=item_input.notes
|
||||
)
|
||||
db.add(line_item)
|
||||
total_cost += line_item.total_cost
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
# ============ Endpoints ============
|
||||
|
||||
@router.get("")
|
||||
async def get_logbook_entries(
|
||||
entry_type: Optional[EntryType] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> List[LogbookEntryResponse]:
|
||||
"""Get logbook entries with filters"""
|
||||
|
||||
query = select(LogbookEntry).options(
|
||||
selectinload(LogbookEntry.line_items),
|
||||
selectinload(LogbookEntry.attachments),
|
||||
selectinload(LogbookEntry.created_by_user)
|
||||
).where(
|
||||
and_(
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
|
||||
if entry_type:
|
||||
query = query.where(LogbookEntry.entry_type == entry_type)
|
||||
|
||||
if date_from:
|
||||
query = query.where(LogbookEntry.entry_date >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.where(LogbookEntry.entry_date <= date_to)
|
||||
|
||||
if search:
|
||||
# Search in notes, reference number, and line item product names
|
||||
query = query.outerjoin(LogbookLineItem).where(
|
||||
or_(
|
||||
LogbookEntry.notes.ilike(f"%{search}%"),
|
||||
LogbookEntry.reference_number.ilike(f"%{search}%"),
|
||||
LogbookLineItem.product_name.ilike(f"%{search}%")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
query = query.order_by(LogbookEntry.entry_date.desc(), LogbookEntry.created_at.desc())
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
try:
|
||||
result = await db.execute(query)
|
||||
entries = result.scalars().unique().all()
|
||||
return [build_entry_response(entry) for entry in entries]
|
||||
except Exception as e:
|
||||
logger.exception(f"Error fetching logbook entries: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching entries: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_logbook_summary(
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookSummary:
|
||||
"""Get summary statistics for logbook entries"""
|
||||
|
||||
query = select(
|
||||
LogbookEntry.entry_type,
|
||||
func.count(LogbookEntry.id).label('count'),
|
||||
func.sum(LogbookEntry.total_cost).label('total')
|
||||
).where(
|
||||
and_(
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
).group_by(LogbookEntry.entry_type)
|
||||
|
||||
if date_from:
|
||||
query = query.where(LogbookEntry.entry_date >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.where(LogbookEntry.entry_date <= date_to)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
by_type = {}
|
||||
total_entries = 0
|
||||
total_cost = Decimal(0)
|
||||
|
||||
for row in rows:
|
||||
entry_type, count, cost = row
|
||||
by_type[entry_type.value] = {
|
||||
'count': count,
|
||||
'total_cost': float(cost or 0)
|
||||
}
|
||||
total_entries += count
|
||||
total_cost += cost or 0
|
||||
|
||||
return LogbookSummary(
|
||||
total_entries=total_entries,
|
||||
total_cost=float(total_cost),
|
||||
by_type=by_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily-stats")
|
||||
async def get_daily_logbook_stats(
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
entry_type: Optional[EntryType] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get daily logbook statistics for a date range (for purchases chart integration)"""
|
||||
|
||||
query = select(
|
||||
LogbookEntry.entry_date,
|
||||
LogbookEntry.entry_type,
|
||||
func.count(LogbookEntry.id).label("count"),
|
||||
func.sum(LogbookEntry.total_cost).label("total_cost")
|
||||
).where(
|
||||
and_(
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
|
||||
if date_from:
|
||||
query = query.where(LogbookEntry.entry_date >= date_from)
|
||||
if date_to:
|
||||
query = query.where(LogbookEntry.entry_date <= date_to)
|
||||
if entry_type:
|
||||
query = query.where(LogbookEntry.entry_type == entry_type)
|
||||
|
||||
query = query.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type)
|
||||
query = query.order_by(LogbookEntry.entry_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Group by date, then by type
|
||||
daily_stats = {}
|
||||
for row in rows:
|
||||
date_str = row.entry_date.isoformat()
|
||||
if date_str not in daily_stats:
|
||||
daily_stats[date_str] = {}
|
||||
daily_stats[date_str][row.entry_type.value] = {
|
||||
"count": row.count,
|
||||
"total_cost": float(row.total_cost or 0)
|
||||
}
|
||||
|
||||
return {"daily_stats": daily_stats}
|
||||
|
||||
|
||||
@router.get("/products/search")
|
||||
async def search_products(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Search products from invoice line items for logbook entry"""
|
||||
from models.line_item import LineItem
|
||||
from models.invoice import Invoice
|
||||
from models.supplier import Supplier
|
||||
|
||||
# Search line items from invoices for this kitchen
|
||||
# Get distinct products with most recent price
|
||||
result = await db.execute(
|
||||
select(
|
||||
LineItem.description,
|
||||
LineItem.product_code,
|
||||
LineItem.unit,
|
||||
LineItem.unit_price,
|
||||
Supplier.name.label('supplier_name')
|
||||
)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(
|
||||
and_(
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
or_(
|
||||
LineItem.description.ilike(f"%{query}%"),
|
||||
LineItem.product_code.ilike(f"%{query}%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(Invoice.invoice_date.desc())
|
||||
.limit(limit * 3) # Get more to allow for deduplication
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# Deduplicate by description, keeping first (most recent) price
|
||||
seen = set()
|
||||
products = []
|
||||
for row in rows:
|
||||
key = (row.description or '').lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
products.append({
|
||||
"id": 0, # No persistent product ID
|
||||
"name": row.description,
|
||||
"product_code": row.product_code,
|
||||
"supplier_name": row.supplier_name,
|
||||
"unit": row.unit,
|
||||
"last_price": float(row.unit_price) if row.unit_price else None
|
||||
})
|
||||
if len(products) >= limit:
|
||||
break
|
||||
|
||||
return products
|
||||
|
||||
|
||||
@router.get("/{entry_id}")
|
||||
async def get_logbook_entry(
|
||||
entry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Get single logbook entry with details"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
return build_entry_response(entry)
|
||||
|
||||
|
||||
@router.post("/wastage")
|
||||
async def create_wastage_entry(
|
||||
entry_input: WastageEntryInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create wastage entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.WASTAGE,
|
||||
entry_date=entry_input.entry_date,
|
||||
reference_number=entry_input.reference_number,
|
||||
notes=entry_input.notes,
|
||||
type_data={"reason": entry_input.reason.value},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush() # Get entry.id
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created wastage entry {entry.id} for kitchen {current_user.kitchen_id}, cost: {total_cost}")
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/transfer")
|
||||
async def create_transfer_entry(
|
||||
entry_input: TransferEntryInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create transfer entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.TRANSFER,
|
||||
entry_date=entry_input.entry_date,
|
||||
reference_number=entry_input.reference_number,
|
||||
notes=entry_input.notes,
|
||||
type_data={
|
||||
"destination_kitchen_id": entry_input.destination_kitchen_id,
|
||||
"status": entry_input.status.value
|
||||
},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created transfer entry {entry.id} for kitchen {current_user.kitchen_id} -> {entry_input.destination_kitchen_id}")
|
||||
|
||||
# TODO: Send notification to destination kitchen
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/staff-food")
|
||||
async def create_staff_food_entry(
|
||||
entry_input: StaffFoodEntryInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create staff food entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.STAFF_FOOD,
|
||||
entry_date=entry_input.entry_date,
|
||||
notes=entry_input.notes,
|
||||
type_data={
|
||||
"meal_type": entry_input.meal_type,
|
||||
"staff_count": entry_input.staff_count
|
||||
},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created staff food entry {entry.id} for kitchen {current_user.kitchen_id}, meal: {entry_input.meal_type}")
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/manual-adjustment")
|
||||
async def create_manual_adjustment_entry(
|
||||
entry_input: ManualAdjustmentInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create manual adjustment entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.MANUAL_ADJUSTMENT,
|
||||
entry_date=entry_input.entry_date,
|
||||
reference_number=entry_input.reference_number,
|
||||
notes=entry_input.notes,
|
||||
type_data={
|
||||
"adjustment_reason": entry_input.adjustment_reason,
|
||||
"original_invoice_id": entry_input.original_invoice_id
|
||||
},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created manual adjustment entry {entry.id} for kitchen {current_user.kitchen_id}")
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}")
|
||||
async def update_logbook_entry(
|
||||
entry_id: int,
|
||||
notes: Optional[str] = None,
|
||||
reference_number: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update logbook entry (limited fields)"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
if notes is not None:
|
||||
entry.notes = notes
|
||||
|
||||
if reference_number is not None:
|
||||
entry.reference_number = reference_number
|
||||
|
||||
entry.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/{entry_id}")
|
||||
async def delete_logbook_entry(
|
||||
entry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Soft delete logbook entry"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
entry.is_deleted = True
|
||||
entry.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Deleted logbook entry {entry_id} for kitchen {current_user.kitchen_id}")
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{entry_id}/attachments")
|
||||
async def upload_attachment(
|
||||
entry_id: int,
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = Form(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload photo or document to logbook entry"""
|
||||
|
||||
# Verify entry exists and belongs to user's kitchen
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
# Validate file type
|
||||
allowed_types = ["image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"]
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed. Allowed: {allowed_types}")
|
||||
|
||||
# Save file
|
||||
upload_dir = f"/app/attachments/logbook/kitchen_{current_user.kitchen_id}"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
file_extension = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
|
||||
file_name = f"entry_{entry_id}_{timestamp}{file_extension}"
|
||||
file_path = f"{upload_dir}/{file_name}"
|
||||
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Create attachment record
|
||||
attachment = LogbookAttachment(
|
||||
entry_id=entry_id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
file_name=file.filename or file_name,
|
||||
file_path=file_path,
|
||||
file_type=file.content_type,
|
||||
file_size_bytes=len(content),
|
||||
description=description,
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Uploaded attachment {attachment.id} to entry {entry_id}")
|
||||
|
||||
return {
|
||||
"id": attachment.id,
|
||||
"file_name": attachment.file_name,
|
||||
"file_path": attachment.file_path
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{entry_id}/attachments/{attachment_id}")
|
||||
async def delete_attachment(
|
||||
entry_id: int,
|
||||
attachment_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete attachment from logbook entry"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookAttachment).where(
|
||||
and_(
|
||||
LogbookAttachment.id == attachment_id,
|
||||
LogbookAttachment.entry_id == entry_id,
|
||||
LogbookAttachment.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Delete file from disk
|
||||
if os.path.exists(attachment.file_path):
|
||||
os.remove(attachment.file_path)
|
||||
|
||||
await db.delete(attachment)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted"}
|
||||
1308
backend/api/menus.py
Normal file
1308
backend/api/menus.py
Normal file
File diff suppressed because it is too large
Load diff
1016
backend/api/newbook.py
Normal file
1016
backend/api/newbook.py
Normal file
File diff suppressed because it is too large
Load diff
118
backend/api/public.py
Normal file
118
backend/api/public.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""
|
||||
Public API endpoints - NO AUTHENTICATION REQUIRED.
|
||||
|
||||
These endpoints are designed for sharing with external parties (e.g., suppliers)
|
||||
via hash-based URLs that don't require login.
|
||||
"""
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from fastapi import Depends
|
||||
|
||||
from database import get_db
|
||||
from models.dispute import DisputeAttachment
|
||||
from models.settings import KitchenSettings
|
||||
from services.nextcloud_service import NextcloudService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/attachments/{public_hash}")
|
||||
async def get_public_attachment(
|
||||
public_hash: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
View a dispute attachment publicly via its hash.
|
||||
|
||||
This endpoint does NOT require authentication, allowing suppliers
|
||||
to view attached images/documents via shareable links in emails.
|
||||
"""
|
||||
# Find attachment by public hash
|
||||
result = await db.execute(
|
||||
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get file content
|
||||
content = None
|
||||
|
||||
# Try local file first
|
||||
if attachment.file_storage_location == "local" and attachment.file_path:
|
||||
if os.path.exists(attachment.file_path):
|
||||
with open(attachment.file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
# Try Nextcloud if local not found
|
||||
if content is None and attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
|
||||
# Get kitchen settings for Nextcloud credentials
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == attachment.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if settings and settings.nextcloud_enabled:
|
||||
nc = NextcloudService(
|
||||
settings.nextcloud_host,
|
||||
settings.nextcloud_username,
|
||||
settings.nextcloud_password,
|
||||
""
|
||||
)
|
||||
success, nc_content = await nc.download_file(attachment.nextcloud_path)
|
||||
await nc.close()
|
||||
|
||||
if success:
|
||||
content = nc_content
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Determine if browser should display inline or download
|
||||
# Images and PDFs display inline, others download
|
||||
inline_types = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf'
|
||||
]
|
||||
|
||||
disposition = "inline" if attachment.file_type in inline_types else "attachment"
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.file_type,
|
||||
headers={
|
||||
"Content-Disposition": f'{disposition}; filename="{attachment.file_name}"',
|
||||
"Cache-Control": "private, max-age=3600" # Cache for 1 hour
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/attachments/{public_hash}/info")
|
||||
async def get_public_attachment_info(
|
||||
public_hash: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get attachment metadata without downloading the file.
|
||||
Useful for email previews or link unfurling.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
return {
|
||||
"file_name": attachment.file_name,
|
||||
"file_type": attachment.file_type,
|
||||
"file_size_bytes": attachment.file_size_bytes,
|
||||
"attachment_type": attachment.attachment_type,
|
||||
"description": attachment.description,
|
||||
"uploaded_at": attachment.uploaded_at.isoformat() if attachment.uploaded_at else None
|
||||
}
|
||||
835
backend/api/purchase_orders.py
Normal file
835
backend/api/purchase_orders.py
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
"""
|
||||
Purchase Order API endpoints — full CRUD, attachment, product search, budget view,
|
||||
preview (HTML), and email sending.
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from html import escape as html_escape
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, func, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, field_serializer
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, Kitchen
|
||||
from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.line_item import LineItem
|
||||
from models.invoice import Invoice
|
||||
from auth import get_current_user, require_cap, get_current_user_from_token
|
||||
from services.email_service import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_DIR = "data/po_attachments"
|
||||
|
||||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineItemIn(BaseModel):
|
||||
product_id: Optional[int] = None
|
||||
product_code: Optional[str] = None
|
||||
description: str
|
||||
unit: Optional[str] = None
|
||||
unit_price: Decimal
|
||||
quantity: Decimal
|
||||
total: Decimal
|
||||
line_number: int = 0
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class PurchaseOrderCreate(BaseModel):
|
||||
supplier_id: int
|
||||
order_date: date
|
||||
order_type: str # 'itemised' or 'single_value'
|
||||
total_amount: Optional[Decimal] = None
|
||||
order_reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str = "DRAFT"
|
||||
line_items: list[LineItemIn] = []
|
||||
|
||||
|
||||
class PurchaseOrderUpdate(BaseModel):
|
||||
supplier_id: Optional[int] = None
|
||||
order_date: Optional[date] = None
|
||||
order_type: Optional[str] = None
|
||||
total_amount: Optional[Decimal] = None
|
||||
order_reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
line_items: Optional[list[LineItemIn]] = None
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class LineItemOut(BaseModel):
|
||||
id: int
|
||||
product_id: Optional[int]
|
||||
product_code: Optional[str]
|
||||
description: str
|
||||
unit: Optional[str]
|
||||
unit_price: Decimal
|
||||
quantity: Decimal
|
||||
total: Decimal
|
||||
line_number: int
|
||||
source: str
|
||||
|
||||
@field_serializer('unit_price', 'quantity', 'total')
|
||||
def ser(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class PurchaseOrderOut(BaseModel):
|
||||
id: int
|
||||
kitchen_id: int
|
||||
supplier_id: int
|
||||
supplier_name: Optional[str] = None
|
||||
order_date: date
|
||||
order_type: str
|
||||
status: str
|
||||
total_amount: Optional[Decimal]
|
||||
order_reference: Optional[str]
|
||||
notes: Optional[str]
|
||||
attachment_path: Optional[str]
|
||||
attachment_original_name: Optional[str]
|
||||
linked_invoice_id: Optional[int]
|
||||
created_by: int
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
line_items: list[LineItemOut] = []
|
||||
|
||||
@field_serializer('total_amount')
|
||||
def ser_amount(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class BudgetPO(BaseModel):
|
||||
id: int
|
||||
order_type: str
|
||||
status: str
|
||||
total_amount: Optional[float]
|
||||
order_reference: Optional[str]
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def po_to_out(po: PurchaseOrder) -> PurchaseOrderOut:
|
||||
return PurchaseOrderOut(
|
||||
id=po.id,
|
||||
kitchen_id=po.kitchen_id,
|
||||
supplier_id=po.supplier_id,
|
||||
supplier_name=po.supplier.name if po.supplier else None,
|
||||
order_date=po.order_date,
|
||||
order_type=po.order_type,
|
||||
status=po.status,
|
||||
total_amount=po.total_amount,
|
||||
order_reference=po.order_reference,
|
||||
notes=po.notes,
|
||||
attachment_path=po.attachment_path,
|
||||
attachment_original_name=po.attachment_original_name,
|
||||
linked_invoice_id=po.linked_invoice_id,
|
||||
created_by=po.created_by,
|
||||
created_by_name=po.created_by_user.name if po.created_by_user else None,
|
||||
created_at=po.created_at.isoformat() if po.created_at else "",
|
||||
updated_at=po.updated_at.isoformat() if po.updated_at else "",
|
||||
line_items=[
|
||||
LineItemOut(
|
||||
id=li.id,
|
||||
product_id=li.product_id,
|
||||
product_code=li.product_code,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
unit_price=li.unit_price,
|
||||
quantity=li.quantity,
|
||||
total=li.total,
|
||||
line_number=li.line_number,
|
||||
source=li.source,
|
||||
)
|
||||
for li in (po.line_items or [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _calc_itemised_total(items: list[LineItemIn]) -> Decimal:
|
||||
return sum((i.total for i in items), Decimal("0"))
|
||||
|
||||
|
||||
async def _load_po(db: AsyncSession, po_id: int, kitchen_id: int) -> PurchaseOrder:
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder)
|
||||
.where(
|
||||
PurchaseOrder.id == po_id,
|
||||
PurchaseOrder.kitchen_id == kitchen_id,
|
||||
)
|
||||
.options(
|
||||
selectinload(PurchaseOrder.line_items),
|
||||
selectinload(PurchaseOrder.supplier),
|
||||
selectinload(PurchaseOrder.created_by_user),
|
||||
)
|
||||
)
|
||||
po = result.scalar_one_or_none()
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
return po
|
||||
|
||||
|
||||
# ── CRUD Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/", response_model=PurchaseOrderOut)
|
||||
async def create_purchase_order(
|
||||
data: PurchaseOrderCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Validate supplier belongs to this kitchen
|
||||
sup = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == data.supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
if not sup.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Invalid supplier")
|
||||
|
||||
total = data.total_amount if data.order_type == "single_value" else _calc_itemised_total(data.line_items)
|
||||
|
||||
po = PurchaseOrder(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
supplier_id=data.supplier_id,
|
||||
order_date=data.order_date,
|
||||
order_type=data.order_type,
|
||||
status=data.status if data.status in ("DRAFT", "PENDING") else "DRAFT",
|
||||
total_amount=total,
|
||||
order_reference=data.order_reference,
|
||||
notes=data.notes,
|
||||
created_by=current_user.id,
|
||||
updated_by=current_user.id,
|
||||
)
|
||||
db.add(po)
|
||||
await db.flush()
|
||||
|
||||
for idx, li in enumerate(data.line_items):
|
||||
db.add(PurchaseOrderLineItem(
|
||||
purchase_order_id=po.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
product_id=li.product_id,
|
||||
product_code=li.product_code,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
unit_price=li.unit_price,
|
||||
quantity=li.quantity,
|
||||
total=li.total,
|
||||
line_number=li.line_number or idx,
|
||||
source=li.source,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PurchaseOrderOut])
|
||||
async def list_purchase_orders(
|
||||
status: Optional[str] = None,
|
||||
supplier_id: Optional[int] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = (
|
||||
select(PurchaseOrder)
|
||||
.where(PurchaseOrder.kitchen_id == current_user.kitchen_id)
|
||||
.options(
|
||||
selectinload(PurchaseOrder.line_items),
|
||||
selectinload(PurchaseOrder.supplier),
|
||||
selectinload(PurchaseOrder.created_by_user),
|
||||
)
|
||||
.order_by(PurchaseOrder.order_date.desc(), PurchaseOrder.id.desc())
|
||||
)
|
||||
|
||||
if status:
|
||||
statuses = [s.strip().upper() for s in status.split(",")]
|
||||
q = q.where(PurchaseOrder.status.in_(statuses))
|
||||
if supplier_id:
|
||||
q = q.where(PurchaseOrder.supplier_id == supplier_id)
|
||||
if date_from:
|
||||
q = q.where(PurchaseOrder.order_date >= date_from)
|
||||
if date_to:
|
||||
q = q.where(PurchaseOrder.order_date <= date_to)
|
||||
|
||||
q = q.offset(offset).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return [po_to_out(po) for po in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/products/search")
|
||||
async def search_products_for_po(
|
||||
query: str,
|
||||
supplier_id: Optional[int] = None,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Search products from invoice line items, optionally filtered by supplier."""
|
||||
q = (
|
||||
select(
|
||||
LineItem.description,
|
||||
LineItem.product_code,
|
||||
LineItem.unit,
|
||||
LineItem.unit_price,
|
||||
Supplier.name.label("supplier_name"),
|
||||
Invoice.supplier_id.label("sup_id"),
|
||||
)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(
|
||||
and_(
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
or_(
|
||||
LineItem.description.ilike(f"%{query}%"),
|
||||
LineItem.product_code.ilike(f"%{query}%"),
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(Invoice.invoice_date.desc())
|
||||
)
|
||||
|
||||
if supplier_id:
|
||||
q = q.where(Invoice.supplier_id == supplier_id)
|
||||
|
||||
q = q.limit(limit * 3)
|
||||
result = await db.execute(q)
|
||||
rows = result.all()
|
||||
|
||||
seen: set[str] = set()
|
||||
products = []
|
||||
for row in rows:
|
||||
key = (row.description or "").lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
products.append({
|
||||
"id": 0,
|
||||
"name": row.description,
|
||||
"product_code": row.product_code,
|
||||
"supplier_name": row.supplier_name,
|
||||
"unit": row.unit,
|
||||
"last_price": float(row.unit_price) if row.unit_price else None,
|
||||
})
|
||||
if len(products) >= limit:
|
||||
break
|
||||
|
||||
return products
|
||||
|
||||
|
||||
@router.get("/by-date")
|
||||
async def get_pos_by_date(
|
||||
week_start: date,
|
||||
week_end: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""POs grouped by supplier_id → date for budget table integration."""
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder)
|
||||
.where(
|
||||
PurchaseOrder.kitchen_id == current_user.kitchen_id,
|
||||
PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
|
||||
PurchaseOrder.order_date >= week_start,
|
||||
PurchaseOrder.order_date <= week_end,
|
||||
)
|
||||
.options(selectinload(PurchaseOrder.line_items))
|
||||
)
|
||||
pos = result.scalars().all()
|
||||
|
||||
grouped: dict[int, dict[str, list]] = {}
|
||||
for po in pos:
|
||||
sid = po.supplier_id
|
||||
ds = po.order_date.isoformat()
|
||||
if sid not in grouped:
|
||||
grouped[sid] = {}
|
||||
if ds not in grouped[sid]:
|
||||
grouped[sid][ds] = []
|
||||
grouped[sid][ds].append(BudgetPO(
|
||||
id=po.id,
|
||||
order_type=po.order_type,
|
||||
status=po.status,
|
||||
total_amount=float(po.total_amount) if po.total_amount else None,
|
||||
order_reference=po.order_reference,
|
||||
).model_dump())
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
@router.get("/{po_id}", response_model=PurchaseOrderOut)
|
||||
async def get_purchase_order(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return po_to_out(await _load_po(db, po_id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.put("/{po_id}", response_model=PurchaseOrderOut)
|
||||
async def update_purchase_order(
|
||||
po_id: int,
|
||||
data: PurchaseOrderUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
|
||||
if po.status in ("LINKED", "CLOSED", "CANCELLED"):
|
||||
raise HTTPException(status_code=400, detail=f"Cannot edit PO with status {po.status}")
|
||||
|
||||
if data.supplier_id is not None:
|
||||
po.supplier_id = data.supplier_id
|
||||
if data.order_date is not None:
|
||||
po.order_date = data.order_date
|
||||
if data.order_type is not None:
|
||||
po.order_type = data.order_type
|
||||
if data.order_reference is not None:
|
||||
po.order_reference = data.order_reference
|
||||
if data.notes is not None:
|
||||
po.notes = data.notes
|
||||
if data.status is not None and data.status in ("DRAFT", "PENDING"):
|
||||
po.status = data.status
|
||||
|
||||
# Replace line items if provided
|
||||
if data.line_items is not None:
|
||||
await db.execute(
|
||||
delete(PurchaseOrderLineItem).where(
|
||||
PurchaseOrderLineItem.purchase_order_id == po.id
|
||||
)
|
||||
)
|
||||
for idx, li in enumerate(data.line_items):
|
||||
db.add(PurchaseOrderLineItem(
|
||||
purchase_order_id=po.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
product_id=li.product_id,
|
||||
product_code=li.product_code,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
unit_price=li.unit_price,
|
||||
quantity=li.quantity,
|
||||
total=li.total,
|
||||
line_number=li.line_number or idx,
|
||||
source=li.source,
|
||||
))
|
||||
|
||||
# Recalculate total
|
||||
if po.order_type == "single_value":
|
||||
if data.total_amount is not None:
|
||||
po.total_amount = data.total_amount
|
||||
else:
|
||||
items = data.line_items if data.line_items is not None else []
|
||||
po.total_amount = _calc_itemised_total(items) if items else po.total_amount
|
||||
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.delete("/{po_id}")
|
||||
async def delete_purchase_order(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
allowed_statuses = ("DRAFT", "CANCELLED", "PENDING") if current_user.is_admin else ("DRAFT", "CANCELLED")
|
||||
if po.status not in allowed_statuses:
|
||||
raise HTTPException(status_code=400, detail="Only DRAFT or CANCELLED POs can be deleted")
|
||||
|
||||
await db.delete(po)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{po_id}/status", response_model=PurchaseOrderOut)
|
||||
async def update_po_status(
|
||||
po_id: int,
|
||||
data: StatusUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
allowed = {
|
||||
"DRAFT": ["PENDING", "CANCELLED"],
|
||||
"PENDING": ["DRAFT", "CLOSED", "CANCELLED"],
|
||||
"LINKED": ["CLOSED"],
|
||||
"CLOSED": [],
|
||||
"CANCELLED": ["DRAFT"],
|
||||
}
|
||||
if data.status not in allowed.get(po.status, []):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot change status from {po.status} to {data.status}",
|
||||
)
|
||||
po.status = data.status
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
# ── Attachment ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{po_id}/attachment", response_model=PurchaseOrderOut)
|
||||
async def upload_attachment(
|
||||
po_id: int,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(file.filename or "file")[1]
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
contents = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
po.attachment_path = filepath
|
||||
po.attachment_original_name = file.filename
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.delete("/{po_id}/attachment", response_model=PurchaseOrderOut)
|
||||
async def remove_attachment(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
if po.attachment_path and os.path.exists(po.attachment_path):
|
||||
os.remove(po.attachment_path)
|
||||
po.attachment_path = None
|
||||
po.attachment_original_name = None
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
# ── Preview & Email ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str = "£") -> str:
|
||||
"""Generate a clean HTML page for PO preview / email body."""
|
||||
supplier = po.supplier
|
||||
esc = html_escape
|
||||
|
||||
# Kitchen letterhead
|
||||
kitchen_name = esc(kitchen.kitchen_display_name or "")
|
||||
addr_parts = [
|
||||
kitchen.kitchen_address_line1,
|
||||
kitchen.kitchen_address_line2,
|
||||
kitchen.kitchen_city,
|
||||
kitchen.kitchen_postcode,
|
||||
]
|
||||
addr_html = "<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))
|
||||
2318
backend/api/recipes.py
Normal file
2318
backend/api/recipes.py
Normal file
File diff suppressed because it is too large
Load diff
1187
backend/api/reconciliation.py
Normal file
1187
backend/api/reconciliation.py
Normal file
File diff suppressed because it is too large
Load diff
3421
backend/api/reports.py
Normal file
3421
backend/api/reports.py
Normal file
File diff suppressed because it is too large
Load diff
422
backend/api/residents_table_chart.py
Normal file
422
backend/api/residents_table_chart.py
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"""
|
||||
Residents Table Chart API
|
||||
|
||||
Gantt-style visualization showing hotel bookings with restaurant table indicators.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.newbook import NewbookDailyOccupancy
|
||||
from models.resos import ResosBooking
|
||||
|
||||
router = APIRouter(prefix="/residents-table-chart", tags=["Residents Table Chart"])
|
||||
|
||||
|
||||
class RestaurantBookingDetail(BaseModel):
|
||||
has_booking: bool
|
||||
time: Optional[str] = None
|
||||
people: Optional[int] = None
|
||||
table_name: Optional[str] = None
|
||||
opening_hour_name: Optional[str] = None
|
||||
is_group_match: Optional[bool] = None # True if matched via group/exclude field (not the lead booking)
|
||||
|
||||
|
||||
class BookingSegment(BaseModel):
|
||||
booking_id: str | None
|
||||
bookings_group_id: Optional[str] = None
|
||||
check_in: str
|
||||
check_out: str
|
||||
nights: list[str]
|
||||
is_dbb: Optional[bool] = None
|
||||
is_package: Optional[bool] = None
|
||||
restaurant_bookings: dict[str, RestaurantBookingDetail]
|
||||
|
||||
|
||||
class RoomRow(BaseModel):
|
||||
room_number: str | None
|
||||
bookings: list[BookingSegment] # Multiple bookings in the same room
|
||||
|
||||
|
||||
class ResidentsTableChartResponse(BaseModel):
|
||||
date_range: dict
|
||||
rooms: list[RoomRow] # Changed from 'bookings' to 'rooms'
|
||||
summary: dict
|
||||
metrics: Optional[dict] = None # Aggregated metrics for different time periods
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_residents_table_chart(
|
||||
start_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResidentsTableChartResponse:
|
||||
"""
|
||||
Get Gantt-style chart data showing hotel bookings with restaurant table indicators.
|
||||
|
||||
Args:
|
||||
start_date: First day of 7-day period (defaults to today)
|
||||
|
||||
Returns:
|
||||
Chart data with hotel stays and restaurant booking indicators
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if start_date is None:
|
||||
start_date = date.today()
|
||||
|
||||
logger.info(f"ResidentsTableChart API called with start_date={start_date}")
|
||||
|
||||
end_date = start_date + timedelta(days=6) # 7-day period
|
||||
date_range = {
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat(),
|
||||
"dates": [(start_date + timedelta(days=i)).isoformat() for i in range(7)]
|
||||
}
|
||||
|
||||
# Fetch Newbook occupancy data for 7-day period
|
||||
result = await db.execute(
|
||||
select(NewbookDailyOccupancy).where(
|
||||
and_(
|
||||
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
||||
NewbookDailyOccupancy.date >= start_date,
|
||||
NewbookDailyOccupancy.date <= end_date,
|
||||
NewbookDailyOccupancy.rooms_breakdown.isnot(None) # Only records with room breakdown
|
||||
)
|
||||
).order_by(NewbookDailyOccupancy.date)
|
||||
)
|
||||
occupancy_records = result.scalars().all()
|
||||
logger.info(f"Found {len(occupancy_records)} occupancy records")
|
||||
|
||||
# Group by room number only (one row per room in Gantt chart)
|
||||
# Key: room_number, Value: dict of bookings for that room
|
||||
rooms_dict = {}
|
||||
for record in occupancy_records:
|
||||
# Parse JSONB array - each element is a room object for this date
|
||||
rooms = record.rooms_breakdown or []
|
||||
|
||||
for room in rooms:
|
||||
room_number = room.get("room_number")
|
||||
booking_id = room.get("booking_id")
|
||||
|
||||
if room_number not in rooms_dict:
|
||||
rooms_dict[room_number] = {}
|
||||
|
||||
# Track each booking within this room
|
||||
if booking_id not in rooms_dict[room_number]:
|
||||
rooms_dict[room_number][booking_id] = {
|
||||
'booking_id': booking_id,
|
||||
'bookings_group_id': room.get("bookings_group_id"),
|
||||
'nights': [],
|
||||
'is_dbb': room.get("is_dbb", False),
|
||||
'is_package': room.get("is_package", False)
|
||||
}
|
||||
|
||||
rooms_dict[room_number][booking_id]['nights'].append(record.date)
|
||||
|
||||
# Log rooms with multiple bookings to diagnose stacking issue
|
||||
for room_number, bookings in rooms_dict.items():
|
||||
if len(bookings) > 1:
|
||||
logger.warning(f"Room {room_number} has {len(bookings)} different bookings:")
|
||||
for booking_id, booking_data in bookings.items():
|
||||
nights_str = ', '.join(sorted([n.isoformat() for n in booking_data['nights']]))
|
||||
logger.warning(f" - Booking {booking_id}: nights={nights_str}")
|
||||
|
||||
# Convert to list - one entry per room with all its bookings
|
||||
hotel_stays = []
|
||||
for room_number, bookings in rooms_dict.items():
|
||||
# Collect all bookings for this room
|
||||
room_bookings = []
|
||||
all_nights = []
|
||||
|
||||
for booking_data in bookings.values():
|
||||
nights = sorted(booking_data['nights'])
|
||||
if nights:
|
||||
all_nights.extend(nights)
|
||||
check_in = nights[0]
|
||||
check_out = nights[-1] + timedelta(days=1)
|
||||
|
||||
room_bookings.append({
|
||||
'booking_id': booking_data['booking_id'],
|
||||
'bookings_group_id': booking_data.get('bookings_group_id'),
|
||||
'check_in': check_in.isoformat(),
|
||||
'check_out': check_out.isoformat(),
|
||||
'nights': [n.isoformat() for n in nights],
|
||||
'is_dbb': booking_data['is_dbb'],
|
||||
'is_package': booking_data['is_package']
|
||||
})
|
||||
|
||||
# Create one entry per room with all bookings
|
||||
if room_bookings:
|
||||
all_nights_sorted = sorted(set(all_nights))
|
||||
hotel_stays.append({
|
||||
'room_number': room_number,
|
||||
'bookings': room_bookings, # Array of all bookings in this room
|
||||
'all_nights': [n.isoformat() for n in all_nights_sorted] # All occupied nights for this room
|
||||
})
|
||||
|
||||
logger.info(f"Built {len(hotel_stays)} room entries")
|
||||
|
||||
# Fetch Resos bookings for hotel guests in this period
|
||||
result = await db.execute(
|
||||
select(ResosBooking).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= start_date,
|
||||
ResosBooking.booking_date <= end_date,
|
||||
ResosBooking.is_hotel_guest == True,
|
||||
ResosBooking.hotel_booking_number.isnot(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
resos_bookings = result.scalars().all()
|
||||
|
||||
# Build lookup: booking_id -> {date -> resos_booking}
|
||||
# Also handle group bookings via exclude_flag field (format: "#32990,#32991")
|
||||
resos_lookup = {}
|
||||
import re
|
||||
|
||||
for resos_booking in resos_bookings:
|
||||
booking_id = resos_booking.hotel_booking_number
|
||||
booking_date = resos_booking.booking_date.isoformat()
|
||||
|
||||
if booking_id not in resos_lookup:
|
||||
resos_lookup[booking_id] = {}
|
||||
|
||||
# Direct match for the lead/primary booking
|
||||
resos_lookup[booking_id][booking_date] = {
|
||||
'has_booking': True,
|
||||
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
|
||||
'people': resos_booking.people,
|
||||
'table_name': resos_booking.table_name,
|
||||
'opening_hour_name': resos_booking.opening_hour_name,
|
||||
'is_group_match': False # Direct match, not a group member
|
||||
}
|
||||
|
||||
# Parse exclude_flag for group bookings (format: "#32990,#32991")
|
||||
if resos_booking.exclude_flag:
|
||||
# Extract all booking numbers from the exclude_flag field
|
||||
group_booking_ids = re.findall(r'#(\d+)', resos_booking.exclude_flag)
|
||||
|
||||
for group_id in group_booking_ids:
|
||||
# Skip the lead booking itself (already added above)
|
||||
if group_id == booking_id:
|
||||
continue
|
||||
|
||||
# Add group member with is_group_match=True
|
||||
if group_id not in resos_lookup:
|
||||
resos_lookup[group_id] = {}
|
||||
|
||||
# Only add if not already present (don't overwrite direct matches)
|
||||
if booking_date not in resos_lookup[group_id]:
|
||||
resos_lookup[group_id][booking_date] = {
|
||||
'has_booking': True,
|
||||
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
|
||||
'people': resos_booking.people,
|
||||
'table_name': resos_booking.table_name,
|
||||
'opening_hour_name': resos_booking.opening_hour_name,
|
||||
'is_group_match': True # Matched via group, not direct
|
||||
}
|
||||
|
||||
logger.info(f"Built resos_lookup with {len(resos_lookup)} booking IDs (including group matches)")
|
||||
|
||||
# Combine rooms with restaurant bookings
|
||||
room_rows = []
|
||||
total_room_nights = 0
|
||||
nights_with_restaurant = 0
|
||||
|
||||
try:
|
||||
for room_data in hotel_stays:
|
||||
booking_segments = []
|
||||
|
||||
# Process each booking within this room
|
||||
for booking_data in room_data['bookings']:
|
||||
# Build restaurant bookings dict for each night in the 7-day period
|
||||
restaurant_bookings = {}
|
||||
|
||||
for date_str in date_range['dates']:
|
||||
# Check if this date is within this specific booking's nights
|
||||
if date_str in booking_data['nights']:
|
||||
total_room_nights += 1
|
||||
|
||||
# Check if there's a restaurant booking for this date
|
||||
resos_data = resos_lookup.get(booking_data['booking_id'], {}).get(date_str)
|
||||
|
||||
if resos_data:
|
||||
restaurant_bookings[date_str] = resos_data
|
||||
nights_with_restaurant += 1
|
||||
else:
|
||||
restaurant_bookings[date_str] = {'has_booking': False}
|
||||
else:
|
||||
# Not staying this night
|
||||
restaurant_bookings[date_str] = {'has_booking': False}
|
||||
|
||||
# Create booking segment with restaurant data
|
||||
booking_segments.append(BookingSegment(
|
||||
booking_id=booking_data['booking_id'],
|
||||
bookings_group_id=booking_data.get('bookings_group_id'),
|
||||
check_in=booking_data['check_in'],
|
||||
check_out=booking_data['check_out'],
|
||||
nights=booking_data['nights'],
|
||||
is_dbb=booking_data['is_dbb'],
|
||||
is_package=booking_data['is_package'],
|
||||
restaurant_bookings=restaurant_bookings
|
||||
))
|
||||
|
||||
# Create room row with all its bookings
|
||||
room_rows.append(RoomRow(
|
||||
room_number=room_data['room_number'],
|
||||
bookings=booking_segments
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Error building room_rows: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
logger.info(f"Built {len(room_rows)} room rows")
|
||||
|
||||
# Sort rooms by room number (natural sort for numeric rooms)
|
||||
def natural_sort_key(room: RoomRow):
|
||||
"""Natural sort key for room numbers (handles both numeric and alphanumeric)"""
|
||||
if not room.room_number:
|
||||
return (float('inf'), '') # Put None/empty at end
|
||||
|
||||
# Extract numeric part for sorting (e.g., "102" -> 102, "A-12" -> 12)
|
||||
import re
|
||||
numbers = re.findall(r'\d+', room.room_number)
|
||||
if numbers:
|
||||
return (int(numbers[0]), room.room_number)
|
||||
return (float('inf'), room.room_number)
|
||||
|
||||
room_rows.sort(key=natural_sort_key)
|
||||
|
||||
# Calculate summary
|
||||
coverage_pct = (nights_with_restaurant / total_room_nights * 100) if total_room_nights > 0 else 0.0
|
||||
|
||||
# Count total bookings across all rooms
|
||||
total_bookings = sum(len(room.bookings) for room in room_rows)
|
||||
|
||||
summary = {
|
||||
'total_rooms': len(room_rows),
|
||||
'total_bookings': total_bookings,
|
||||
'total_room_nights': total_room_nights,
|
||||
'nights_with_restaurant': nights_with_restaurant,
|
||||
'coverage_percentage': round(coverage_pct, 1)
|
||||
}
|
||||
|
||||
# Calculate aggregated metrics for different time periods
|
||||
def get_week_start(d: date) -> date:
|
||||
"""Get Monday of the week containing date d"""
|
||||
return d - timedelta(days=d.weekday())
|
||||
|
||||
async def calculate_period_metrics(period_start: date, period_end: date, is_forecast: Optional[bool] = None) -> dict:
|
||||
"""Calculate metrics for a specific date range"""
|
||||
query = select(NewbookDailyOccupancy).where(
|
||||
and_(
|
||||
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
||||
NewbookDailyOccupancy.date >= period_start,
|
||||
NewbookDailyOccupancy.date <= period_end,
|
||||
NewbookDailyOccupancy.rooms_breakdown.isnot(None)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by forecast status if specified
|
||||
if is_forecast is not None:
|
||||
query = query.where(NewbookDailyOccupancy.is_forecast == is_forecast)
|
||||
|
||||
result = await db.execute(query.order_by(NewbookDailyOccupancy.date))
|
||||
records = result.scalars().all()
|
||||
|
||||
# Count metrics
|
||||
total_room_nights_period = 0
|
||||
unique_bookings = set()
|
||||
nights_with_rest = 0
|
||||
|
||||
for record in records:
|
||||
rooms = record.rooms_breakdown or []
|
||||
for room in rooms:
|
||||
booking_id = room.get("booking_id")
|
||||
if booking_id:
|
||||
unique_bookings.add(booking_id)
|
||||
total_room_nights_period += 1
|
||||
|
||||
# Check if has restaurant booking for this date
|
||||
date_str = record.date.isoformat()
|
||||
resos_data = resos_lookup.get(booking_id, {}).get(date_str)
|
||||
if resos_data:
|
||||
nights_with_rest += 1
|
||||
|
||||
coverage_pct_period = (nights_with_rest / total_room_nights_period * 100) if total_room_nights_period > 0 else 0.0
|
||||
|
||||
# Calculate average occupancy
|
||||
total_available = 0
|
||||
total_occupied = 0
|
||||
for record in records:
|
||||
if record.total_rooms and record.occupied_rooms:
|
||||
total_available += record.total_rooms
|
||||
total_occupied += record.occupied_rooms
|
||||
|
||||
avg_occupancy = (total_occupied / total_available * 100) if total_available > 0 else 0.0
|
||||
|
||||
return {
|
||||
'total_bookings': len(unique_bookings),
|
||||
'total_room_nights': total_room_nights_period,
|
||||
'nights_with_restaurant': nights_with_rest,
|
||||
'coverage_percentage': round(coverage_pct_period, 1),
|
||||
'avg_occupancy_percentage': round(avg_occupancy, 1)
|
||||
}
|
||||
|
||||
today = date.today()
|
||||
|
||||
# This week (Monday to Sunday)
|
||||
this_week_start = get_week_start(today)
|
||||
this_week_end = this_week_start + timedelta(days=6)
|
||||
|
||||
# Last week (previous Monday to Sunday)
|
||||
last_week_start = this_week_start - timedelta(days=7)
|
||||
last_week_end = last_week_start + timedelta(days=6)
|
||||
|
||||
# Last 30 days rolling (from yesterday)
|
||||
yesterday = today - timedelta(days=1)
|
||||
rolling_30_start = yesterday - timedelta(days=29)
|
||||
rolling_30_end = yesterday
|
||||
|
||||
# Calculate metrics for each period - always return metrics with default values
|
||||
default_metrics = {
|
||||
'total_bookings': 0,
|
||||
'total_room_nights': 0,
|
||||
'nights_with_restaurant': 0,
|
||||
'coverage_percentage': 0.0,
|
||||
'avg_occupancy_percentage': 0.0
|
||||
}
|
||||
|
||||
try:
|
||||
metrics = {
|
||||
'this_week_actual': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=False),
|
||||
'this_week_forecast': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=True),
|
||||
'last_week_actual': await calculate_period_metrics(last_week_start, last_week_end, is_forecast=False),
|
||||
'last_30_days_rolling': await calculate_period_metrics(rolling_30_start, rolling_30_end, is_forecast=False),
|
||||
}
|
||||
logger.info(f"Calculated metrics: {metrics}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating metrics: {e}", exc_info=True)
|
||||
# Return default metrics structure instead of None
|
||||
metrics = {
|
||||
'this_week_actual': default_metrics.copy(),
|
||||
'this_week_forecast': default_metrics.copy(),
|
||||
'last_week_actual': default_metrics.copy(),
|
||||
'last_30_days_rolling': default_metrics.copy(),
|
||||
}
|
||||
|
||||
return ResidentsTableChartResponse(
|
||||
date_range=date_range,
|
||||
rooms=room_rows,
|
||||
summary=summary,
|
||||
metrics=metrics
|
||||
)
|
||||
809
backend/api/resos.py
Normal file
809
backend/api/resos.py
Normal file
|
|
@ -0,0 +1,809 @@
|
|||
"""
|
||||
Resos API Endpoints
|
||||
|
||||
Handles Resos configuration, sync operations, and booking data retrieval.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, func, Date, case, or_
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
|
||||
from auth import get_current_user, require_cap
|
||||
from services.resos_sync import ResosSyncService
|
||||
from services.resos_api import ResosAPIClient, ResosAPIError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class ResosSettingsResponse(BaseModel):
|
||||
resos_api_key_set: bool # Masked
|
||||
resos_last_sync: datetime | None
|
||||
resos_auto_sync_enabled: bool
|
||||
resos_upcoming_sync_enabled: bool
|
||||
resos_upcoming_sync_interval: int
|
||||
resos_last_upcoming_sync: datetime | None
|
||||
resos_large_group_threshold: int
|
||||
resos_note_keywords: str | None
|
||||
resos_allergy_keywords: str | None
|
||||
resos_custom_field_mapping: dict | None
|
||||
resos_opening_hours_mapping: list | None
|
||||
resos_restaurant_table_entities: str | None
|
||||
resos_enable_manual_breakfast: bool
|
||||
resos_manual_breakfast_periods: list | None
|
||||
resos_flag_icon_mapping: dict | None
|
||||
resos_arrival_widget_service_filter: str | None # Service type: breakfast/lunch/dinner/other
|
||||
sambapos_food_gl_codes: str | None # Phase 8.1
|
||||
sambapos_beverage_gl_codes: str | None # Phase 8.1
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResosSettingsUpdate(BaseModel):
|
||||
resos_api_key: str | None = None
|
||||
resos_auto_sync_enabled: bool | None = None
|
||||
resos_upcoming_sync_enabled: bool | None = None
|
||||
resos_upcoming_sync_interval: int | None = None
|
||||
resos_large_group_threshold: int | None = None
|
||||
resos_note_keywords: str | None = None
|
||||
resos_allergy_keywords: str | None = None
|
||||
resos_custom_field_mapping: dict | None = None
|
||||
resos_opening_hours_mapping: list | None = None
|
||||
resos_restaurant_table_entities: str | None = None
|
||||
resos_enable_manual_breakfast: bool | None = None
|
||||
resos_manual_breakfast_periods: list | None = None
|
||||
resos_flag_icon_mapping: dict | None = None
|
||||
resos_arrival_widget_service_filter: str | None = None # Opening hour ID for arrivals widget filter
|
||||
sambapos_food_gl_codes: str | None = None # Phase 8.1
|
||||
sambapos_beverage_gl_codes: str | None = None # Phase 8.1
|
||||
|
||||
|
||||
class DailyStatsResponse(BaseModel):
|
||||
date: str
|
||||
total_bookings: int
|
||||
total_covers: int
|
||||
service_breakdown: list[dict]
|
||||
flagged_booking_count: int
|
||||
unique_flag_types: list[str] | None
|
||||
is_forecast: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BookingResponse(BaseModel):
|
||||
id: int
|
||||
resos_booking_id: str
|
||||
booking_date: str
|
||||
booking_time: str
|
||||
people: int
|
||||
status: str
|
||||
seating_area: str | None
|
||||
hotel_booking_number: str | None
|
||||
is_hotel_guest: bool | None
|
||||
is_dbb: bool | None
|
||||
is_package: bool | None
|
||||
allergies: str | None
|
||||
notes: str | None
|
||||
opening_hour_name: str | None
|
||||
is_flagged: bool
|
||||
flag_reasons: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardCoversResponse(BaseModel):
|
||||
date: str
|
||||
total_bookings: int
|
||||
total_covers: int
|
||||
service_breakdown: list[dict]
|
||||
has_flagged_bookings: bool
|
||||
unique_flag_types: list[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_resos_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResosSettingsResponse:
|
||||
"""Get Resos settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
logger.info(f"[Resos GET] Returning upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
|
||||
|
||||
return ResosSettingsResponse(
|
||||
resos_api_key_set=bool(settings.resos_api_key),
|
||||
resos_last_sync=settings.resos_last_sync,
|
||||
resos_auto_sync_enabled=settings.resos_auto_sync_enabled or False,
|
||||
resos_upcoming_sync_enabled=settings.resos_upcoming_sync_enabled or False,
|
||||
resos_upcoming_sync_interval=settings.resos_upcoming_sync_interval or 15,
|
||||
resos_last_upcoming_sync=settings.resos_last_upcoming_sync,
|
||||
resos_large_group_threshold=settings.resos_large_group_threshold or 8,
|
||||
resos_note_keywords=settings.resos_note_keywords,
|
||||
resos_allergy_keywords=settings.resos_allergy_keywords,
|
||||
resos_custom_field_mapping=settings.resos_custom_field_mapping,
|
||||
resos_opening_hours_mapping=settings.resos_opening_hours_mapping,
|
||||
resos_restaurant_table_entities=settings.resos_restaurant_table_entities,
|
||||
resos_enable_manual_breakfast=settings.resos_enable_manual_breakfast or False,
|
||||
resos_manual_breakfast_periods=settings.resos_manual_breakfast_periods,
|
||||
resos_flag_icon_mapping=settings.resos_flag_icon_mapping,
|
||||
resos_arrival_widget_service_filter=settings.resos_arrival_widget_service_filter,
|
||||
sambapos_food_gl_codes=settings.sambapos_food_gl_codes, # Phase 8.1
|
||||
sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes # Phase 8.1
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_resos_settings(
|
||||
update: ResosSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update Resos settings"""
|
||||
logger.info(f"[Resos PATCH] Received update: {update.model_dump(exclude_unset=True)}")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
logger.info(f"[Resos PATCH] Before update - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
|
||||
|
||||
if update.resos_api_key is not None:
|
||||
settings.resos_api_key = update.resos_api_key
|
||||
if update.resos_auto_sync_enabled is not None:
|
||||
settings.resos_auto_sync_enabled = update.resos_auto_sync_enabled
|
||||
if update.resos_upcoming_sync_enabled is not None:
|
||||
logger.info(f"[Resos PATCH] Setting upcoming_sync_enabled to {update.resos_upcoming_sync_enabled}")
|
||||
settings.resos_upcoming_sync_enabled = update.resos_upcoming_sync_enabled
|
||||
if update.resos_upcoming_sync_interval is not None:
|
||||
settings.resos_upcoming_sync_interval = update.resos_upcoming_sync_interval
|
||||
if update.resos_large_group_threshold is not None:
|
||||
settings.resos_large_group_threshold = update.resos_large_group_threshold
|
||||
if update.resos_note_keywords is not None:
|
||||
settings.resos_note_keywords = update.resos_note_keywords
|
||||
if update.resos_allergy_keywords is not None:
|
||||
settings.resos_allergy_keywords = update.resos_allergy_keywords
|
||||
if update.resos_custom_field_mapping is not None:
|
||||
settings.resos_custom_field_mapping = update.resos_custom_field_mapping
|
||||
if update.resos_opening_hours_mapping is not None:
|
||||
settings.resos_opening_hours_mapping = update.resos_opening_hours_mapping
|
||||
if update.resos_restaurant_table_entities is not None:
|
||||
settings.resos_restaurant_table_entities = update.resos_restaurant_table_entities
|
||||
if update.resos_enable_manual_breakfast is not None:
|
||||
settings.resos_enable_manual_breakfast = update.resos_enable_manual_breakfast
|
||||
if update.resos_manual_breakfast_periods is not None:
|
||||
settings.resos_manual_breakfast_periods = update.resos_manual_breakfast_periods
|
||||
if update.resos_flag_icon_mapping is not None:
|
||||
settings.resos_flag_icon_mapping = update.resos_flag_icon_mapping
|
||||
if update.resos_arrival_widget_service_filter is not None:
|
||||
settings.resos_arrival_widget_service_filter = update.resos_arrival_widget_service_filter
|
||||
# Phase 8.1: GL codes for food/beverage spend split
|
||||
if update.sambapos_food_gl_codes is not None:
|
||||
settings.sambapos_food_gl_codes = update.sambapos_food_gl_codes
|
||||
if update.sambapos_beverage_gl_codes is not None:
|
||||
settings.sambapos_beverage_gl_codes = update.sambapos_beverage_gl_codes
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"[Resos PATCH] After commit - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
|
||||
return {"message": "Settings updated successfully"}
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_resos_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Resos API connection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
if not settings.resos_api_key:
|
||||
raise HTTPException(status_code=400, detail="Resos API key not configured")
|
||||
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
success = await client.test_connection()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Connection failed")
|
||||
|
||||
return {"message": "Connection successful"}
|
||||
|
||||
|
||||
@router.get("/debug-upcoming-sync")
|
||||
async def debug_upcoming_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to check upcoming sync settings directly"""
|
||||
from sqlalchemy import text
|
||||
|
||||
# Get value via ORM
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
orm_value = settings.resos_upcoming_sync_enabled
|
||||
|
||||
# Get value via raw SQL
|
||||
raw_result = await db.execute(
|
||||
text("SELECT resos_upcoming_sync_enabled, resos_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"),
|
||||
{"kid": current_user.kitchen_id}
|
||||
)
|
||||
raw_row = raw_result.fetchone()
|
||||
|
||||
return {
|
||||
"orm_value": orm_value,
|
||||
"raw_db_enabled": raw_row[0] if raw_row else None,
|
||||
"raw_db_interval": raw_row[1] if raw_row else None,
|
||||
"column_exists": raw_row is not None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/custom-fields")
|
||||
async def fetch_custom_fields(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch custom field definitions from Resos API (GET request only)"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
if not settings.resos_api_key:
|
||||
raise HTTPException(status_code=400, detail="Resos API key not configured")
|
||||
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
fields = await client.get_custom_field_definitions()
|
||||
|
||||
return {"custom_fields": fields}
|
||||
|
||||
|
||||
@router.get("/opening-hours")
|
||||
async def fetch_opening_hours(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch opening hours/service periods from Resos API (GET request only)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
if not settings.resos_api_key:
|
||||
raise HTTPException(status_code=400, detail="Resos API key not configured")
|
||||
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
hours = await client.get_opening_hours()
|
||||
|
||||
# Log the raw response to understand structure
|
||||
logger.info(f"Raw opening hours from Resos API: {len(hours)} periods")
|
||||
|
||||
# Filter out special/one-off periods - only return regular service periods
|
||||
# Filter on 'special' field: True = one-off events, False = recurring service periods
|
||||
regular_hours = [h for h in hours if h.get('special') == False]
|
||||
|
||||
# Day of week mapping (Resos uses 1=Monday, 7=Sunday)
|
||||
day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
# Transform time format: Resos uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00)
|
||||
# Convert to 'startTime' and 'endTime' in HH:MM format for frontend
|
||||
for hour in regular_hours:
|
||||
# Add day of week name
|
||||
day_num = hour.get('day', 0)
|
||||
if 1 <= day_num <= 7:
|
||||
hour['dayName'] = day_names[day_num]
|
||||
else:
|
||||
hour['dayName'] = 'Unknown'
|
||||
|
||||
if 'open' in hour:
|
||||
open_val = hour['open']
|
||||
hours_part = open_val // 100
|
||||
mins_part = open_val % 100
|
||||
hour['startTime'] = f"{hours_part:02d}:{mins_part:02d}"
|
||||
|
||||
if 'close' in hour:
|
||||
close_val = hour['close']
|
||||
hours_part = close_val // 100
|
||||
mins_part = close_val % 100
|
||||
hour['endTime'] = f"{hours_part:02d}:{mins_part:02d}"
|
||||
|
||||
# Auto-calculate actual end time by subtracting booking duration
|
||||
# Resos extends close time to allow late bookings
|
||||
seating = hour.get('seating', {})
|
||||
duration = seating.get('duration', 0) # Duration in minutes
|
||||
if duration > 0:
|
||||
# Convert close time to minutes
|
||||
close_minutes = hours_part * 60 + mins_part
|
||||
# Subtract booking duration
|
||||
actual_end_minutes = close_minutes - duration
|
||||
# Convert back to HH:MM
|
||||
actual_hours = actual_end_minutes // 60
|
||||
actual_mins = actual_end_minutes % 60
|
||||
hour['actualEnd'] = f"{actual_hours:02d}:{actual_mins:02d}"
|
||||
hour['bookingDuration'] = duration
|
||||
|
||||
# Sort by day of week first, then by open time within each day
|
||||
regular_hours.sort(key=lambda h: (h.get('day', 0), h.get('open', 0)))
|
||||
|
||||
logger.info(f"After filtering: {len(regular_hours)} regular periods (filtered out {len(hours) - len(regular_hours)} special periods)")
|
||||
|
||||
return {"opening_hours": regular_hours}
|
||||
|
||||
|
||||
@router.get("/opening-hours/{date}")
|
||||
async def get_opening_hours_for_date(
|
||||
date: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get opening hours configuration for a specific date
|
||||
|
||||
Returns opening hour periods with their times and intervals.
|
||||
Used by Gantt chart to determine time range and closed periods.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
query_date = datetime.fromisoformat(date).date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
|
||||
# Get kitchen settings
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.resos_api_key:
|
||||
logger.warning("No Resos API key configured")
|
||||
return []
|
||||
|
||||
# Fetch opening hours from Resos API
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
hours = await client.get_opening_hours()
|
||||
|
||||
# Filter to regular (non-special) hours and format for Gantt chart
|
||||
day_of_week = query_date.isoweekday() # Monday=1, Sunday=7
|
||||
|
||||
formatted_hours = []
|
||||
for hour in hours:
|
||||
# Skip special/one-off periods
|
||||
if hour.get('special') == True:
|
||||
continue
|
||||
|
||||
# Check if this opening hour applies to the query date's day of week
|
||||
hour_day = hour.get('day', 0)
|
||||
if hour_day != day_of_week:
|
||||
continue
|
||||
|
||||
# Get open and close times (already in HHMM format from API)
|
||||
open_time = hour.get('open', 0)
|
||||
close_time = hour.get('close', 0)
|
||||
|
||||
# Find service type from mapping
|
||||
resos_id = hour.get('id', '')
|
||||
service_type = None
|
||||
if settings.resos_opening_hours_mapping:
|
||||
for mapping in settings.resos_opening_hours_mapping:
|
||||
if isinstance(mapping, dict) and mapping.get('resos_id') == resos_id:
|
||||
service_type = mapping.get('service_type', '')
|
||||
break
|
||||
|
||||
formatted_hours.append({
|
||||
"name": hour.get('name', ''),
|
||||
"service_type": service_type or hour.get('name', ''),
|
||||
"open": open_time,
|
||||
"close": close_time,
|
||||
"is_special": False
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(formatted_hours)} opening hours for {query_date} (day {day_of_week})")
|
||||
return formatted_hours
|
||||
|
||||
|
||||
# ============ Sync Endpoints ============
|
||||
|
||||
@router.post("/sync/upcoming")
|
||||
async def sync_upcoming(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manual upcoming sync (next 7 days) - triggers immediately and updates last_upcoming_sync timestamp"""
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
result = await sync_service.run_upcoming_sync()
|
||||
logger.info(f"[Resos] Manual upcoming sync completed for kitchen {current_user.kitchen_id}")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync/forecast")
|
||||
async def sync_forecast(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manual forecast sync (next 60 days)"""
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
|
||||
today = date.today()
|
||||
to_date = today + timedelta(days=60)
|
||||
|
||||
result = await sync_service.sync_bookings(today, to_date, is_forecast=True)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync/historical")
|
||||
async def sync_historical(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manual historical sync"""
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
result = await sync_service.sync_bookings(from_date, to_date, is_forecast=False)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync/opening-hours")
|
||||
async def sync_opening_hours(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Sync opening hours from Resos API to local database"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"Starting opening hours sync for kitchen {current_user.kitchen_id}")
|
||||
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
count = await sync_service.sync_opening_hours()
|
||||
|
||||
logger.info(f"Successfully synced {count} opening hours to database")
|
||||
|
||||
return {
|
||||
"message": f"Successfully synced {count} opening hours",
|
||||
"count": count
|
||||
}
|
||||
|
||||
|
||||
# ============ Data Retrieval Endpoints ============
|
||||
|
||||
@router.get("/daily-stats")
|
||||
async def get_daily_stats(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[DailyStatsResponse]:
|
||||
"""Get daily stats for date range"""
|
||||
result = await db.execute(
|
||||
select(ResosDailyStats).where(
|
||||
and_(
|
||||
ResosDailyStats.kitchen_id == current_user.kitchen_id,
|
||||
ResosDailyStats.date >= from_date,
|
||||
ResosDailyStats.date <= to_date
|
||||
)
|
||||
).order_by(ResosDailyStats.date)
|
||||
)
|
||||
|
||||
stats = result.scalars().all()
|
||||
|
||||
return [
|
||||
DailyStatsResponse(
|
||||
date=stat.date.isoformat(),
|
||||
total_bookings=stat.total_bookings,
|
||||
total_covers=stat.total_covers,
|
||||
service_breakdown=stat.service_breakdown or [],
|
||||
flagged_booking_count=stat.flagged_booking_count,
|
||||
unique_flag_types=stat.unique_flag_types,
|
||||
is_forecast=stat.is_forecast
|
||||
)
|
||||
for stat in stats
|
||||
]
|
||||
|
||||
|
||||
@router.get("/bookings/{booking_date}")
|
||||
async def get_bookings_for_date(
|
||||
booking_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[BookingResponse]:
|
||||
"""Get all bookings for a specific date, excluding cancelled/deleted"""
|
||||
excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected']
|
||||
result = await db.execute(
|
||||
select(ResosBooking).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date == booking_date,
|
||||
~func.lower(ResosBooking.status).in_(excluded_statuses)
|
||||
)
|
||||
).order_by(ResosBooking.booking_time)
|
||||
)
|
||||
|
||||
bookings = result.scalars().all()
|
||||
|
||||
return [
|
||||
BookingResponse(
|
||||
id=b.id,
|
||||
resos_booking_id=b.resos_booking_id,
|
||||
booking_date=b.booking_date.isoformat(),
|
||||
booking_time=b.booking_time.isoformat(),
|
||||
people=b.people,
|
||||
status=b.status,
|
||||
seating_area=b.seating_area,
|
||||
hotel_booking_number=b.hotel_booking_number,
|
||||
is_hotel_guest=b.is_hotel_guest,
|
||||
is_dbb=b.is_dbb,
|
||||
is_package=b.is_package,
|
||||
allergies=b.allergies,
|
||||
notes=b.notes,
|
||||
opening_hour_name=b.opening_hour_name,
|
||||
is_flagged=b.is_flagged,
|
||||
flag_reasons=b.flag_reasons
|
||||
)
|
||||
for b in bookings
|
||||
]
|
||||
|
||||
|
||||
# ============ Dashboard Endpoint ============
|
||||
|
||||
@router.get("/dashboard/today-tomorrow")
|
||||
async def get_dashboard_covers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Get today, tomorrow, and day after tomorrow covers for dashboard"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
day_after = today + timedelta(days=2)
|
||||
|
||||
# Day names for display
|
||||
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
result = await db.execute(
|
||||
select(ResosDailyStats).where(
|
||||
and_(
|
||||
ResosDailyStats.kitchen_id == current_user.kitchen_id,
|
||||
ResosDailyStats.date.in_([today, tomorrow, day_after])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
stats = {stat.date: stat for stat in result.scalars().all()}
|
||||
|
||||
def build_response(target_date: date, day_label: str) -> Optional[dict]:
|
||||
if target_date not in stats:
|
||||
return {
|
||||
'date': target_date.isoformat(),
|
||||
'day_label': day_label,
|
||||
'total_bookings': 0,
|
||||
'total_covers': 0,
|
||||
'service_breakdown': [],
|
||||
'has_flagged_bookings': False,
|
||||
'unique_flag_types': []
|
||||
}
|
||||
stat = stats[target_date]
|
||||
logger.info(f"Date {target_date}: unique_flag_types={stat.unique_flag_types}, type={type(stat.unique_flag_types)}, flagged_count={stat.flagged_booking_count}")
|
||||
return {
|
||||
'date': stat.date.isoformat(),
|
||||
'day_label': day_label,
|
||||
'total_bookings': stat.total_bookings,
|
||||
'total_covers': stat.total_covers,
|
||||
'service_breakdown': stat.service_breakdown or [],
|
||||
'has_flagged_bookings': stat.flagged_booking_count > 0,
|
||||
'unique_flag_types': stat.unique_flag_types or []
|
||||
}
|
||||
|
||||
return {
|
||||
'today': build_response(today, 'Today'),
|
||||
'tomorrow': build_response(tomorrow, 'Tomorrow'),
|
||||
'day_after': build_response(day_after, day_names[day_after.weekday()])
|
||||
}
|
||||
|
||||
|
||||
# ============ Stats Report Endpoint (Phase 8) ============
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_bookings_stats(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""
|
||||
Get comprehensive booking statistics with spend analysis (Phase 8).
|
||||
|
||||
Calculates:
|
||||
- Total bookings and covers
|
||||
- Average lead time (days between booking and reservation)
|
||||
- Spend analysis (food/beverage/total) matched from SambaPOS
|
||||
- Resident vs non-resident split (by covers and spend)
|
||||
- Daily breakdown
|
||||
- Service period breakdown with avg spend per cover
|
||||
"""
|
||||
from services.resos_stats import ResosStatsService
|
||||
|
||||
# Get summary metrics from resos_bookings
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(ResosBooking.id).label('total_bookings'),
|
||||
func.sum(ResosBooking.people).label('total_covers'),
|
||||
func.avg(
|
||||
ResosBooking.booking_date - func.cast(ResosBooking.booked_at, Date)
|
||||
).label('avg_lead_time_days')
|
||||
).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= from_date,
|
||||
ResosBooking.booking_date <= to_date,
|
||||
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved'])
|
||||
)
|
||||
)
|
||||
)
|
||||
summary = result.first()
|
||||
|
||||
total_bookings = summary.total_bookings or 0
|
||||
total_covers = summary.total_covers or 0
|
||||
avg_lead_time_days = float(summary.avg_lead_time_days) if summary.avg_lead_time_days else 0.0
|
||||
|
||||
# Get resident/non-resident cover counts
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.sum(case((ResosBooking.is_hotel_guest == True, ResosBooking.people), else_=0)).label('resident_covers'),
|
||||
func.sum(case((ResosBooking.is_hotel_guest == False, ResosBooking.people), else_=0)).label('non_resident_covers')
|
||||
).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= from_date,
|
||||
ResosBooking.booking_date <= to_date,
|
||||
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved'])
|
||||
)
|
||||
)
|
||||
)
|
||||
resident_split = result.first()
|
||||
resident_covers_booking = resident_split.resident_covers or 0
|
||||
non_resident_covers_booking = resident_split.non_resident_covers or 0
|
||||
|
||||
# Get spend statistics (Phase 8 integration)
|
||||
stats_service = ResosStatsService(current_user.kitchen_id, db)
|
||||
spend_stats = await stats_service.get_spend_statistics(from_date, to_date)
|
||||
|
||||
# Calculate resident percentages
|
||||
resident_pct_covers = (resident_covers_booking / total_covers * 100) if total_covers > 0 else 0.0
|
||||
total_spend = spend_stats['total_spend']
|
||||
resident_pct_spend = (spend_stats['resident_spend'] / total_spend * 100) if total_spend > 0 else 0.0
|
||||
|
||||
# Combine booking data with spend data
|
||||
return {
|
||||
'summary': {
|
||||
'total_bookings': total_bookings,
|
||||
'total_covers': total_covers,
|
||||
'avg_lead_time_days': round(avg_lead_time_days, 1),
|
||||
'resident_covers': resident_covers_booking,
|
||||
'non_resident_covers': non_resident_covers_booking,
|
||||
'resident_pct_covers': round(resident_pct_covers, 1),
|
||||
'resident_pct_spend': round(resident_pct_spend, 1)
|
||||
},
|
||||
'spend': {
|
||||
'total_spend': spend_stats['total_spend'],
|
||||
'food_spend': spend_stats['food_spend'],
|
||||
'beverage_spend': spend_stats['beverage_spend'],
|
||||
'resident_spend': spend_stats['resident_spend'],
|
||||
'non_resident_spend': spend_stats['non_resident_spend'],
|
||||
'total_tickets': spend_stats['total_tickets'],
|
||||
'resident_tickets': spend_stats['resident_tickets'],
|
||||
'non_resident_tickets': spend_stats['non_resident_tickets'],
|
||||
'matched_to_resos': spend_stats['matched_to_resos'],
|
||||
'unmatched_to_resos': spend_stats['unmatched_to_resos'],
|
||||
'classification': spend_stats['classification']
|
||||
},
|
||||
'daily_breakdown': spend_stats['daily_breakdown'],
|
||||
'service_period_breakdown': spend_stats['service_period_breakdown'],
|
||||
'daily_service_breakdown': spend_stats['daily_service_breakdown']
|
||||
}
|
||||
|
||||
|
||||
# ============ Resident Covers for Budget ============
|
||||
|
||||
@router.get("/resident-covers")
|
||||
async def get_resident_covers(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""
|
||||
Get per-date, per-service-period hotel guest (resident) cover counts.
|
||||
Used by the Budget page to show 'inc Residents' row in the forecast table.
|
||||
"""
|
||||
excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected']
|
||||
|
||||
# Query bookings grouped by date and opening_hour
|
||||
result = await db.execute(
|
||||
select(
|
||||
ResosBooking.booking_date,
|
||||
ResosBooking.opening_hour_id,
|
||||
ResosBooking.opening_hour_name,
|
||||
func.sum(ResosBooking.people).label('resident_covers'),
|
||||
func.count(ResosBooking.id).label('resident_bookings'),
|
||||
).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= start_date,
|
||||
ResosBooking.booking_date <= end_date,
|
||||
ResosBooking.is_hotel_guest == True,
|
||||
~func.lower(ResosBooking.status).in_(excluded_statuses)
|
||||
)
|
||||
).group_by(
|
||||
ResosBooking.booking_date,
|
||||
ResosBooking.opening_hour_id,
|
||||
ResosBooking.opening_hour_name
|
||||
)
|
||||
)
|
||||
|
||||
# Get kitchen settings for service type mapping
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None
|
||||
|
||||
service_type_map = {}
|
||||
if opening_hours_mapping:
|
||||
for mapping in opening_hours_mapping:
|
||||
if isinstance(mapping, dict):
|
||||
resos_id = mapping.get('resos_id', '')
|
||||
service_type = mapping.get('service_type', '')
|
||||
if resos_id and service_type:
|
||||
service_type_map[resos_id] = service_type
|
||||
|
||||
# Build per-date, per-period response with covers and booking counts
|
||||
dates: dict[str, dict[str, dict[str, int]]] = {}
|
||||
for row in result:
|
||||
date_str = row.booking_date.isoformat()
|
||||
if date_str not in dates:
|
||||
dates[date_str] = {}
|
||||
|
||||
opening_hour_id = row.opening_hour_id
|
||||
opening_hour_name = row.opening_hour_name or 'Unknown'
|
||||
service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name
|
||||
period = service_type.lower() if service_type else 'unknown'
|
||||
|
||||
# Accumulate in case multiple opening hours map to same period
|
||||
if period not in dates[date_str]:
|
||||
dates[date_str][period] = {"covers": 0, "bookings": 0}
|
||||
dates[date_str][period]["covers"] += row.resident_covers or 0
|
||||
dates[date_str][period]["bookings"] += row.resident_bookings or 0
|
||||
|
||||
return {"dates": dates}
|
||||
690
backend/api/sambapos.py
Normal file
690
backend/api/sambapos.py
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
"""
|
||||
SambaPOS API Endpoints
|
||||
|
||||
Handles SambaPOS MSSQL configuration and category management.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
from services.sambapos_api import SambaPOSClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class SambaPOSSettingsResponse(BaseModel):
|
||||
sambapos_db_host: str | None
|
||||
sambapos_db_port: int | None
|
||||
sambapos_db_name: str | None
|
||||
sambapos_db_username: str | None
|
||||
sambapos_db_password_set: bool
|
||||
sambapos_tracked_categories: list[str]
|
||||
sambapos_excluded_items: list[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SambaPOSSettingsUpdate(BaseModel):
|
||||
sambapos_db_host: str | None = None
|
||||
sambapos_db_port: int | None = None
|
||||
sambapos_db_name: str | None = None
|
||||
sambapos_db_username: str | None = None
|
||||
sambapos_db_password: str | None = None
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class MenuItemResponse(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
|
||||
|
||||
class TrackedCategoriesUpdate(BaseModel):
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class ExcludedItemsUpdate(BaseModel):
|
||||
items: list[str]
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings", response_model=SambaPOSSettingsResponse)
|
||||
async def get_sambapos_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get SambaPOS connection settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse tracked categories from comma-separated string
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
# Parse excluded items from comma-separated string
|
||||
excluded_items = []
|
||||
if settings.sambapos_excluded_items:
|
||||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||||
|
||||
return SambaPOSSettingsResponse(
|
||||
sambapos_db_host=settings.sambapos_db_host,
|
||||
sambapos_db_port=settings.sambapos_db_port,
|
||||
sambapos_db_name=settings.sambapos_db_name,
|
||||
sambapos_db_username=settings.sambapos_db_username,
|
||||
sambapos_db_password_set=bool(settings.sambapos_db_password),
|
||||
sambapos_tracked_categories=tracked_categories,
|
||||
sambapos_excluded_items=excluded_items
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SambaPOSSettingsResponse)
|
||||
async def update_sambapos_settings(
|
||||
update: SambaPOSSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update SambaPOS connection settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
# Parse tracked categories from comma-separated string
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
# Parse excluded items from pipe-separated string
|
||||
excluded_items = []
|
||||
if settings.sambapos_excluded_items:
|
||||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||||
|
||||
return SambaPOSSettingsResponse(
|
||||
sambapos_db_host=settings.sambapos_db_host,
|
||||
sambapos_db_port=settings.sambapos_db_port,
|
||||
sambapos_db_name=settings.sambapos_db_name,
|
||||
sambapos_db_username=settings.sambapos_db_username,
|
||||
sambapos_db_password_set=bool(settings.sambapos_db_password),
|
||||
sambapos_tracked_categories=tracked_categories,
|
||||
sambapos_excluded_items=excluded_items
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_sambapos_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test SambaPOS database connection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
result = await client.test_connection()
|
||||
|
||||
if result["success"]:
|
||||
return {"status": "success", "message": "SambaPOS connection successful"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}")
|
||||
|
||||
|
||||
# ============ Categories Endpoints ============
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryResponse])
|
||||
async def get_sambapos_categories(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all menu categories from SambaPOS database"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
categories = await client.get_categories()
|
||||
return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tracked-categories")
|
||||
async def get_tracked_categories(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of category names enabled for top sellers"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse tracked categories from comma-separated string
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
return {"categories": tracked_categories}
|
||||
|
||||
|
||||
@router.patch("/tracked-categories")
|
||||
async def update_tracked_categories(
|
||||
update: TrackedCategoriesUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update which categories are included in top sellers"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Store as comma-separated string
|
||||
settings.sambapos_tracked_categories = ','.join(update.categories)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "categories": update.categories}
|
||||
|
||||
|
||||
@router.get("/debug/menuitems")
|
||||
async def debug_menuitems(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to explore MenuItems table structure"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.sambapos_db_password:
|
||||
raise HTTPException(status_code=400, detail="SambaPOS not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_menu_items()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ============ Menu Items Endpoints ============
|
||||
|
||||
@router.get("/menu-items", response_model=list[MenuItemResponse])
|
||||
async def get_menu_items(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all unique menu item names with their categories for exclusion selection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
# Get tracked categories to filter menu items
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None)
|
||||
return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}")
|
||||
|
||||
|
||||
class MenuItemWithPortionResponse(BaseModel):
|
||||
menu_item_name: str
|
||||
portion_name: str
|
||||
category: str
|
||||
on_pos_menu: bool
|
||||
|
||||
|
||||
@router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse])
|
||||
async def get_menu_items_with_portions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all menu items with their portion names, grouped by Kitchen Course category."""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
# Get tracked categories to filter
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
items = await client.get_menu_items_with_portions(
|
||||
categories=tracked_categories if tracked_categories else None
|
||||
)
|
||||
return [
|
||||
MenuItemWithPortionResponse(
|
||||
menu_item_name=item["menu_item_name"],
|
||||
portion_name=item["portion_name"],
|
||||
category=item["category"],
|
||||
on_pos_menu=item.get("on_pos_menu", False)
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/excluded-items")
|
||||
async def get_excluded_items(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of menu item names excluded from top sellers report"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse excluded items from pipe-separated string
|
||||
excluded_items = []
|
||||
if settings.sambapos_excluded_items:
|
||||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||||
|
||||
return {"items": excluded_items}
|
||||
|
||||
|
||||
@router.patch("/excluded-items")
|
||||
async def update_excluded_items(
|
||||
update: ExcludedItemsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update which menu item GroupCodes are excluded from top sellers report"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Store as pipe-separated string (to allow commas in names)
|
||||
settings.sambapos_excluded_items = '|'.join(update.items)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "items": update.items}
|
||||
|
||||
|
||||
# ============ Group Codes Endpoints ============
|
||||
|
||||
class GroupCodeResponse(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.get("/group-codes", response_model=list[GroupCodeResponse])
|
||||
async def get_group_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all distinct GroupCode values from MenuItems table for exclusion selection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
group_codes = await client.get_menu_group_codes()
|
||||
return [GroupCodeResponse(name=gc["name"]) for gc in group_codes]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}")
|
||||
|
||||
|
||||
# ============ GL Codes Endpoints (Phase 8) ============
|
||||
|
||||
class GLCodeResponse(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class GLCodesUpdate(BaseModel):
|
||||
food_codes: list[str]
|
||||
beverage_codes: list[str]
|
||||
|
||||
|
||||
@router.get("/gl-codes", response_model=list[GLCodeResponse])
|
||||
async def get_gl_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all unique GL codes from ProductTag custom tags for food/beverage classification"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
gl_codes = await client.get_gl_codes()
|
||||
return [GLCodeResponse(code=gc["code"]) for gc in gl_codes]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/gl-codes/selected")
|
||||
async def get_selected_gl_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get currently selected food and beverage GL codes"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse food GL codes from comma-separated string
|
||||
food_codes = []
|
||||
if settings.sambapos_food_gl_codes:
|
||||
food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()]
|
||||
|
||||
# Parse beverage GL codes from comma-separated string
|
||||
beverage_codes = []
|
||||
if settings.sambapos_beverage_gl_codes:
|
||||
beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()]
|
||||
|
||||
return {
|
||||
"food_codes": food_codes,
|
||||
"beverage_codes": beverage_codes
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/gl-codes")
|
||||
async def update_gl_codes(
|
||||
update: GLCodesUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update food and beverage GL code selections"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Store as comma-separated strings
|
||||
settings.sambapos_food_gl_codes = ','.join(update.food_codes)
|
||||
settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"food_codes": update.food_codes,
|
||||
"beverage_codes": update.beverage_codes
|
||||
}
|
||||
|
||||
|
||||
@router.get("/debug/custom-tags")
|
||||
async def debug_custom_tags(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to see sample CustomTags from MenuItems"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_menu_items()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/debug/zero-price-order-states")
|
||||
async def debug_zero_price_order_states(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Debug endpoint: returns sample OrderStates blobs for zero-priced orders.
|
||||
Use this to discover the JSON structure and identify where original/package price is stored.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.sambapos_db_password:
|
||||
raise HTTPException(status_code=400, detail="SambaPOS not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_zero_price_order_states()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/debug/table-schema")
|
||||
async def debug_table_schema(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to inspect column names in SambaPOS tables"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_table_schema()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}")
|
||||
744
backend/api/search.py
Normal file
744
backend/api/search.py
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
"""
|
||||
Search API endpoints for searching invoices, line items, and product definitions.
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.invoice import Invoice, InvoiceStatus
|
||||
from models.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.product_definition import ProductDefinition
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
from services.price_history import PriceHistoryService
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
# ============ Response Models ============
|
||||
|
||||
class GroupSummary(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
total: Optional[Decimal] = None
|
||||
|
||||
|
||||
class InvoiceSearchItem(BaseModel):
|
||||
id: int
|
||||
invoice_number: Optional[str]
|
||||
invoice_date: Optional[date]
|
||||
total: Optional[Decimal]
|
||||
net_total: Optional[Decimal]
|
||||
supplier_id: Optional[int]
|
||||
supplier_name: Optional[str]
|
||||
vendor_name: Optional[str]
|
||||
status: str
|
||||
document_type: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InvoiceSearchResponse(BaseModel):
|
||||
items: List[InvoiceSearchItem]
|
||||
total_count: int
|
||||
grouped_by: Optional[str]
|
||||
groups: Optional[List[GroupSummary]]
|
||||
|
||||
|
||||
class LineItemSearchItem(BaseModel):
|
||||
product_code: Optional[str]
|
||||
description: Optional[str]
|
||||
supplier_id: Optional[int]
|
||||
supplier_name: Optional[str]
|
||||
unit: Optional[str]
|
||||
most_recent_price: Optional[Decimal]
|
||||
earliest_price_in_period: Optional[Decimal]
|
||||
price_change_percent: Optional[float]
|
||||
price_change_status: str
|
||||
total_quantity: Optional[Decimal]
|
||||
occurrence_count: int
|
||||
most_recent_invoice_id: Optional[int]
|
||||
most_recent_invoice_number: Optional[str]
|
||||
most_recent_date: Optional[date]
|
||||
has_definition: bool
|
||||
portions_per_unit: Optional[int]
|
||||
pack_quantity: Optional[int]
|
||||
most_recent_line_item_id: Optional[int] = None
|
||||
most_recent_line_number: Optional[int] = None
|
||||
most_recent_raw_content: Optional[str] = None
|
||||
most_recent_pack_quantity: Optional[int] = None
|
||||
most_recent_unit_size: Optional[Decimal] = None
|
||||
most_recent_unit_size_type: Optional[str] = None
|
||||
# Ingredient mapping info
|
||||
ingredient_id: Optional[int] = None
|
||||
ingredient_name: Optional[str] = None
|
||||
ingredient_standard_unit: Optional[str] = None
|
||||
price_per_std_unit: Optional[Decimal] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LineItemSearchResponse(BaseModel):
|
||||
items: List[LineItemSearchItem]
|
||||
total_count: int
|
||||
grouped_by: Optional[str]
|
||||
groups: Optional[List[GroupSummary]]
|
||||
|
||||
|
||||
class DefinitionSearchItem(BaseModel):
|
||||
id: int
|
||||
product_code: Optional[str]
|
||||
description_pattern: Optional[str]
|
||||
supplier_id: Optional[int]
|
||||
supplier_name: Optional[str]
|
||||
pack_quantity: Optional[int]
|
||||
unit_size: Optional[Decimal]
|
||||
unit_size_type: Optional[str]
|
||||
portions_per_unit: Optional[int]
|
||||
portion_description: Optional[str]
|
||||
source_invoice_id: Optional[int]
|
||||
source_invoice_number: Optional[str]
|
||||
most_recent_price: Optional[Decimal]
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DefinitionSearchResponse(BaseModel):
|
||||
items: List[DefinitionSearchItem]
|
||||
total_count: int
|
||||
|
||||
|
||||
class PriceHistoryPointResponse(BaseModel):
|
||||
date: date
|
||||
price: Decimal
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
quantity: Optional[Decimal]
|
||||
|
||||
|
||||
class LineItemHistoryResponse(BaseModel):
|
||||
product_code: Optional[str]
|
||||
description: Optional[str]
|
||||
supplier_id: int
|
||||
supplier_name: Optional[str]
|
||||
price_history: List[PriceHistoryPointResponse]
|
||||
total_occurrences: int
|
||||
total_quantity: Decimal
|
||||
avg_qty_per_invoice: Decimal
|
||||
avg_qty_per_week: Decimal
|
||||
avg_qty_per_month: Decimal
|
||||
current_price: Optional[Decimal]
|
||||
price_change_status: str
|
||||
|
||||
|
||||
class AcknowledgePriceRequest(BaseModel):
|
||||
product_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
supplier_id: int
|
||||
new_price: Decimal
|
||||
source_invoice_id: Optional[int] = None
|
||||
source_line_item_id: Optional[int] = None
|
||||
|
||||
|
||||
class AcknowledgePriceResponse(BaseModel):
|
||||
id: int
|
||||
acknowledged_price: Decimal
|
||||
acknowledged_at: datetime
|
||||
|
||||
|
||||
# ============ Invoice Search ============
|
||||
|
||||
@router.get("/invoices", response_model=InvoiceSearchResponse)
|
||||
async def search_invoices(
|
||||
q: str = "",
|
||||
include_line_items: bool = False,
|
||||
supplier_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
group_by: Optional[str] = None,
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search invoices with optional filters.
|
||||
|
||||
- q: Search term (invoice_number, vendor_name)
|
||||
- include_line_items: Also search line item product_code/description
|
||||
- supplier_id: Filter by supplier
|
||||
- status: Filter by status (pending, confirmed, etc.)
|
||||
- date_from/date_to: Date range (default: last 30 days)
|
||||
- group_by: "supplier" or "month" for grouped results
|
||||
"""
|
||||
# Default date range: last 30 days
|
||||
if date_to is None:
|
||||
date_to = date.today()
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=30)
|
||||
|
||||
# Build base conditions
|
||||
conditions = [
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
Invoice.invoice_date >= date_from,
|
||||
Invoice.invoice_date <= date_to,
|
||||
]
|
||||
|
||||
if supplier_id:
|
||||
conditions.append(Invoice.supplier_id == supplier_id)
|
||||
|
||||
if status:
|
||||
conditions.append(Invoice.status == status)
|
||||
|
||||
# Search filter
|
||||
if q:
|
||||
search_pattern = f"%{q}%"
|
||||
search_conditions = [
|
||||
Invoice.invoice_number.ilike(search_pattern),
|
||||
Invoice.vendor_name.ilike(search_pattern),
|
||||
]
|
||||
|
||||
if include_line_items:
|
||||
# Need to join line items and search there too
|
||||
line_item_subquery = (
|
||||
select(LineItem.invoice_id)
|
||||
.where(or_(
|
||||
LineItem.product_code.ilike(search_pattern),
|
||||
LineItem.description.ilike(search_pattern)
|
||||
))
|
||||
.distinct()
|
||||
)
|
||||
search_conditions.append(Invoice.id.in_(line_item_subquery))
|
||||
|
||||
conditions.append(or_(*search_conditions))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count(Invoice.id)).where(and_(*conditions))
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Get invoices with supplier name
|
||||
query = (
|
||||
select(Invoice, Supplier.name.label('supplier_name'))
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = [
|
||||
InvoiceSearchItem(
|
||||
id=row.Invoice.id,
|
||||
invoice_number=row.Invoice.invoice_number,
|
||||
invoice_date=row.Invoice.invoice_date,
|
||||
total=row.Invoice.total,
|
||||
net_total=row.Invoice.net_total,
|
||||
supplier_id=row.Invoice.supplier_id,
|
||||
supplier_name=row.supplier_name,
|
||||
vendor_name=row.Invoice.vendor_name,
|
||||
status=row.Invoice.status.value if isinstance(row.Invoice.status, InvoiceStatus) else row.Invoice.status,
|
||||
document_type=row.Invoice.document_type
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Handle grouping
|
||||
groups = None
|
||||
if group_by == "supplier":
|
||||
group_query = (
|
||||
select(
|
||||
Supplier.name,
|
||||
func.count(Invoice.id).label('count'),
|
||||
func.sum(Invoice.net_total).label('total')
|
||||
)
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(and_(*conditions))
|
||||
.group_by(Supplier.name)
|
||||
.order_by(desc('total'))
|
||||
)
|
||||
group_result = await db.execute(group_query)
|
||||
groups = [
|
||||
GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2])
|
||||
for row in group_result.fetchall()
|
||||
]
|
||||
elif group_by == "month":
|
||||
group_query = (
|
||||
select(
|
||||
func.to_char(Invoice.invoice_date, 'YYYY-MM').label('month'),
|
||||
func.count(Invoice.id).label('count'),
|
||||
func.sum(Invoice.net_total).label('total')
|
||||
)
|
||||
.where(and_(*conditions))
|
||||
.group_by(func.to_char(Invoice.invoice_date, 'YYYY-MM'))
|
||||
.order_by(desc('month'))
|
||||
)
|
||||
group_result = await db.execute(group_query)
|
||||
groups = [
|
||||
GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2])
|
||||
for row in group_result.fetchall()
|
||||
]
|
||||
|
||||
return InvoiceSearchResponse(
|
||||
items=items,
|
||||
total_count=total_count,
|
||||
grouped_by=group_by,
|
||||
groups=groups
|
||||
)
|
||||
|
||||
|
||||
# ============ Line Items Search (Consolidated) ============
|
||||
|
||||
@router.get("/line-items", response_model=LineItemSearchResponse)
|
||||
async def search_line_items(
|
||||
q: str = "",
|
||||
supplier_id: Optional[int] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
group_by: Optional[str] = None,
|
||||
mapped: Optional[str] = Query(default=None, description="Filter by ingredient mapping: 'yes', 'no'"),
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search line items with consolidation.
|
||||
|
||||
Returns DISTINCT line items by (product_code OR description + supplier),
|
||||
with most recent price, price change status, total quantity, occurrence count.
|
||||
"""
|
||||
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
||||
|
||||
items_data, total_count = await price_service.get_consolidated_line_items(
|
||||
search_query=q if q else None,
|
||||
supplier_id=supplier_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
items = [LineItemSearchItem(**item) for item in items_data]
|
||||
|
||||
# Filter by ingredient mapping status
|
||||
if mapped == 'yes':
|
||||
items = [i for i in items if i.ingredient_id is not None]
|
||||
total_count = len(items)
|
||||
elif mapped == 'no':
|
||||
items = [i for i in items if i.ingredient_id is None]
|
||||
total_count = len(items)
|
||||
|
||||
# Handle grouping (for UI display)
|
||||
groups = None
|
||||
if group_by == "supplier":
|
||||
# Group items by supplier
|
||||
supplier_groups = {}
|
||||
for item in items:
|
||||
name = item.supplier_name or "Unknown"
|
||||
if name not in supplier_groups:
|
||||
supplier_groups[name] = {"count": 0, "total": Decimal(0)}
|
||||
supplier_groups[name]["count"] += item.occurrence_count
|
||||
if item.total_quantity:
|
||||
supplier_groups[name]["total"] += item.total_quantity
|
||||
|
||||
groups = [
|
||||
GroupSummary(name=name, count=data["count"], total=data["total"])
|
||||
for name, data in sorted(supplier_groups.items(), key=lambda x: -x[1]["count"])
|
||||
]
|
||||
|
||||
return LineItemSearchResponse(
|
||||
items=items,
|
||||
total_count=total_count,
|
||||
grouped_by=group_by,
|
||||
groups=groups
|
||||
)
|
||||
|
||||
|
||||
# ============ Definitions Search ============
|
||||
|
||||
@router.get("/definitions", response_model=DefinitionSearchResponse)
|
||||
async def search_definitions(
|
||||
q: str = "",
|
||||
supplier_id: Optional[int] = None,
|
||||
has_portions: Optional[bool] = None,
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search product definitions.
|
||||
|
||||
- q: Search term (product_code, description_pattern)
|
||||
- supplier_id: Filter by supplier
|
||||
- has_portions: Filter by whether portions_per_unit is set
|
||||
"""
|
||||
conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id]
|
||||
|
||||
if supplier_id:
|
||||
conditions.append(ProductDefinition.supplier_id == supplier_id)
|
||||
|
||||
if q:
|
||||
search_pattern = f"%{q}%"
|
||||
conditions.append(or_(
|
||||
ProductDefinition.product_code.ilike(search_pattern),
|
||||
ProductDefinition.description_pattern.ilike(search_pattern)
|
||||
))
|
||||
|
||||
if has_portions is not None:
|
||||
if has_portions:
|
||||
conditions.append(ProductDefinition.portions_per_unit.isnot(None))
|
||||
else:
|
||||
conditions.append(ProductDefinition.portions_per_unit.is_(None))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count(ProductDefinition.id)).where(and_(*conditions))
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Get definitions with supplier name and source invoice number
|
||||
query = (
|
||||
select(
|
||||
ProductDefinition,
|
||||
Supplier.name.label('supplier_name'),
|
||||
Invoice.invoice_number.label('source_invoice_number')
|
||||
)
|
||||
.outerjoin(Supplier, ProductDefinition.supplier_id == Supplier.id)
|
||||
.outerjoin(Invoice, ProductDefinition.source_invoice_id == Invoice.id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(ProductDefinition.updated_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
definition = row.ProductDefinition
|
||||
|
||||
# Get most recent price from matching line items
|
||||
most_recent_price = None
|
||||
price_conditions = [Invoice.kitchen_id == current_user.kitchen_id]
|
||||
|
||||
if definition.supplier_id:
|
||||
price_conditions.append(Invoice.supplier_id == definition.supplier_id)
|
||||
|
||||
if definition.product_code:
|
||||
price_conditions.append(LineItem.product_code == definition.product_code)
|
||||
elif definition.description_pattern:
|
||||
price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%"))
|
||||
|
||||
if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id
|
||||
price_query = (
|
||||
select(LineItem.unit_price)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.where(and_(*price_conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(1)
|
||||
)
|
||||
price_result = await db.execute(price_query)
|
||||
price_row = price_result.scalar_one_or_none()
|
||||
if price_row is not None:
|
||||
most_recent_price = price_row
|
||||
|
||||
items.append(DefinitionSearchItem(
|
||||
id=definition.id,
|
||||
product_code=definition.product_code,
|
||||
description_pattern=definition.description_pattern,
|
||||
supplier_id=definition.supplier_id,
|
||||
supplier_name=row.supplier_name,
|
||||
pack_quantity=definition.pack_quantity,
|
||||
unit_size=definition.unit_size,
|
||||
unit_size_type=definition.unit_size_type,
|
||||
portions_per_unit=definition.portions_per_unit,
|
||||
portion_description=definition.portion_description,
|
||||
source_invoice_id=definition.source_invoice_id,
|
||||
source_invoice_number=row.source_invoice_number,
|
||||
most_recent_price=most_recent_price,
|
||||
updated_at=definition.updated_at
|
||||
))
|
||||
|
||||
return DefinitionSearchResponse(items=items, total_count=total_count)
|
||||
|
||||
|
||||
# ============ Line Item History ============
|
||||
|
||||
@router.get("/line-items/history", response_model=LineItemHistoryResponse)
|
||||
async def get_line_item_history(
|
||||
supplier_id: int,
|
||||
product_code: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get price and quantity history for a specific line item.
|
||||
|
||||
Used by the history modal to show price chart and stats.
|
||||
"""
|
||||
if not product_code and not description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either product_code or description is required"
|
||||
)
|
||||
|
||||
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
||||
history = await price_service.get_history(
|
||||
supplier_id=supplier_id,
|
||||
product_code=product_code,
|
||||
description=description,
|
||||
unit=unit,
|
||||
date_from=date_from,
|
||||
date_to=date_to
|
||||
)
|
||||
|
||||
return LineItemHistoryResponse(
|
||||
product_code=history.product_code,
|
||||
description=history.description,
|
||||
supplier_id=history.supplier_id,
|
||||
supplier_name=history.supplier_name,
|
||||
price_history=[
|
||||
PriceHistoryPointResponse(
|
||||
date=point.date,
|
||||
price=point.price,
|
||||
invoice_id=point.invoice_id,
|
||||
invoice_number=point.invoice_number,
|
||||
quantity=point.quantity
|
||||
)
|
||||
for point in history.price_history
|
||||
],
|
||||
total_occurrences=history.total_occurrences,
|
||||
total_quantity=history.total_quantity,
|
||||
avg_qty_per_invoice=history.avg_qty_per_invoice,
|
||||
avg_qty_per_week=history.avg_qty_per_week,
|
||||
avg_qty_per_month=history.avg_qty_per_month,
|
||||
current_price=history.current_price,
|
||||
price_change_status=history.price_change_status
|
||||
)
|
||||
|
||||
|
||||
# ============ Price Acknowledgement ============
|
||||
|
||||
@router.post("/line-items/acknowledge-price", response_model=AcknowledgePriceResponse)
|
||||
async def acknowledge_price_change(
|
||||
request: AcknowledgePriceRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Acknowledge a price change for a line item.
|
||||
|
||||
Creates or updates the AcknowledgedPrice record so the price
|
||||
won't be flagged as changed in future.
|
||||
"""
|
||||
if not request.product_code and not request.description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either product_code or description is required"
|
||||
)
|
||||
|
||||
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
||||
acknowledged = await price_service.acknowledge_price(
|
||||
user_id=current_user.id,
|
||||
supplier_id=request.supplier_id,
|
||||
product_code=request.product_code,
|
||||
description=request.description,
|
||||
new_price=request.new_price,
|
||||
source_invoice_id=request.source_invoice_id,
|
||||
source_line_item_id=request.source_line_item_id
|
||||
)
|
||||
|
||||
return AcknowledgePriceResponse(
|
||||
id=acknowledged.id,
|
||||
acknowledged_price=acknowledged.acknowledged_price,
|
||||
acknowledged_at=acknowledged.acknowledged_at
|
||||
)
|
||||
|
||||
|
||||
# ============ Search Settings ============
|
||||
|
||||
class SearchSettingsResponse(BaseModel):
|
||||
price_change_lookback_days: int
|
||||
price_change_amber_threshold: int
|
||||
price_change_red_threshold: int
|
||||
|
||||
|
||||
class SearchSettingsUpdate(BaseModel):
|
||||
price_change_lookback_days: Optional[int] = None
|
||||
price_change_amber_threshold: Optional[int] = None
|
||||
price_change_red_threshold: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SearchSettingsResponse)
|
||||
async def get_search_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get search/price change settings."""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
return SearchSettingsResponse(
|
||||
price_change_lookback_days=settings.price_change_lookback_days if settings else 30,
|
||||
price_change_amber_threshold=settings.price_change_amber_threshold if settings else 10,
|
||||
price_change_red_threshold=settings.price_change_red_threshold if settings else 20
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SearchSettingsResponse)
|
||||
async def update_search_settings(
|
||||
update: SearchSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update search/price change settings."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return SearchSettingsResponse(
|
||||
price_change_lookback_days=settings.price_change_lookback_days,
|
||||
price_change_amber_threshold=settings.price_change_amber_threshold,
|
||||
price_change_red_threshold=settings.price_change_red_threshold
|
||||
)
|
||||
|
||||
|
||||
# ============ Definition Update ============
|
||||
|
||||
class DefinitionUpdateRequest(BaseModel):
|
||||
pack_quantity: Optional[int] = None
|
||||
unit_size: Optional[Decimal] = None
|
||||
unit_size_type: Optional[str] = None
|
||||
portions_per_unit: Optional[int] = None
|
||||
portion_description: Optional[str] = None
|
||||
|
||||
|
||||
@router.patch("/definitions/{definition_id}", response_model=DefinitionSearchItem)
|
||||
async def update_definition(
|
||||
definition_id: int,
|
||||
update: DefinitionUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a product definition."""
|
||||
# Fetch the definition
|
||||
result = await db.execute(
|
||||
select(ProductDefinition).where(
|
||||
ProductDefinition.id == definition_id,
|
||||
ProductDefinition.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
definition = result.scalar_one_or_none()
|
||||
|
||||
if not definition:
|
||||
raise HTTPException(status_code=404, detail="Definition not found")
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(definition, field, value)
|
||||
|
||||
# Update saved_by metadata
|
||||
definition.saved_by_user_id = current_user.id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(definition)
|
||||
|
||||
# Get supplier name for response
|
||||
supplier_name = None
|
||||
if definition.supplier_id:
|
||||
supplier_result = await db.execute(
|
||||
select(Supplier.name).where(Supplier.id == definition.supplier_id)
|
||||
)
|
||||
supplier_name = supplier_result.scalar_one_or_none()
|
||||
|
||||
# Get source invoice number
|
||||
source_invoice_number = None
|
||||
if definition.source_invoice_id:
|
||||
invoice_result = await db.execute(
|
||||
select(Invoice.invoice_number).where(Invoice.id == definition.source_invoice_id)
|
||||
)
|
||||
source_invoice_number = invoice_result.scalar_one_or_none()
|
||||
|
||||
# Get most recent price
|
||||
most_recent_price = None
|
||||
price_conditions = [Invoice.kitchen_id == current_user.kitchen_id]
|
||||
|
||||
if definition.supplier_id:
|
||||
price_conditions.append(Invoice.supplier_id == definition.supplier_id)
|
||||
|
||||
if definition.product_code:
|
||||
price_conditions.append(LineItem.product_code == definition.product_code)
|
||||
elif definition.description_pattern:
|
||||
price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%"))
|
||||
|
||||
if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id
|
||||
price_query = (
|
||||
select(LineItem.unit_price)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.where(and_(*price_conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(1)
|
||||
)
|
||||
price_result = await db.execute(price_query)
|
||||
price_row = price_result.scalar_one_or_none()
|
||||
if price_row is not None:
|
||||
most_recent_price = price_row
|
||||
|
||||
return DefinitionSearchItem(
|
||||
id=definition.id,
|
||||
product_code=definition.product_code,
|
||||
description_pattern=definition.description_pattern,
|
||||
supplier_id=definition.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
pack_quantity=definition.pack_quantity,
|
||||
unit_size=definition.unit_size,
|
||||
unit_size_type=definition.unit_size_type,
|
||||
portions_per_unit=definition.portions_per_unit,
|
||||
portion_description=definition.portion_description,
|
||||
source_invoice_id=definition.source_invoice_id,
|
||||
source_invoice_number=source_invoice_number,
|
||||
most_recent_price=most_recent_price,
|
||||
updated_at=definition.updated_at
|
||||
)
|
||||
798
backend/api/settings.py
Normal file
798
backend/api/settings.py
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
azure_endpoint: str | None
|
||||
azure_key_set: bool # Don't expose the actual key, just whether it's set
|
||||
currency_symbol: str
|
||||
date_format: str
|
||||
high_quantity_threshold: int
|
||||
# SMTP settings
|
||||
smtp_host: str | None
|
||||
smtp_port: int | None
|
||||
smtp_username: str | None
|
||||
smtp_password_set: bool # Don't expose the actual password
|
||||
smtp_use_tls: bool
|
||||
smtp_from_email: str | None
|
||||
smtp_from_name: str | None
|
||||
support_email: str | None
|
||||
# Dext integration
|
||||
dext_email: str | None
|
||||
dext_include_notes: bool
|
||||
dext_include_non_stock: bool
|
||||
dext_auto_send_enabled: bool
|
||||
dext_manual_send_enabled: bool
|
||||
dext_include_annotations: bool
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled: bool
|
||||
pdf_preview_show_annotations: bool
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes: bool
|
||||
ocr_filter_subtotal_rows: bool
|
||||
ocr_use_weight_as_quantity: bool
|
||||
# Cost distribution settings
|
||||
cost_distribution_max_days: int
|
||||
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled: bool = False
|
||||
anthropic_api_key_set: bool = False # Don't expose the actual key
|
||||
llm_model: str | None = None
|
||||
llm_confidence_threshold: float | None = None
|
||||
llm_monthly_token_limit: int = 500000
|
||||
llm_features_enabled: dict | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
azure_endpoint: str | None = None
|
||||
azure_key: str | None = None
|
||||
currency_symbol: str | None = None
|
||||
date_format: str | None = None
|
||||
high_quantity_threshold: int | None = None
|
||||
# SMTP settings
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None # Only set if provided
|
||||
smtp_use_tls: bool | None = None
|
||||
smtp_from_email: str | None = None
|
||||
smtp_from_name: str | None = None
|
||||
support_email: str | None = None
|
||||
# Dext integration
|
||||
dext_email: str | None = None
|
||||
dext_include_notes: bool | None = None
|
||||
dext_include_non_stock: bool | None = None
|
||||
dext_auto_send_enabled: bool | None = None
|
||||
dext_manual_send_enabled: bool | None = None
|
||||
dext_include_annotations: bool | None = None
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled: bool | None = None
|
||||
pdf_preview_show_annotations: bool | None = None
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes: bool | None = None
|
||||
ocr_filter_subtotal_rows: bool | None = None
|
||||
ocr_use_weight_as_quantity: bool | None = None
|
||||
# Cost distribution settings
|
||||
cost_distribution_max_days: int | None = None
|
||||
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled: bool | None = None
|
||||
anthropic_api_key: str | None = None # Only set if provided
|
||||
llm_model: str | None = None
|
||||
llm_confidence_threshold: float | None = None
|
||||
llm_monthly_token_limit: int | None = None
|
||||
llm_features_enabled: dict | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=SettingsResponse)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current kitchen settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
# Create default settings if none exist
|
||||
settings = KitchenSettings(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
currency_symbol="£",
|
||||
date_format="DD/MM/YYYY"
|
||||
)
|
||||
db.add(settings)
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return _build_settings_response(settings)
|
||||
|
||||
|
||||
def _build_settings_response(settings: KitchenSettings) -> SettingsResponse:
|
||||
"""Build SettingsResponse from a KitchenSettings model instance."""
|
||||
return SettingsResponse(
|
||||
azure_endpoint=settings.azure_endpoint,
|
||||
azure_key_set=bool(settings.azure_key),
|
||||
currency_symbol=settings.currency_symbol,
|
||||
date_format=settings.date_format,
|
||||
high_quantity_threshold=settings.high_quantity_threshold,
|
||||
# SMTP settings
|
||||
smtp_host=settings.smtp_host,
|
||||
smtp_port=settings.smtp_port,
|
||||
smtp_username=settings.smtp_username,
|
||||
smtp_password_set=bool(settings.smtp_password),
|
||||
smtp_use_tls=settings.smtp_use_tls,
|
||||
smtp_from_email=settings.smtp_from_email,
|
||||
smtp_from_name=settings.smtp_from_name,
|
||||
support_email=settings.support_email,
|
||||
# Dext integration
|
||||
dext_email=settings.dext_email,
|
||||
dext_include_notes=settings.dext_include_notes,
|
||||
dext_include_non_stock=settings.dext_include_non_stock,
|
||||
dext_auto_send_enabled=settings.dext_auto_send_enabled,
|
||||
dext_manual_send_enabled=settings.dext_manual_send_enabled,
|
||||
dext_include_annotations=settings.dext_include_annotations,
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled=settings.pdf_annotations_enabled,
|
||||
pdf_preview_show_annotations=settings.pdf_preview_show_annotations,
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes=settings.ocr_clean_product_codes,
|
||||
ocr_filter_subtotal_rows=settings.ocr_filter_subtotal_rows,
|
||||
ocr_use_weight_as_quantity=settings.ocr_use_weight_as_quantity,
|
||||
cost_distribution_max_days=settings.cost_distribution_max_days,
|
||||
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled=settings.llm_enabled,
|
||||
anthropic_api_key_set=bool(settings.anthropic_api_key),
|
||||
llm_model=settings.llm_model,
|
||||
llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None,
|
||||
llm_monthly_token_limit=settings.llm_monthly_token_limit,
|
||||
llm_features_enabled=settings.llm_features_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/", response_model=SettingsResponse)
|
||||
async def update_settings(
|
||||
update: SettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update kitchen settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return _build_settings_response(settings)
|
||||
|
||||
|
||||
@router.post("/test-azure")
|
||||
async def test_azure_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Azure Document Intelligence connection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.azure_endpoint or not settings.azure_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Azure credentials not configured"
|
||||
)
|
||||
|
||||
try:
|
||||
from azure.ai.formrecognizer import DocumentAnalysisClient
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
client = DocumentAnalysisClient(
|
||||
endpoint=settings.azure_endpoint,
|
||||
credential=AzureKeyCredential(settings.azure_key)
|
||||
)
|
||||
# Simple connection test - this will validate credentials
|
||||
# The actual analysis would happen during invoice processing
|
||||
return {"status": "success", "message": "Azure connection successful"}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Azure connection failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-smtp")
|
||||
async def test_smtp_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test SMTP connection with current settings"""
|
||||
from services.email_service import EmailService
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.smtp_host or not settings.smtp_from_email:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="SMTP not fully configured. Please set SMTP host and from email."
|
||||
)
|
||||
|
||||
email_service = EmailService(settings)
|
||||
success, message = email_service.test_connection()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"status": "success", "message": message}
|
||||
|
||||
|
||||
# ============ Kitchen Details Endpoints ============
|
||||
|
||||
class KitchenDetailsResponse(BaseModel):
|
||||
kitchen_display_name: str | None = None
|
||||
kitchen_address_line1: str | None = None
|
||||
kitchen_address_line2: str | None = None
|
||||
kitchen_city: str | None = None
|
||||
kitchen_postcode: str | None = None
|
||||
kitchen_phone: str | None = None
|
||||
kitchen_email: str | None = None
|
||||
|
||||
|
||||
class KitchenDetailsUpdate(BaseModel):
|
||||
kitchen_display_name: str | None = None
|
||||
kitchen_address_line1: str | None = None
|
||||
kitchen_address_line2: str | None = None
|
||||
kitchen_city: str | None = None
|
||||
kitchen_postcode: str | None = None
|
||||
kitchen_phone: str | None = None
|
||||
kitchen_email: str | None = None
|
||||
|
||||
|
||||
@router.get("/kitchen-details", response_model=KitchenDetailsResponse)
|
||||
async def get_kitchen_details(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get kitchen details for PO letterhead"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return KitchenDetailsResponse()
|
||||
|
||||
return KitchenDetailsResponse(
|
||||
kitchen_display_name=settings.kitchen_display_name,
|
||||
kitchen_address_line1=settings.kitchen_address_line1,
|
||||
kitchen_address_line2=settings.kitchen_address_line2,
|
||||
kitchen_city=settings.kitchen_city,
|
||||
kitchen_postcode=settings.kitchen_postcode,
|
||||
kitchen_phone=settings.kitchen_phone,
|
||||
kitchen_email=settings.kitchen_email,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/kitchen-details", response_model=KitchenDetailsResponse)
|
||||
async def update_kitchen_details(
|
||||
update: KitchenDetailsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update kitchen details for PO letterhead"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return KitchenDetailsResponse(
|
||||
kitchen_display_name=settings.kitchen_display_name,
|
||||
kitchen_address_line1=settings.kitchen_address_line1,
|
||||
kitchen_address_line2=settings.kitchen_address_line2,
|
||||
kitchen_city=settings.kitchen_city,
|
||||
kitchen_postcode=settings.kitchen_postcode,
|
||||
kitchen_phone=settings.kitchen_phone,
|
||||
kitchen_email=settings.kitchen_email,
|
||||
)
|
||||
|
||||
|
||||
# ============ Page Restrictions Endpoints ============
|
||||
|
||||
class PageRestrictionsResponse(BaseModel):
|
||||
restricted_pages: list[str]
|
||||
|
||||
|
||||
class PageRestrictionsUpdate(BaseModel):
|
||||
restricted_pages: list[str]
|
||||
|
||||
|
||||
@router.get("/page-restrictions", response_model=PageRestrictionsResponse)
|
||||
async def get_page_restrictions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of pages restricted to admin users only"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return PageRestrictionsResponse(restricted_pages=[])
|
||||
|
||||
# Parse comma-separated list
|
||||
restricted = []
|
||||
if settings.admin_restricted_pages:
|
||||
restricted = [p.strip() for p in settings.admin_restricted_pages.split(',') if p.strip()]
|
||||
|
||||
return PageRestrictionsResponse(restricted_pages=restricted)
|
||||
|
||||
|
||||
@router.patch("/page-restrictions", response_model=PageRestrictionsResponse)
|
||||
async def update_page_restrictions(
|
||||
update: PageRestrictionsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update list of pages restricted to admin users only (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admins can modify page restrictions"
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
# Store as comma-separated string
|
||||
settings.admin_restricted_pages = ','.join(update.restricted_pages) if update.restricted_pages else None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return PageRestrictionsResponse(restricted_pages=update.restricted_pages)
|
||||
|
||||
|
||||
# ============ Nextcloud Settings Endpoints ============
|
||||
|
||||
class NextcloudSettingsResponse(BaseModel):
|
||||
nextcloud_host: str | None
|
||||
nextcloud_username: str | None
|
||||
nextcloud_password_set: bool
|
||||
nextcloud_base_path: str | None
|
||||
nextcloud_enabled: bool
|
||||
nextcloud_delete_local: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NextcloudSettingsUpdate(BaseModel):
|
||||
nextcloud_host: str | None = None
|
||||
nextcloud_username: str | None = None
|
||||
nextcloud_password: str | None = None
|
||||
nextcloud_base_path: str | None = None
|
||||
nextcloud_enabled: bool | None = None
|
||||
nextcloud_delete_local: bool | None = None
|
||||
|
||||
|
||||
class NextcloudStatsResponse(BaseModel):
|
||||
pending_count: int
|
||||
archived_count: int
|
||||
local_count: int
|
||||
nextcloud_enabled: bool
|
||||
nextcloud_configured: bool
|
||||
|
||||
|
||||
class NextcloudArchiveResponse(BaseModel):
|
||||
success_count: int
|
||||
failed_count: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("/nextcloud", response_model=NextcloudSettingsResponse)
|
||||
async def get_nextcloud_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get Nextcloud settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return NextcloudSettingsResponse(
|
||||
nextcloud_host=None,
|
||||
nextcloud_username=None,
|
||||
nextcloud_password_set=False,
|
||||
nextcloud_base_path="/Kitchen Invoices",
|
||||
nextcloud_enabled=False,
|
||||
nextcloud_delete_local=False
|
||||
)
|
||||
|
||||
return NextcloudSettingsResponse(
|
||||
nextcloud_host=settings.nextcloud_host,
|
||||
nextcloud_username=settings.nextcloud_username,
|
||||
nextcloud_password_set=bool(settings.nextcloud_password),
|
||||
nextcloud_base_path=settings.nextcloud_base_path,
|
||||
nextcloud_enabled=settings.nextcloud_enabled,
|
||||
nextcloud_delete_local=settings.nextcloud_delete_local
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/nextcloud", response_model=NextcloudSettingsResponse)
|
||||
async def update_nextcloud_settings(
|
||||
update: NextcloudSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update Nextcloud settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
# Map 'nextcloud_password' to the model field
|
||||
if field == 'nextcloud_password' and value:
|
||||
setattr(settings, field, value)
|
||||
elif value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return NextcloudSettingsResponse(
|
||||
nextcloud_host=settings.nextcloud_host,
|
||||
nextcloud_username=settings.nextcloud_username,
|
||||
nextcloud_password_set=bool(settings.nextcloud_password),
|
||||
nextcloud_base_path=settings.nextcloud_base_path,
|
||||
nextcloud_enabled=settings.nextcloud_enabled,
|
||||
nextcloud_delete_local=settings.nextcloud_delete_local
|
||||
)
|
||||
|
||||
|
||||
@router.post("/nextcloud/test")
|
||||
async def test_nextcloud_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Nextcloud WebDAV connection"""
|
||||
from services.nextcloud_service import NextcloudService
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
|
||||
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
||||
|
||||
nc = NextcloudService(
|
||||
settings.nextcloud_host,
|
||||
settings.nextcloud_username,
|
||||
settings.nextcloud_password,
|
||||
settings.nextcloud_base_path
|
||||
)
|
||||
|
||||
success, message = await nc.test_connection()
|
||||
await nc.close()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"status": "success", "message": message}
|
||||
|
||||
|
||||
@router.get("/nextcloud/stats", response_model=NextcloudStatsResponse)
|
||||
async def get_nextcloud_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get Nextcloud archive statistics"""
|
||||
from services.file_archival_service import FileArchivalService
|
||||
|
||||
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
||||
stats = await archival_service.get_archive_stats()
|
||||
|
||||
return NextcloudStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.post("/nextcloud/archive-all", response_model=NextcloudArchiveResponse)
|
||||
async def archive_all_pending(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manually archive all pending invoices to Nextcloud"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
from services.file_archival_service import FileArchivalService
|
||||
|
||||
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
||||
success_count, failed_count, errors = await archival_service.archive_all_pending()
|
||||
|
||||
return NextcloudArchiveResponse(
|
||||
success_count=success_count,
|
||||
failed_count=failed_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.post("/nextcloud/archive/{invoice_id}")
|
||||
async def archive_single_invoice(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Archive a single invoice to Nextcloud (for testing)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
from models.invoice import Invoice
|
||||
from services.file_archival_service import FileArchivalService
|
||||
|
||||
# Get the invoice
|
||||
result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
invoice = result.scalar_one_or_none()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.file_storage_location == "nextcloud":
|
||||
return {"status": "skipped", "message": "Invoice already archived to Nextcloud"}
|
||||
|
||||
# Check Nextcloud config
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
|
||||
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
||||
|
||||
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
||||
success, message = await archival_service.archive_invoice_file(invoice)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
return {"status": "success", "message": message, "nextcloud_path": invoice.nextcloud_path}
|
||||
|
||||
|
||||
# ============ API Access Endpoints ============
|
||||
|
||||
class ApiAccessResponse(BaseModel):
|
||||
api_key: str | None
|
||||
api_key_enabled: bool
|
||||
|
||||
|
||||
class ApiAccessUpdate(BaseModel):
|
||||
api_key_enabled: bool
|
||||
|
||||
|
||||
@router.get("/api-access", response_model=ApiAccessResponse)
|
||||
async def get_api_access(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get API access settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return ApiAccessResponse(api_key=None, api_key_enabled=False)
|
||||
|
||||
return ApiAccessResponse(
|
||||
api_key=settings.api_key,
|
||||
api_key_enabled=settings.api_key_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/api-access", response_model=ApiAccessResponse)
|
||||
async def update_api_access(
|
||||
update: ApiAccessUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update API access settings (enable/disable)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
settings.api_key_enabled = update.api_key_enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return ApiAccessResponse(
|
||||
api_key=settings.api_key,
|
||||
api_key_enabled=settings.api_key_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api-access/regenerate")
|
||||
async def regenerate_api_key(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Generate or regenerate the API key"""
|
||||
import secrets
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
new_key = secrets.token_urlsafe(32)
|
||||
settings.api_key = new_key
|
||||
settings.api_key_enabled = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return {"api_key": new_key, "api_key_enabled": True}
|
||||
|
||||
|
||||
# ============ LLM Usage Stats Endpoints ============
|
||||
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
||||
|
||||
|
||||
class LlmUsageStatsResponse(BaseModel):
|
||||
total_calls: int = 0
|
||||
successful_calls: int = 0
|
||||
failed_calls: int = 0
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
estimated_cost_usd: float = 0.0
|
||||
cache_entries_this_month: int = 0
|
||||
|
||||
|
||||
@router.get("/llm-usage", response_model=LlmUsageStatsResponse)
|
||||
async def get_llm_usage(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get aggregated LLM usage stats for the current month"""
|
||||
from services.llm_service import get_usage_stats
|
||||
|
||||
stats = await get_usage_stats(db, current_user.kitchen_id)
|
||||
return LlmUsageStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.post("/test-llm")
|
||||
async def test_llm_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Anthropic API connection with current settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.anthropic_api_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Anthropic API key not configured"
|
||||
)
|
||||
|
||||
if not settings.llm_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="LLM features are disabled. Enable them in Settings first."
|
||||
)
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
from services.llm_service import DEFAULT_LLM_MODEL
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model=settings.llm_model or DEFAULT_LLM_MODEL,
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "Say 'ok'"}],
|
||||
)
|
||||
return {"status": "success", "message": f"Connection successful. Model: {response.model}"}
|
||||
except anthropic.AuthenticationError:
|
||||
raise HTTPException(status_code=400, detail="Authentication failed — check your API key")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}")
|
||||
|
||||
|
||||
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
||||
@router.get("/llm-models")
|
||||
async def get_llm_models(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fetch available models from Anthropic API."""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.anthropic_api_key:
|
||||
return {"models": [], "default": None, "error": "No API key configured"}
|
||||
|
||||
from services.llm_service import list_available_models, DEFAULT_LLM_MODEL
|
||||
models = await list_available_models(settings.anthropic_api_key)
|
||||
|
||||
return {
|
||||
"models": models,
|
||||
"default": DEFAULT_LLM_MODEL,
|
||||
"current": settings.llm_model or DEFAULT_LLM_MODEL,
|
||||
}
|
||||
343
backend/api/suppliers.py
Normal file
343
backend/api/suppliers.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db, AsyncSessionLocal
|
||||
from models.user import User
|
||||
from models.supplier import Supplier
|
||||
from models.invoice import Invoice
|
||||
from auth import get_current_user, require_cap
|
||||
from ocr.parser import identify_supplier
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def rematch_unmatched_invoices(kitchen_id: int):
|
||||
"""
|
||||
Re-run supplier matching for all invoices without a supplier.
|
||||
Called after supplier create/update to match previously unmatched invoices.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get all invoices without a supplier that have vendor_name from OCR
|
||||
result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.kitchen_id == kitchen_id,
|
||||
Invoice.supplier_id == None,
|
||||
Invoice.vendor_name != None
|
||||
)
|
||||
)
|
||||
invoices = result.scalars().all()
|
||||
|
||||
for invoice in invoices:
|
||||
if invoice.vendor_name:
|
||||
supplier_id, match_type = await identify_supplier(
|
||||
invoice.vendor_name, kitchen_id, db
|
||||
)
|
||||
if supplier_id:
|
||||
invoice.supplier_id = supplier_id
|
||||
invoice.supplier_match_type = match_type
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
class SupplierCreate(BaseModel):
|
||||
name: str
|
||||
aliases: list[str] = []
|
||||
template_config: dict = {}
|
||||
identifier_config: dict = {}
|
||||
skip_dext: bool = False
|
||||
order_email: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
|
||||
|
||||
class SupplierUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
aliases: Optional[list[str]] = None
|
||||
template_config: Optional[dict] = None
|
||||
identifier_config: Optional[dict] = None
|
||||
skip_dext: Optional[bool] = None
|
||||
order_email: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
|
||||
|
||||
class SupplierResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
aliases: list[str]
|
||||
template_config: dict
|
||||
identifier_config: dict
|
||||
skip_dext: bool
|
||||
order_email: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/", response_model=SupplierResponse)
|
||||
async def create_supplier(
|
||||
request: SupplierCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new supplier with extraction templates"""
|
||||
supplier = Supplier(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
name=request.name,
|
||||
aliases=request.aliases,
|
||||
template_config=request.template_config,
|
||||
identifier_config=request.identifier_config,
|
||||
skip_dext=request.skip_dext,
|
||||
order_email=request.order_email,
|
||||
account_number=request.account_number,
|
||||
)
|
||||
db.add(supplier)
|
||||
await db.commit()
|
||||
await db.refresh(supplier)
|
||||
|
||||
# Rematch unmatched invoices in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SupplierResponse])
|
||||
async def list_suppliers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List all suppliers for the current kitchen"""
|
||||
result = await db.execute(
|
||||
select(Supplier)
|
||||
.where(Supplier.kitchen_id == current_user.kitchen_id)
|
||||
.order_by(Supplier.name)
|
||||
)
|
||||
suppliers = result.scalars().all()
|
||||
|
||||
return [
|
||||
SupplierResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
aliases=s.aliases or [],
|
||||
template_config=s.template_config,
|
||||
identifier_config=s.identifier_config,
|
||||
skip_dext=s.skip_dext,
|
||||
order_email=s.order_email,
|
||||
account_number=s.account_number,
|
||||
created_at=s.created_at.isoformat()
|
||||
)
|
||||
for s in suppliers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{supplier_id}", response_model=SupplierResponse)
|
||||
async def get_supplier(
|
||||
supplier_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get a supplier by ID"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{supplier_id}", response_model=SupplierResponse)
|
||||
async def update_supplier(
|
||||
supplier_id: int,
|
||||
update: SupplierUpdate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a supplier's template configuration"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(supplier, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(supplier)
|
||||
|
||||
# Rematch unmatched invoices in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
async def delete_supplier(
|
||||
supplier_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a supplier"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
await db.delete(supplier)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Supplier deleted"}
|
||||
|
||||
|
||||
class AddAliasRequest(BaseModel):
|
||||
alias: str
|
||||
invoice_id: Optional[int] = None # If provided, update this invoice's match type to 'exact'
|
||||
|
||||
|
||||
@router.post("/{supplier_id}/aliases", response_model=SupplierResponse)
|
||||
async def add_supplier_alias(
|
||||
supplier_id: int,
|
||||
request: AddAliasRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add an alias to a supplier for better matching"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
alias = request.alias.strip()
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Alias cannot be empty")
|
||||
|
||||
# Add alias if not already present
|
||||
# Create a new list to ensure SQLAlchemy detects the change (JSON columns don't detect in-place mutations)
|
||||
current_aliases = list(supplier.aliases or [])
|
||||
if alias not in current_aliases:
|
||||
current_aliases.append(alias)
|
||||
supplier.aliases = current_aliases
|
||||
|
||||
# If invoice_id provided, update that invoice's match type to 'exact'
|
||||
if request.invoice_id:
|
||||
inv_result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == request.invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
invoice = inv_result.scalar_one_or_none()
|
||||
if invoice and invoice.supplier_match_type == 'fuzzy':
|
||||
invoice.supplier_match_type = 'exact'
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(supplier)
|
||||
|
||||
# Rematch unmatched invoices in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/rematch-fuzzy")
|
||||
async def rematch_fuzzy_invoices(
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Clear all fuzzy-matched invoices and re-run supplier matching.
|
||||
Use this after updating matching logic to fix incorrect fuzzy matches.
|
||||
"""
|
||||
# Count fuzzy matches before clearing
|
||||
count_result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
Invoice.supplier_match_type == "fuzzy"
|
||||
)
|
||||
)
|
||||
fuzzy_invoices = count_result.scalars().all()
|
||||
count = len(fuzzy_invoices)
|
||||
|
||||
# Clear supplier assignment for all fuzzy matches
|
||||
for invoice in fuzzy_invoices:
|
||||
invoice.supplier_id = None
|
||||
invoice.supplier_match_type = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-run matching in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return {"message": f"Cleared {count} fuzzy matches. Re-matching in background."}
|
||||
204
backend/api/support.py
Normal file
204
backend/api/support.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""
|
||||
Support Request API
|
||||
|
||||
Handles user support requests with page screenshots.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user, require_cap
|
||||
from models.user import User, Kitchen
|
||||
from models.settings import KitchenSettings
|
||||
from services.email_service import EmailService
|
||||
from sqlalchemy import select
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupportRequest(BaseModel):
|
||||
"""Support request payload"""
|
||||
description: str
|
||||
screenshot: str # Base64 encoded PNG
|
||||
page_url: str
|
||||
browser_info: str | None = None
|
||||
|
||||
|
||||
class SupportResponse(BaseModel):
|
||||
"""Support request response"""
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
def generate_support_email_html(
|
||||
user_name: str,
|
||||
user_email: str,
|
||||
kitchen_name: str,
|
||||
description: str,
|
||||
page_url: str,
|
||||
browser_info: str | None,
|
||||
timestamp: datetime
|
||||
) -> str:
|
||||
"""Generate HTML email body for support request"""
|
||||
return f"""
|
||||
<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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue