Add .gitignore, remove __pycache__ from tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8d688b459d
commit
c8fad1cf36
181 changed files with 8 additions and 29855 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
dist/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from . import invoices, suppliers, reports, newbook, resos
|
||||
|
||||
__all__ = ["invoices", "suppliers", "reports", "newbook", "resos"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,374 +0,0 @@
|
|||
"""
|
||||
Backup management API endpoints.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from models.backup import BackupHistory
|
||||
from auth import get_current_user, require_cap
|
||||
from services.backup_service import BackupService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Models ============
|
||||
|
||||
class BackupSettingsResponse(BaseModel):
|
||||
backup_frequency: str | None
|
||||
backup_retention_count: int
|
||||
backup_destination: str | None
|
||||
backup_time: str | None
|
||||
backup_nextcloud_path: str | None
|
||||
backup_smb_host: str | None
|
||||
backup_smb_share: str | None
|
||||
backup_smb_username: str | None
|
||||
backup_smb_password_set: bool
|
||||
backup_smb_path: str | None
|
||||
backup_last_run_at: str | None
|
||||
backup_last_status: str | None
|
||||
backup_last_error: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BackupSettingsUpdate(BaseModel):
|
||||
backup_frequency: str | None = None # "daily", "weekly", "manual"
|
||||
backup_retention_count: int | None = None
|
||||
backup_destination: str | None = None # "local", "nextcloud", "smb"
|
||||
backup_time: str | None = None # "HH:MM"
|
||||
backup_nextcloud_path: str | None = None
|
||||
backup_smb_host: str | None = None
|
||||
backup_smb_share: str | None = None
|
||||
backup_smb_username: str | None = None
|
||||
backup_smb_password: str | None = None
|
||||
backup_smb_path: str | None = None
|
||||
|
||||
|
||||
class BackupHistoryResponse(BaseModel):
|
||||
id: int
|
||||
backup_type: str
|
||||
destination: str
|
||||
status: str
|
||||
filename: str
|
||||
file_size_bytes: int | None
|
||||
invoice_count: int | None
|
||||
file_count: int | None
|
||||
started_at: str
|
||||
completed_at: str | None
|
||||
error_message: str | None
|
||||
triggered_by_username: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BackupCreateResponse(BaseModel):
|
||||
message: str
|
||||
status: str
|
||||
backup_id: int | None = None
|
||||
|
||||
|
||||
class BackupRestoreResponse(BaseModel):
|
||||
status: str
|
||||
message: str
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings", response_model=BackupSettingsResponse)
|
||||
async def get_backup_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get backup settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return BackupSettingsResponse(
|
||||
backup_frequency="manual",
|
||||
backup_retention_count=7,
|
||||
backup_destination="local",
|
||||
backup_time="03:00",
|
||||
backup_nextcloud_path="/Backups",
|
||||
backup_smb_host=None,
|
||||
backup_smb_share=None,
|
||||
backup_smb_username=None,
|
||||
backup_smb_password_set=False,
|
||||
backup_smb_path="/backups",
|
||||
backup_last_run_at=None,
|
||||
backup_last_status=None,
|
||||
backup_last_error=None
|
||||
)
|
||||
|
||||
return BackupSettingsResponse(
|
||||
backup_frequency=settings.backup_frequency,
|
||||
backup_retention_count=settings.backup_retention_count,
|
||||
backup_destination=settings.backup_destination,
|
||||
backup_time=settings.backup_time,
|
||||
backup_nextcloud_path=settings.backup_nextcloud_path,
|
||||
backup_smb_host=settings.backup_smb_host,
|
||||
backup_smb_share=settings.backup_smb_share,
|
||||
backup_smb_username=settings.backup_smb_username,
|
||||
backup_smb_password_set=bool(settings.backup_smb_password),
|
||||
backup_smb_path=settings.backup_smb_path,
|
||||
backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
|
||||
backup_last_status=settings.backup_last_status,
|
||||
backup_last_error=settings.backup_last_error
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=BackupSettingsResponse)
|
||||
async def update_backup_settings(
|
||||
update: BackupSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update backup settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == 'backup_smb_password' and value:
|
||||
setattr(settings, field, value)
|
||||
elif value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return BackupSettingsResponse(
|
||||
backup_frequency=settings.backup_frequency,
|
||||
backup_retention_count=settings.backup_retention_count,
|
||||
backup_destination=settings.backup_destination,
|
||||
backup_time=settings.backup_time,
|
||||
backup_nextcloud_path=settings.backup_nextcloud_path,
|
||||
backup_smb_host=settings.backup_smb_host,
|
||||
backup_smb_share=settings.backup_smb_share,
|
||||
backup_smb_username=settings.backup_smb_username,
|
||||
backup_smb_password_set=bool(settings.backup_smb_password),
|
||||
backup_smb_path=settings.backup_smb_path,
|
||||
backup_last_run_at=settings.backup_last_run_at.isoformat() if settings.backup_last_run_at else None,
|
||||
backup_last_status=settings.backup_last_status,
|
||||
backup_last_error=settings.backup_last_error
|
||||
)
|
||||
|
||||
|
||||
# ============ Backup Operations ============
|
||||
|
||||
async def _run_backup_task(db: AsyncSession, kitchen_id: int, user_id: int):
|
||||
"""Background task to run backup"""
|
||||
backup_service = BackupService(db, kitchen_id)
|
||||
await backup_service.create_backup(user_id=user_id, backup_type="manual")
|
||||
|
||||
|
||||
@router.post("/create", response_model=BackupCreateResponse)
|
||||
async def create_backup(
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Trigger a manual backup"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
# Run backup synchronously for now to get immediate feedback
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message, backup = await backup_service.create_backup(
|
||||
user_id=current_user.id,
|
||||
backup_type="manual"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
return BackupCreateResponse(
|
||||
message=message,
|
||||
status="success",
|
||||
backup_id=backup.id if backup else None
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[BackupHistoryResponse])
|
||||
async def list_backups(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List backup history"""
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
backups = await backup_service.list_backups()
|
||||
|
||||
return [
|
||||
BackupHistoryResponse(
|
||||
id=b.id,
|
||||
backup_type=b.backup_type,
|
||||
destination=b.destination,
|
||||
status=b.status,
|
||||
filename=b.filename,
|
||||
file_size_bytes=b.file_size_bytes,
|
||||
invoice_count=b.invoice_count,
|
||||
file_count=b.file_count,
|
||||
started_at=b.started_at.isoformat(),
|
||||
completed_at=b.completed_at.isoformat() if b.completed_at else None,
|
||||
error_message=b.error_message,
|
||||
triggered_by_username=b.triggered_by_user.name if b.triggered_by_user else None
|
||||
)
|
||||
for b in backups
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{backup_id}/restore", response_model=BackupRestoreResponse)
|
||||
async def restore_backup(
|
||||
backup_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Restore from a backup"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message = await backup_service.restore_backup(backup_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return BackupRestoreResponse(status="success", message=message)
|
||||
|
||||
|
||||
@router.delete("/{backup_id}")
|
||||
async def delete_backup(
|
||||
backup_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a backup"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message = await backup_service.delete_backup(backup_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.get("/{backup_id}/download")
|
||||
async def download_backup(
|
||||
backup_id: int,
|
||||
token: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Download a backup file. Auth via token query param for direct browser downloads."""
|
||||
from auth import get_current_user, require_cap_from_token
|
||||
|
||||
current_user = await get_current_user_from_token(token, db)
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
backup = await backup_service.get_backup(backup_id)
|
||||
|
||||
if not backup:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
|
||||
if backup.status != "success":
|
||||
raise HTTPException(status_code=400, detail="Cannot download failed backup")
|
||||
|
||||
# Handle local backups
|
||||
if not backup.file_path.startswith("nextcloud:"):
|
||||
if not os.path.exists(backup.file_path):
|
||||
raise HTTPException(status_code=404, detail="Backup file not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=backup.file_path,
|
||||
filename=backup.filename,
|
||||
media_type="application/zip"
|
||||
)
|
||||
|
||||
# Handle Nextcloud backups - download to temp file
|
||||
from services.nextcloud_service import NextcloudService
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.nextcloud_host:
|
||||
raise HTTPException(status_code=400, detail="Nextcloud not configured")
|
||||
|
||||
nc_path = backup.file_path.replace("nextcloud:", "")
|
||||
nc = NextcloudService(
|
||||
settings.nextcloud_host,
|
||||
settings.nextcloud_username,
|
||||
settings.nextcloud_password,
|
||||
""
|
||||
)
|
||||
success, content = await nc.download_file(nc_path)
|
||||
await nc.close()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to download from Nextcloud: {content}")
|
||||
|
||||
# Write to temp file and return
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_path = os.path.join(temp_dir, backup.filename)
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
return FileResponse(
|
||||
path=temp_path,
|
||||
filename=backup.filename,
|
||||
media_type="application/zip",
|
||||
background=lambda: shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_model=BackupRestoreResponse)
|
||||
async def upload_and_restore_backup(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload a backup file and restore from it"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
if not file.filename.endswith('.zip'):
|
||||
raise HTTPException(status_code=400, detail="Only ZIP files are accepted")
|
||||
|
||||
backup_service = BackupService(db, current_user.kitchen_id)
|
||||
success, message = await backup_service.restore_from_upload(file)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return BackupRestoreResponse(status="success", message=message)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,236 +0,0 @@
|
|||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.calendar_events import CalendarEvent
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas
|
||||
class CalendarEventCreate(BaseModel):
|
||||
event_date: date
|
||||
event_type: str # reminder, event, note
|
||||
title: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CalendarEventUpdate(BaseModel):
|
||||
event_date: date | None = None
|
||||
event_type: str | None = None
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
id: int
|
||||
event_date: date
|
||||
event_type: str
|
||||
title: str
|
||||
description: str | None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Endpoints
|
||||
@router.get("/")
|
||||
async def list_events(
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[CalendarEventResponse]:
|
||||
"""List events for date range"""
|
||||
query = select(CalendarEvent).where(
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
|
||||
if from_date:
|
||||
query = query.where(CalendarEvent.event_date >= from_date)
|
||||
if to_date:
|
||||
query = query.where(CalendarEvent.event_date <= to_date)
|
||||
|
||||
query = query.order_by(CalendarEvent.event_date, CalendarEvent.created_at)
|
||||
result = await db.execute(query)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [
|
||||
CalendarEventResponse(
|
||||
id=e.id,
|
||||
event_date=e.event_date,
|
||||
event_type=e.event_type,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
created_at=e.created_at.isoformat()
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{date}")
|
||||
async def get_events_for_date(
|
||||
date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[CalendarEventResponse]:
|
||||
"""Get all events for a specific date"""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id,
|
||||
CalendarEvent.event_date == date
|
||||
)
|
||||
).order_by(CalendarEvent.created_at)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return [
|
||||
CalendarEventResponse(
|
||||
id=e.id,
|
||||
event_date=e.event_date,
|
||||
event_type=e.event_type,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
created_at=e.created_at.isoformat()
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_event(
|
||||
event: CalendarEventCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> CalendarEventResponse:
|
||||
"""Create new event"""
|
||||
new_event = CalendarEvent(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
event_date=event.event_date,
|
||||
event_type=event.event_type,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
await db.refresh(new_event)
|
||||
|
||||
return CalendarEventResponse(
|
||||
id=new_event.id,
|
||||
event_date=new_event.event_date,
|
||||
event_type=new_event.event_type,
|
||||
title=new_event.title,
|
||||
description=new_event.description,
|
||||
created_at=new_event.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{id}")
|
||||
async def update_event(
|
||||
id: int,
|
||||
update: CalendarEventUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update existing event"""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.id == id,
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
if update.event_date is not None:
|
||||
event.event_date = update.event_date
|
||||
if update.event_type is not None:
|
||||
event.event_type = update.event_type
|
||||
if update.title is not None:
|
||||
event.title = update.title
|
||||
if update.description is not None:
|
||||
event.description = update.description
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
|
||||
return CalendarEventResponse(
|
||||
id=event.id,
|
||||
event_date=event.event_date,
|
||||
event_type=event.event_type,
|
||||
title=event.title,
|
||||
description=event.description,
|
||||
created_at=event.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
async def delete_event(
|
||||
id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete event"""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.id == id,
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
await db.delete(event)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Event deleted"}
|
||||
|
||||
|
||||
@router.get("/dashboard/upcoming")
|
||||
async def get_upcoming_events(
|
||||
limit: int = 3,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get next N upcoming events for dashboard widget"""
|
||||
today = date.today()
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.kitchen_id == current_user.kitchen_id,
|
||||
CalendarEvent.event_date >= today
|
||||
)
|
||||
).order_by(CalendarEvent.event_date, CalendarEvent.created_at).limit(limit)
|
||||
)
|
||||
events = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total_count": len(events),
|
||||
"upcoming_events": [
|
||||
{
|
||||
"id": e.id,
|
||||
"event_date": e.event_date.isoformat(),
|
||||
"event_type": e.event_type,
|
||||
"title": e.title
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
}
|
||||
|
|
@ -1,863 +0,0 @@
|
|||
"""
|
||||
Cost Distribution API endpoints — create, view, settle early, cancel,
|
||||
and weekly summaries for budget page integration.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, field_serializer
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.invoice import Invoice, InvoiceStatus
|
||||
from models.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.cost_distribution import (
|
||||
CostDistribution,
|
||||
CostDistributionLineSelection,
|
||||
CostDistributionEntry,
|
||||
DistributionStatus,
|
||||
DistributionMethod,
|
||||
)
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineSelectionIn(BaseModel):
|
||||
line_item_id: int
|
||||
selected_quantity: Decimal
|
||||
|
||||
|
||||
class CostDistributionCreate(BaseModel):
|
||||
invoice_id: int
|
||||
method: str # "OFFSET" or "DISTRIBUTE"
|
||||
notes: Optional[str] = None
|
||||
line_selections: list[LineSelectionIn]
|
||||
# For OFFSET
|
||||
target_date: Optional[date] = None
|
||||
# For DISTRIBUTE
|
||||
days_of_week: Optional[list[int]] = None # 0=Mon, 6=Sun (Python weekday())
|
||||
num_weeks: Optional[int] = None
|
||||
start_date: Optional[date] = None
|
||||
|
||||
|
||||
class CostDistributionUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SettleEarlyRequest(BaseModel):
|
||||
entry_date: date
|
||||
amount: Optional[Decimal] = None # null = settle all
|
||||
|
||||
|
||||
# ── Response schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineSelectionOut(BaseModel):
|
||||
id: int
|
||||
line_item_id: int
|
||||
description: Optional[str] = None
|
||||
original_quantity: Optional[Decimal] = None
|
||||
selected_quantity: Decimal
|
||||
unit_price: Decimal
|
||||
distributed_value: Decimal
|
||||
|
||||
@field_serializer('selected_quantity', 'unit_price', 'distributed_value', 'original_quantity')
|
||||
def ser(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
id: int
|
||||
entry_date: date
|
||||
amount: Decimal
|
||||
is_source_offset: bool
|
||||
is_overpay: bool
|
||||
|
||||
@field_serializer('amount')
|
||||
def ser_amount(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class CostDistributionOut(BaseModel):
|
||||
id: int
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str] = None
|
||||
invoice_date: Optional[date] = None
|
||||
supplier_name: Optional[str] = None
|
||||
status: str
|
||||
method: str
|
||||
notes: Optional[str]
|
||||
total_distributed_value: Decimal
|
||||
remaining_balance: Decimal
|
||||
source_date: date
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: str
|
||||
line_selections: list[LineSelectionOut] = []
|
||||
entries: list[EntryOut] = []
|
||||
|
||||
@field_serializer('total_distributed_value', 'remaining_balance')
|
||||
def ser_dec(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class LineItemAvailability(BaseModel):
|
||||
id: int
|
||||
description: Optional[str]
|
||||
unit: Optional[str]
|
||||
quantity: Optional[Decimal]
|
||||
unit_price: Optional[Decimal]
|
||||
amount: Optional[Decimal]
|
||||
is_non_stock: bool
|
||||
already_distributed_qty: Decimal
|
||||
available_qty: Decimal
|
||||
|
||||
@field_serializer('quantity', 'unit_price', 'amount', 'already_distributed_qty', 'available_qty')
|
||||
def ser(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class InvoiceAvailabilityOut(BaseModel):
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
invoice_date: Optional[date]
|
||||
supplier_name: Optional[str]
|
||||
line_items: list[LineItemAvailability] = []
|
||||
|
||||
|
||||
class WeeklyDistributionRow(BaseModel):
|
||||
distribution_id: int
|
||||
title: str # Compact: "dd/mm/yy - Supplier"
|
||||
supplier_name: Optional[str] = None
|
||||
invoice_number: Optional[str] = None
|
||||
source_date_str: str = "" # dd/mm/yy
|
||||
summary: str = "" # e.g. "£200 laid away to 28/02/26" or "£200 distributed over 4 weeks"
|
||||
notes: Optional[str] = None
|
||||
invoice_id: int
|
||||
entries_by_date: dict[str, float]
|
||||
total_distributed_value: float
|
||||
remaining_balance: float
|
||||
bf_balance: float # Outstanding at start of this week
|
||||
cf_balance: float # Outstanding at end of this week
|
||||
status: str
|
||||
|
||||
|
||||
class WeeklyDistributionsOut(BaseModel):
|
||||
week_start: date
|
||||
week_end: date
|
||||
distributions: list[WeeklyDistributionRow] = []
|
||||
daily_totals: dict[str, float] = {}
|
||||
bf_balance: float
|
||||
cf_balance: float
|
||||
week_total: float
|
||||
|
||||
|
||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _generate_target_dates(
|
||||
method: str,
|
||||
target_date: Optional[date],
|
||||
days_of_week: Optional[list[int]],
|
||||
num_weeks: Optional[int],
|
||||
start_date: Optional[date],
|
||||
) -> list[date]:
|
||||
"""Generate target dates based on distribution method."""
|
||||
if method == DistributionMethod.OFFSET.value:
|
||||
if not target_date:
|
||||
raise HTTPException(400, "target_date required for OFFSET method")
|
||||
return [target_date]
|
||||
|
||||
if method == DistributionMethod.DISTRIBUTE.value:
|
||||
if not days_of_week or not num_weeks or not start_date:
|
||||
raise HTTPException(400, "days_of_week, num_weeks, and start_date required for DISTRIBUTE method")
|
||||
if not days_of_week:
|
||||
raise HTTPException(400, "At least one day of week must be selected")
|
||||
if num_weeks < 1:
|
||||
raise HTTPException(400, "num_weeks must be at least 1")
|
||||
|
||||
dates = []
|
||||
for week in range(num_weeks):
|
||||
week_start = start_date + timedelta(weeks=week)
|
||||
for day_offset in range(7):
|
||||
d = week_start + timedelta(days=day_offset)
|
||||
if d.weekday() in days_of_week:
|
||||
dates.append(d)
|
||||
|
||||
# Deduplicate and sort (in case start_date is mid-week)
|
||||
dates = sorted(set(dates))
|
||||
if not dates:
|
||||
raise HTTPException(400, "No target dates generated from the selected days and weeks")
|
||||
return dates
|
||||
|
||||
raise HTTPException(400, f"Invalid method: {method}")
|
||||
|
||||
|
||||
def _distribute_amount(total: Decimal, count: int) -> list[Decimal]:
|
||||
"""Distribute a total amount evenly across count entries. Last entry absorbs rounding."""
|
||||
if count == 0:
|
||||
return []
|
||||
per_entry = (total / count).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
amounts = [per_entry] * count
|
||||
# Adjust last entry to absorb rounding difference
|
||||
distributed_sum = per_entry * (count - 1)
|
||||
amounts[-1] = total - distributed_sum
|
||||
return amounts
|
||||
|
||||
|
||||
async def _get_already_distributed_qty(
|
||||
db: AsyncSession, line_item_id: int, exclude_distribution_id: Optional[int] = None
|
||||
) -> Decimal:
|
||||
"""Get total quantity already distributed for a line item from ACTIVE distributions."""
|
||||
query = (
|
||||
select(func.coalesce(func.sum(CostDistributionLineSelection.selected_quantity), 0))
|
||||
.join(CostDistribution, CostDistributionLineSelection.distribution_id == CostDistribution.id)
|
||||
.where(
|
||||
CostDistributionLineSelection.line_item_id == line_item_id,
|
||||
CostDistribution.status == DistributionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
if exclude_distribution_id:
|
||||
query = query.where(CostDistribution.id != exclude_distribution_id)
|
||||
result = await db.execute(query)
|
||||
return Decimal(str(result.scalar() or 0))
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/invoice/{invoice_id}/availability", response_model=InvoiceAvailabilityOut)
|
||||
async def get_invoice_availability(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get line items available for distribution from an invoice."""
|
||||
invoice = await db.execute(
|
||||
select(Invoice)
|
||||
.options(selectinload(Invoice.line_items))
|
||||
.where(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if invoice.status != InvoiceStatus.CONFIRMED:
|
||||
raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
|
||||
if invoice.document_type == "credit_note":
|
||||
raise HTTPException(400, "Credit notes cannot be distributed")
|
||||
|
||||
supplier_name = None
|
||||
if invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
|
||||
items = []
|
||||
for li in invoice.line_items:
|
||||
already = await _get_already_distributed_qty(db, li.id)
|
||||
original_qty = li.quantity or Decimal("0")
|
||||
available = max(Decimal("0"), original_qty - already)
|
||||
items.append(LineItemAvailability(
|
||||
id=li.id,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
quantity=li.quantity,
|
||||
unit_price=li.unit_price,
|
||||
amount=li.amount,
|
||||
is_non_stock=li.is_non_stock or False,
|
||||
already_distributed_qty=already,
|
||||
available_qty=available,
|
||||
))
|
||||
|
||||
return InvoiceAvailabilityOut(
|
||||
invoice_id=invoice.id,
|
||||
invoice_number=invoice.invoice_number,
|
||||
invoice_date=invoice.invoice_date,
|
||||
supplier_name=supplier_name or invoice.vendor_name,
|
||||
line_items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=CostDistributionOut)
|
||||
async def create_cost_distribution(
|
||||
data: CostDistributionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new cost distribution."""
|
||||
# Validate invoice
|
||||
invoice = await db.execute(
|
||||
select(Invoice)
|
||||
.options(selectinload(Invoice.line_items))
|
||||
.where(
|
||||
Invoice.id == data.invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if invoice.status != InvoiceStatus.CONFIRMED:
|
||||
raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
|
||||
if invoice.document_type == "credit_note":
|
||||
raise HTTPException(400, "Credit notes cannot be distributed")
|
||||
if not invoice.invoice_date:
|
||||
raise HTTPException(400, "Invoice must have a date to be distributed")
|
||||
|
||||
# Validate method
|
||||
if data.method not in [m.value for m in DistributionMethod]:
|
||||
raise HTTPException(400, f"Invalid method: {data.method}")
|
||||
|
||||
# Get settings for max_days validation
|
||||
settings = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings.scalar_one_or_none()
|
||||
max_days = settings.cost_distribution_max_days if settings else 90
|
||||
|
||||
# Generate target dates
|
||||
target_dates = _generate_target_dates(
|
||||
data.method, data.target_date, data.days_of_week, data.num_weeks, data.start_date
|
||||
)
|
||||
|
||||
# Validate max days
|
||||
today = date.today()
|
||||
max_allowed_date = today + timedelta(days=max_days)
|
||||
for td in target_dates:
|
||||
if td > max_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Target date {td} exceeds maximum of {max_days} days into the future ({max_allowed_date})"
|
||||
)
|
||||
|
||||
# Build line item map
|
||||
line_item_map = {li.id: li for li in invoice.line_items}
|
||||
|
||||
# Validate line selections and calculate totals
|
||||
if not data.line_selections:
|
||||
raise HTTPException(400, "At least one line item must be selected")
|
||||
|
||||
total_distributed_value = Decimal("0")
|
||||
selections_data = []
|
||||
|
||||
for sel in data.line_selections:
|
||||
li = line_item_map.get(sel.line_item_id)
|
||||
if not li:
|
||||
raise HTTPException(400, f"Line item {sel.line_item_id} not found on invoice")
|
||||
if li.is_non_stock:
|
||||
raise HTTPException(400, f"Non-stock item '{li.description}' cannot be distributed")
|
||||
if sel.selected_quantity <= 0:
|
||||
raise HTTPException(400, "Selected quantity must be greater than 0")
|
||||
|
||||
# Check available quantity
|
||||
already = await _get_already_distributed_qty(db, li.id)
|
||||
original_qty = li.quantity or Decimal("0")
|
||||
available = original_qty - already
|
||||
if sel.selected_quantity > available:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Requested qty {sel.selected_quantity} exceeds available {available} for '{li.description}'"
|
||||
)
|
||||
|
||||
unit_price = li.unit_price or Decimal("0")
|
||||
distributed_value = (sel.selected_quantity * unit_price).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
total_distributed_value += distributed_value
|
||||
selections_data.append({
|
||||
"line_item_id": li.id,
|
||||
"selected_quantity": sel.selected_quantity,
|
||||
"unit_price": unit_price,
|
||||
"distributed_value": distributed_value,
|
||||
})
|
||||
|
||||
if total_distributed_value <= 0:
|
||||
raise HTTPException(400, "Total distributed value must be greater than 0")
|
||||
|
||||
# Create the distribution header
|
||||
distribution = CostDistribution(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
invoice_id=invoice.id,
|
||||
status=DistributionStatus.ACTIVE.value,
|
||||
method=data.method,
|
||||
notes=data.notes,
|
||||
total_distributed_value=total_distributed_value,
|
||||
remaining_balance=total_distributed_value,
|
||||
source_date=invoice.invoice_date,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(distribution)
|
||||
await db.flush() # Get the distribution ID
|
||||
|
||||
# Create line selections
|
||||
for sel_data in selections_data:
|
||||
selection = CostDistributionLineSelection(
|
||||
distribution_id=distribution.id,
|
||||
**sel_data,
|
||||
)
|
||||
db.add(selection)
|
||||
|
||||
# Create source offset entry (negative on invoice date)
|
||||
source_entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=invoice.invoice_date,
|
||||
amount=-total_distributed_value,
|
||||
is_source_offset=True,
|
||||
is_overpay=False,
|
||||
)
|
||||
db.add(source_entry)
|
||||
|
||||
# Create target entries
|
||||
entry_amounts = _distribute_amount(total_distributed_value, len(target_dates))
|
||||
for td, amt in zip(target_dates, entry_amounts):
|
||||
entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=td,
|
||||
amount=amt,
|
||||
is_source_offset=False,
|
||||
is_overpay=False,
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.get("/weekly", response_model=WeeklyDistributionsOut)
|
||||
async def get_weekly_distributions(
|
||||
week_start: date,
|
||||
week_end: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all cost distributions relevant to a week view for the budget page."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
|
||||
# Get ALL active distributions (even if no entries this week) to prevent gaming
|
||||
# Also include COMPLETED distributions that have entries in this week period
|
||||
active_dists = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.entries),
|
||||
selectinload(CostDistribution.invoice),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.kitchen_id == kitchen_id,
|
||||
CostDistribution.status == DistributionStatus.ACTIVE.value,
|
||||
)
|
||||
.order_by(CostDistribution.id)
|
||||
)
|
||||
active_dists = active_dists.scalars().all()
|
||||
|
||||
# Completed distributions that have entries within the viewed week
|
||||
completed_dists = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.entries),
|
||||
selectinload(CostDistribution.invoice),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.kitchen_id == kitchen_id,
|
||||
CostDistribution.status == DistributionStatus.COMPLETED.value,
|
||||
CostDistribution.id.in_(
|
||||
select(CostDistributionEntry.distribution_id)
|
||||
.where(
|
||||
CostDistributionEntry.kitchen_id == kitchen_id,
|
||||
CostDistributionEntry.entry_date >= week_start,
|
||||
CostDistributionEntry.entry_date <= week_end,
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(CostDistribution.id)
|
||||
)
|
||||
completed_dists = completed_dists.scalars().all()
|
||||
|
||||
# Merge — active first, then completed with entries in period
|
||||
seen_ids = {d.id for d in active_dists}
|
||||
distributions = list(active_dists)
|
||||
for d in completed_dists:
|
||||
if d.id not in seen_ids:
|
||||
distributions.append(d)
|
||||
|
||||
rows = []
|
||||
daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||||
bf_balance = Decimal("0")
|
||||
week_total = Decimal("0")
|
||||
|
||||
for dist in distributions:
|
||||
# Get supplier name
|
||||
supplier_name = None
|
||||
if dist.invoice:
|
||||
if dist.invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == dist.invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
if not supplier_name:
|
||||
supplier_name = dist.invoice.vendor_name
|
||||
|
||||
invoice_num = dist.invoice.invoice_number if dist.invoice else None
|
||||
source_date_short = dist.source_date.strftime("%d/%m/%y") if dist.source_date else ""
|
||||
title = f"{source_date_short} - {supplier_name or 'Unknown'}"
|
||||
|
||||
# Build summary text
|
||||
total_val = f"£{float(dist.total_distributed_value):.2f}"
|
||||
target_entries = [e for e in dist.entries if not e.is_source_offset and not e.is_overpay]
|
||||
if dist.method == "OFFSET" and target_entries:
|
||||
target_date = target_entries[0].entry_date.strftime("%d/%m/%y")
|
||||
summary = f"{total_val} laid away to {target_date}"
|
||||
elif target_entries:
|
||||
# DISTRIBUTE: count unique days of week and number of weeks
|
||||
target_dates = sorted(set(e.entry_date for e in target_entries))
|
||||
if len(target_dates) > 1:
|
||||
first, last = target_dates[0], target_dates[-1]
|
||||
num_weeks = max(1, ((last - first).days // 7) + 1)
|
||||
dow_set = set(d.strftime("%a") for d in target_dates)
|
||||
days_str = ",".join(sorted(dow_set, key=lambda x: ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].index(x)))
|
||||
summary = f"{total_val} distributed {days_str} over {num_weeks} weeks"
|
||||
else:
|
||||
summary = f"{total_val} distributed to {target_dates[0].strftime('%d/%m/%y')}"
|
||||
else:
|
||||
summary = f"{total_val} distributed"
|
||||
|
||||
# Get entries for this week (including source offset so user sees the deduction)
|
||||
entries_by_date: dict[str, float] = {}
|
||||
for entry in dist.entries:
|
||||
if week_start <= entry.entry_date <= week_end:
|
||||
date_key = entry.entry_date.isoformat()
|
||||
entries_by_date[date_key] = entries_by_date.get(date_key, 0) + float(entry.amount)
|
||||
daily_totals[date_key] += entry.amount
|
||||
week_total += entry.amount
|
||||
|
||||
# Per-distribution BF/CF as net running balance:
|
||||
# BF = sum of ALL entries (including source offset) before this week
|
||||
# CF = BF + sum of ALL entries within this week
|
||||
# e.g. new distribution: BF=0, source deducts -£200, CF=-£200
|
||||
# next week: BF=-£200, positives bring it toward 0
|
||||
dist_bf = sum(
|
||||
e.amount for e in dist.entries
|
||||
if e.entry_date < week_start
|
||||
)
|
||||
entries_this_week = sum(
|
||||
e.amount for e in dist.entries
|
||||
if week_start <= e.entry_date <= week_end
|
||||
)
|
||||
dist_cf = dist_bf + entries_this_week
|
||||
|
||||
# Only include distributions that are relevant to this period:
|
||||
# has a non-zero BF or CF, or has entries in this week
|
||||
if dist_bf == 0 and dist_cf == 0 and not entries_by_date:
|
||||
continue
|
||||
|
||||
bf_balance += dist_bf
|
||||
|
||||
rows.append(WeeklyDistributionRow(
|
||||
distribution_id=dist.id,
|
||||
title=title,
|
||||
supplier_name=supplier_name,
|
||||
invoice_number=invoice_num,
|
||||
source_date_str=source_date_short,
|
||||
summary=summary,
|
||||
notes=dist.notes,
|
||||
invoice_id=dist.invoice_id,
|
||||
entries_by_date=entries_by_date,
|
||||
total_distributed_value=float(dist.total_distributed_value),
|
||||
remaining_balance=float(dist.remaining_balance),
|
||||
bf_balance=float(dist_bf),
|
||||
cf_balance=float(dist_cf),
|
||||
status=dist.status,
|
||||
))
|
||||
|
||||
cf_balance = bf_balance + week_total
|
||||
|
||||
return WeeklyDistributionsOut(
|
||||
week_start=week_start,
|
||||
week_end=week_end,
|
||||
distributions=rows,
|
||||
daily_totals={k: float(v) for k, v in daily_totals.items()},
|
||||
bf_balance=float(bf_balance),
|
||||
cf_balance=float(cf_balance),
|
||||
week_total=float(week_total),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{distribution_id}", response_model=CostDistributionOut)
|
||||
async def get_cost_distribution(
|
||||
distribution_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single cost distribution with line selections and entries."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.put("/{distribution_id}", response_model=CostDistributionOut)
|
||||
async def update_cost_distribution(
|
||||
distribution_id: int,
|
||||
data: CostDistributionUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update notes on an existing distribution."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status != DistributionStatus.ACTIVE.value:
|
||||
raise HTTPException(400, "Can only update ACTIVE distributions")
|
||||
|
||||
if data.notes is not None:
|
||||
distribution.notes = data.notes
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.delete("/{distribution_id}")
|
||||
async def cancel_cost_distribution(
|
||||
distribution_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a cost distribution. Non-admin restricted to distributions
|
||||
where the source (invoice) date is within 14 days."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution).where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status == DistributionStatus.CANCELLED.value:
|
||||
raise HTTPException(400, "Distribution is already cancelled")
|
||||
|
||||
# Anti-gaming: non-admin cannot cancel distributions where the source
|
||||
# date is more than 14 days in the past (cost would silently revert to
|
||||
# the old invoice date and could be overlooked)
|
||||
if not current_user.is_admin:
|
||||
min_allowed_date = date.today() - timedelta(days=14)
|
||||
if distribution.source_date < min_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot cancel — invoice date {distribution.source_date} is more than 14 days ago. Ask an admin."
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
distribution.status = DistributionStatus.CANCELLED.value
|
||||
distribution.cancelled_by = current_user.id
|
||||
distribution.cancelled_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Cost distribution cancelled", "id": distribution_id}
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/settle-early", response_model=CostDistributionOut)
|
||||
async def settle_early(
|
||||
distribution_id: int,
|
||||
data: SettleEarlyRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Settle a distribution early by moving remaining balance to a chosen date."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status != DistributionStatus.ACTIVE.value:
|
||||
raise HTTPException(400, "Can only settle ACTIVE distributions")
|
||||
|
||||
today = date.today()
|
||||
|
||||
# Anti-gaming: non-admin cannot settle more than 14 days in the past
|
||||
if not current_user.is_admin:
|
||||
min_allowed_date = today - timedelta(days=14)
|
||||
if data.entry_date < min_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot settle more than 14 days in the past. Earliest allowed: {min_allowed_date}"
|
||||
)
|
||||
|
||||
# Calculate entries from the settle date onwards — these get replaced by the single
|
||||
# settle entry. Using entry_date (not today) so that settling for yesterday correctly
|
||||
# consolidates yesterday's entry + all future entries into the chosen date.
|
||||
settable_entries = [
|
||||
e for e in distribution.entries
|
||||
if not e.is_source_offset and not e.is_overpay and e.entry_date >= data.entry_date
|
||||
]
|
||||
settable_total = sum(e.amount for e in settable_entries)
|
||||
|
||||
if settable_total <= 0:
|
||||
raise HTTPException(400, "No entries to settle from the chosen date onwards")
|
||||
|
||||
settle_amount = data.amount if data.amount is not None else settable_total
|
||||
if settle_amount <= 0:
|
||||
raise HTTPException(400, "Settle amount must be greater than 0")
|
||||
if settle_amount > settable_total:
|
||||
raise HTTPException(400, f"Settle amount {settle_amount} exceeds settable entries total {settable_total}")
|
||||
|
||||
# Delete all entries from the settle date onwards
|
||||
for entry in settable_entries:
|
||||
await db.delete(entry)
|
||||
|
||||
# Create settle entry on the chosen date
|
||||
overpay_entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=data.entry_date,
|
||||
amount=settle_amount,
|
||||
is_source_offset=False,
|
||||
is_overpay=True,
|
||||
)
|
||||
db.add(overpay_entry)
|
||||
|
||||
# Update remaining_balance: what's left unaccounted
|
||||
new_remaining = settable_total - settle_amount
|
||||
distribution.remaining_balance = new_remaining
|
||||
|
||||
# If fully settled, mark as completed
|
||||
if new_remaining <= 0:
|
||||
distribution.status = DistributionStatus.COMPLETED.value
|
||||
distribution.remaining_balance = Decimal("0")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
# ── Response builder helper ───────────────────────────────────────────────────
|
||||
|
||||
async def _build_distribution_response(
|
||||
db: AsyncSession, distribution: CostDistribution
|
||||
) -> CostDistributionOut:
|
||||
"""Build the full response object for a cost distribution."""
|
||||
# Get invoice info
|
||||
invoice = await db.execute(
|
||||
select(Invoice).where(Invoice.id == distribution.invoice_id)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
|
||||
supplier_name = None
|
||||
if invoice and invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
if not supplier_name and invoice:
|
||||
supplier_name = invoice.vendor_name
|
||||
|
||||
# Get creator name
|
||||
creator = await db.execute(
|
||||
select(User).where(User.id == distribution.created_by)
|
||||
)
|
||||
creator = creator.scalar_one_or_none()
|
||||
|
||||
# Build line selections with line item details
|
||||
line_selections = []
|
||||
for sel in distribution.line_selections:
|
||||
li = sel.line_item if hasattr(sel, 'line_item') and sel.line_item else None
|
||||
if not li:
|
||||
li_result = await db.execute(select(LineItem).where(LineItem.id == sel.line_item_id))
|
||||
li = li_result.scalar_one_or_none()
|
||||
|
||||
line_selections.append(LineSelectionOut(
|
||||
id=sel.id,
|
||||
line_item_id=sel.line_item_id,
|
||||
description=li.description if li else None,
|
||||
original_quantity=li.quantity if li else None,
|
||||
selected_quantity=sel.selected_quantity,
|
||||
unit_price=sel.unit_price,
|
||||
distributed_value=sel.distributed_value,
|
||||
))
|
||||
|
||||
entries = [
|
||||
EntryOut(
|
||||
id=e.id,
|
||||
entry_date=e.entry_date,
|
||||
amount=e.amount,
|
||||
is_source_offset=e.is_source_offset,
|
||||
is_overpay=e.is_overpay,
|
||||
)
|
||||
for e in distribution.entries
|
||||
]
|
||||
|
||||
return CostDistributionOut(
|
||||
id=distribution.id,
|
||||
invoice_id=distribution.invoice_id,
|
||||
invoice_number=invoice.invoice_number if invoice else None,
|
||||
invoice_date=invoice.invoice_date if invoice else None,
|
||||
supplier_name=supplier_name,
|
||||
status=distribution.status,
|
||||
method=distribution.method,
|
||||
notes=distribution.notes,
|
||||
total_distributed_value=distribution.total_distributed_value,
|
||||
remaining_balance=distribution.remaining_balance,
|
||||
source_date=distribution.source_date,
|
||||
created_by_name=creator.name if creator else None,
|
||||
created_at=distribution.created_at.isoformat() if distribution.created_at else "",
|
||||
line_selections=line_selections,
|
||||
entries=entries,
|
||||
)
|
||||
|
|
@ -1,648 +0,0 @@
|
|||
"""
|
||||
Cover Override API endpoints.
|
||||
|
||||
Handles forecast snapshots, cover overrides, and spend rate overrides
|
||||
for the Spend Budget feature.
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text, select, delete
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
from services.forecast_api import ForecastAPIClient, ForecastAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
class SnapshotRequest(BaseModel):
|
||||
week_offset: int = 0
|
||||
|
||||
|
||||
class CoverOverrideRequest(BaseModel):
|
||||
override_date: str # YYYY-MM-DD
|
||||
period: str # 'lunch' or 'dinner'
|
||||
override_covers: int # target total covers
|
||||
|
||||
|
||||
class SpendRateOverrideRequest(BaseModel):
|
||||
week_offset: int = 0
|
||||
period: str # 'breakfast', 'lunch', or 'dinner'
|
||||
food_spend: Optional[float] = None
|
||||
drinks_spend: Optional[float] = None
|
||||
|
||||
|
||||
class SnapshotData(BaseModel):
|
||||
date: str
|
||||
period: str
|
||||
forecast_covers: int
|
||||
otb_covers: int
|
||||
food_spend: Optional[float]
|
||||
drinks_spend: Optional[float]
|
||||
forecast_dry_revenue: Optional[float]
|
||||
|
||||
|
||||
class OverrideData(BaseModel):
|
||||
id: int
|
||||
override_date: str
|
||||
period: str
|
||||
override_covers: int
|
||||
original_forecast: Optional[int]
|
||||
original_otb: Optional[int]
|
||||
|
||||
|
||||
class SpendRateData(BaseModel):
|
||||
period: str
|
||||
food_spend_api: Optional[float]
|
||||
drinks_spend_api: Optional[float]
|
||||
food_spend_snapshot: Optional[float]
|
||||
drinks_spend_snapshot: Optional[float]
|
||||
food_spend_override: Optional[float]
|
||||
drinks_spend_override: Optional[float]
|
||||
food_spend_effective: float
|
||||
drinks_spend_effective: float
|
||||
|
||||
|
||||
class RecalcDay(BaseModel):
|
||||
date: str
|
||||
day_name: str
|
||||
is_past: bool
|
||||
periods: dict # period -> {actual, otb, pickup, effective, override, snapshot, variance}
|
||||
day_revenue: float
|
||||
|
||||
|
||||
class WeeklyOverrideResponse(BaseModel):
|
||||
week_start: str
|
||||
week_end: str
|
||||
has_snapshot: bool
|
||||
vat_rate: float = 1.20
|
||||
snapshot_revenue: Optional[float] = None
|
||||
adjusted_revenue: Optional[float] = None
|
||||
snapshots: list[SnapshotData] = []
|
||||
overrides: list[OverrideData] = []
|
||||
spend_rates: list[SpendRateData] = []
|
||||
recalc_days: list[RecalcDay] = []
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def get_week_dates(week_offset: int = 0) -> tuple[date, date, list[date]]:
|
||||
today = date.today()
|
||||
current_monday = today - timedelta(days=today.weekday())
|
||||
week_start = current_monday + timedelta(weeks=week_offset)
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_dates = [week_start + timedelta(days=i) for i in range(7)]
|
||||
return week_start, week_end, week_dates
|
||||
|
||||
|
||||
async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings:
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Kitchen settings not found")
|
||||
return settings
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/weekly", response_model=WeeklyOverrideResponse)
|
||||
async def get_weekly_overrides(
|
||||
week_offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get snapshot, overrides, spend rates, and recalculated breakdown for a week."""
|
||||
week_start, week_end, week_dates = get_week_dates(week_offset)
|
||||
kitchen_id = current_user.kitchen_id
|
||||
today = date.today()
|
||||
|
||||
# Check if snapshot exists
|
||||
snap_result = await db.execute(text("""
|
||||
SELECT snapshot_date, period, forecast_covers, otb_covers,
|
||||
food_spend, drinks_spend, forecast_dry_revenue
|
||||
FROM forecast_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
ORDER BY snapshot_date, period
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
snap_rows = snap_result.fetchall()
|
||||
|
||||
has_snapshot = len(snap_rows) > 0
|
||||
snapshots = []
|
||||
snap_lookup = {} # (date_str, period) -> row
|
||||
|
||||
for row in snap_rows:
|
||||
date_str = row.snapshot_date.isoformat() if hasattr(row.snapshot_date, 'isoformat') else str(row.snapshot_date)
|
||||
snapshots.append(SnapshotData(
|
||||
date=date_str,
|
||||
period=row.period,
|
||||
forecast_covers=row.forecast_covers,
|
||||
otb_covers=row.otb_covers,
|
||||
food_spend=float(row.food_spend) if row.food_spend else None,
|
||||
drinks_spend=float(row.drinks_spend) if row.drinks_spend else None,
|
||||
forecast_dry_revenue=float(row.forecast_dry_revenue) if row.forecast_dry_revenue else None,
|
||||
))
|
||||
snap_lookup[(date_str, row.period)] = row
|
||||
|
||||
# Get week snapshot for revenue totals
|
||||
week_snap_result = await db.execute(text("""
|
||||
SELECT total_forecast_revenue, total_otb_revenue, gp_target
|
||||
FROM forecast_week_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
week_snap = week_snap_result.fetchone()
|
||||
snapshot_revenue = float(week_snap.total_forecast_revenue) if week_snap and week_snap.total_forecast_revenue else None
|
||||
|
||||
# Get overrides
|
||||
override_result = await db.execute(text("""
|
||||
SELECT id, override_date, period, override_covers, original_forecast, original_otb
|
||||
FROM cover_overrides
|
||||
WHERE kitchen_id = :kid
|
||||
AND override_date >= :ws AND override_date <= :we
|
||||
ORDER BY override_date, period
|
||||
"""), {"kid": kitchen_id, "ws": week_start, "we": week_end})
|
||||
override_rows = override_result.fetchall()
|
||||
|
||||
overrides = []
|
||||
override_lookup = {} # (date_str, period) -> row
|
||||
for row in override_rows:
|
||||
date_str = row.override_date.isoformat() if hasattr(row.override_date, 'isoformat') else str(row.override_date)
|
||||
overrides.append(OverrideData(
|
||||
id=row.id,
|
||||
override_date=date_str,
|
||||
period=row.period,
|
||||
override_covers=row.override_covers,
|
||||
original_forecast=row.original_forecast,
|
||||
original_otb=row.original_otb,
|
||||
))
|
||||
override_lookup[(date_str, row.period)] = row
|
||||
|
||||
# Get spend rate overrides
|
||||
spend_override_result = await db.execute(text("""
|
||||
SELECT id, period, food_spend, drinks_spend
|
||||
FROM spend_rate_overrides
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
spend_override_rows = spend_override_result.fetchall()
|
||||
spend_override_lookup = {row.period: row for row in spend_override_rows}
|
||||
|
||||
# Fetch live forecast data + spend rates from API
|
||||
settings = await get_settings(db, kitchen_id)
|
||||
api_spend_rates = {}
|
||||
api_vat_rate = 1.20
|
||||
covers_data = []
|
||||
revenue_data = []
|
||||
|
||||
if settings.forecast_api_url and settings.forecast_api_key:
|
||||
try:
|
||||
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
||||
try:
|
||||
sr = await client.get_spend_rates()
|
||||
api_spend_rates = sr.get("periods", {})
|
||||
api_vat_rate = sr.get("vat_rate", 1.20)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch spend rates: {e}")
|
||||
|
||||
try:
|
||||
covers_data = await client.get_covers_forecast(week_start, days=7)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch covers: {e}")
|
||||
|
||||
try:
|
||||
revenue_data = await client.get_revenue_forecast(week_start, days=7)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch revenue: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to forecast API: {e}")
|
||||
|
||||
# Build spend rates response (per period)
|
||||
spend_rates = []
|
||||
spend_effective = {} # period -> {food, drinks}
|
||||
for period in ("breakfast", "lunch", "dinner"):
|
||||
api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0)
|
||||
api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0)
|
||||
|
||||
# Get snapshot values (from first day of snapshot, same for all days in a period)
|
||||
snap_food = None
|
||||
snap_drinks = None
|
||||
for d in week_dates:
|
||||
key = (d.isoformat(), period)
|
||||
if key in snap_lookup:
|
||||
snap_food = float(snap_lookup[key].food_spend) if snap_lookup[key].food_spend else None
|
||||
snap_drinks = float(snap_lookup[key].drinks_spend) if snap_lookup[key].drinks_spend else None
|
||||
break
|
||||
|
||||
# Get override values
|
||||
ovr = spend_override_lookup.get(period)
|
||||
ovr_food = float(ovr.food_spend) if ovr and ovr.food_spend else None
|
||||
ovr_drinks = float(ovr.drinks_spend) if ovr and ovr.drinks_spend else None
|
||||
|
||||
# Resolve effective: override > snapshot > API
|
||||
eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food)
|
||||
eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks)
|
||||
|
||||
spend_effective[period] = {"food": eff_food, "drinks": eff_drinks}
|
||||
spend_rates.append(SpendRateData(
|
||||
period=period,
|
||||
food_spend_api=api_food,
|
||||
drinks_spend_api=api_drinks,
|
||||
food_spend_snapshot=snap_food,
|
||||
drinks_spend_snapshot=snap_drinks,
|
||||
food_spend_override=ovr_food,
|
||||
drinks_spend_override=ovr_drinks,
|
||||
food_spend_effective=eff_food,
|
||||
drinks_spend_effective=eff_drinks,
|
||||
))
|
||||
|
||||
# Build recalculated days
|
||||
covers_by_date = {d.get("date", ""): d for d in covers_data}
|
||||
revenue_by_date = {d.get("date", ""): d for d in revenue_data}
|
||||
recalc_days = []
|
||||
total_adjusted_revenue = Decimal("0")
|
||||
|
||||
for d in week_dates:
|
||||
date_str = d.isoformat()
|
||||
is_past = d < today
|
||||
day_covers = covers_by_date.get(date_str, {})
|
||||
day_revenue = revenue_by_date.get(date_str, {})
|
||||
day_name = day_covers.get("day", d.strftime("%a"))
|
||||
|
||||
periods_data = {}
|
||||
day_rev = Decimal("0")
|
||||
|
||||
for period in ("breakfast", "lunch", "dinner"):
|
||||
p_covers = day_covers.get(period, {})
|
||||
otb = p_covers.get("otb", 0) or 0
|
||||
forecast = p_covers.get("forecast", 0) or 0
|
||||
pickup = forecast - otb
|
||||
|
||||
# Get snapshot and override for this day/period
|
||||
snap = snap_lookup.get((date_str, period))
|
||||
ovr = override_lookup.get((date_str, period))
|
||||
|
||||
snap_forecast = snap.forecast_covers if snap else None
|
||||
override_val = ovr.override_covers if ovr else None
|
||||
|
||||
if is_past:
|
||||
# Past day: use actual (forecast value from API is actual for past dates)
|
||||
effective = forecast
|
||||
actual = forecast
|
||||
# Calculate variance vs override or snapshot
|
||||
variance = None
|
||||
if override_val is not None:
|
||||
diff = actual - override_val
|
||||
if diff != 0:
|
||||
variance = diff
|
||||
elif snap_forecast is not None:
|
||||
diff = actual - snap_forecast
|
||||
if diff != 0:
|
||||
variance = diff
|
||||
|
||||
# Past revenue from API (dry revenue)
|
||||
dry = day_revenue.get("dry", {})
|
||||
period_rev = Decimal(str(dry.get("forecast", 0) or 0)) if period == "breakfast" else Decimal("0")
|
||||
# For past days, use actual total revenue from the API (proportioned by period isn't available,
|
||||
# so we'll use effective_covers * spend_rates as approximation, but actual total from revenue API)
|
||||
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
||||
period_rev = Decimal(str(effective)) * eff_food
|
||||
|
||||
periods_data[period] = {
|
||||
"actual": actual,
|
||||
"otb": otb,
|
||||
"pickup": 0,
|
||||
"effective": effective,
|
||||
"override": override_val,
|
||||
"snapshot": snap_forecast,
|
||||
"variance": variance,
|
||||
"is_overridden": False,
|
||||
}
|
||||
else:
|
||||
# Today/future: apply override logic
|
||||
if override_val is not None:
|
||||
if otb >= override_val:
|
||||
effective = otb # OTB supersedes upward
|
||||
adj_pickup = 0
|
||||
else:
|
||||
effective = override_val
|
||||
adj_pickup = override_val - otb
|
||||
is_overridden = True
|
||||
else:
|
||||
effective = forecast
|
||||
adj_pickup = pickup
|
||||
is_overridden = False
|
||||
|
||||
# Calculate revenue for this period (dry/food only - budget tracks food revenue)
|
||||
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
||||
period_rev = Decimal(str(effective)) * eff_food
|
||||
|
||||
periods_data[period] = {
|
||||
"actual": None,
|
||||
"otb": otb,
|
||||
"pickup": adj_pickup,
|
||||
"effective": effective,
|
||||
"override": override_val,
|
||||
"snapshot": snap_forecast,
|
||||
"variance": None,
|
||||
"is_overridden": is_overridden,
|
||||
}
|
||||
|
||||
day_rev += period_rev
|
||||
|
||||
# For past days, use actual revenue from API if available
|
||||
if is_past and day_revenue:
|
||||
dry = day_revenue.get("dry", {})
|
||||
actual_dry_rev = Decimal(str(dry.get("forecast", 0) or 0))
|
||||
if actual_dry_rev > 0:
|
||||
day_rev = actual_dry_rev
|
||||
|
||||
total_adjusted_revenue += day_rev
|
||||
|
||||
recalc_days.append(RecalcDay(
|
||||
date=date_str,
|
||||
day_name=day_name,
|
||||
is_past=is_past,
|
||||
periods=periods_data,
|
||||
day_revenue=float(round(day_rev, 2)),
|
||||
))
|
||||
|
||||
return WeeklyOverrideResponse(
|
||||
week_start=week_start.isoformat(),
|
||||
week_end=week_end.isoformat(),
|
||||
has_snapshot=has_snapshot,
|
||||
vat_rate=api_vat_rate,
|
||||
snapshot_revenue=snapshot_revenue,
|
||||
adjusted_revenue=float(round(total_adjusted_revenue, 2)),
|
||||
snapshots=snapshots,
|
||||
overrides=overrides,
|
||||
spend_rates=spend_rates,
|
||||
recalc_days=recalc_days,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/snapshot")
|
||||
async def create_snapshot(
|
||||
req: SnapshotRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Take a snapshot of the current forecast for a week (all periods, all days)."""
|
||||
week_start, week_end, week_dates = get_week_dates(req.week_offset)
|
||||
kitchen_id = current_user.kitchen_id
|
||||
settings = await get_settings(db, kitchen_id)
|
||||
|
||||
if not settings.forecast_api_url or not settings.forecast_api_key:
|
||||
raise HTTPException(status_code=400, detail="Forecast API not configured")
|
||||
|
||||
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
||||
covers_data = await client.get_covers_forecast(week_start, days=7)
|
||||
revenue_data = await client.get_revenue_forecast(week_start, days=7)
|
||||
spend_rates_response = await client.get_spend_rates()
|
||||
|
||||
api_spend = spend_rates_response.get("periods", {})
|
||||
vat_rate = spend_rates_response.get("vat_rate", 1.20)
|
||||
|
||||
covers_by_date = {d.get("date", ""): d for d in covers_data}
|
||||
revenue_by_date = {d.get("date", ""): d for d in revenue_data}
|
||||
|
||||
# Delete existing snapshots for this week (re-snapshot)
|
||||
await db.execute(text("""
|
||||
DELETE FROM forecast_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
|
||||
# Insert snapshot for each day/period
|
||||
total_forecast_rev = Decimal("0")
|
||||
total_otb_rev = Decimal("0")
|
||||
|
||||
for d in week_dates:
|
||||
date_str = d.isoformat()
|
||||
day_covers = covers_by_date.get(date_str, {})
|
||||
day_revenue = revenue_by_date.get(date_str, {})
|
||||
|
||||
for period in ("breakfast", "lunch", "dinner"):
|
||||
p = day_covers.get(period, {})
|
||||
otb = p.get("otb", 0) or 0
|
||||
forecast = p.get("forecast", 0) or 0
|
||||
|
||||
# Spend rates (net, ex VAT)
|
||||
food_net = api_spend.get(period, {}).get("food_spend_net", 0)
|
||||
drinks_net = api_spend.get(period, {}).get("drinks_spend_net", 0)
|
||||
|
||||
# Calculate dry revenue for this period/day
|
||||
dry_rev = Decimal(str(forecast)) * Decimal(str(food_net))
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO forecast_snapshots
|
||||
(kitchen_id, snapshot_date, period, forecast_covers, otb_covers,
|
||||
food_spend, drinks_spend, forecast_dry_revenue, week_start)
|
||||
VALUES (:kid, :sd, :period, :fc, :oc, :fs, :ds, :dr, :ws)
|
||||
"""), {
|
||||
"kid": kitchen_id, "sd": d, "period": period,
|
||||
"fc": forecast, "oc": otb,
|
||||
"fs": food_net, "ds": drinks_net,
|
||||
"dr": float(round(dry_rev, 2)),
|
||||
"ws": week_start,
|
||||
})
|
||||
|
||||
# Accumulate weekly totals from revenue API
|
||||
dry = day_revenue.get("dry", {})
|
||||
total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
|
||||
total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
|
||||
|
||||
# Divide by 3 since we're iterating 3 periods but revenue data is per-day total
|
||||
# Actually, the revenue API returns per-day totals, not per-period.
|
||||
# We accumulated 3x per day. Let's recalculate from revenue_data directly.
|
||||
total_forecast_rev = Decimal("0")
|
||||
total_otb_rev = Decimal("0")
|
||||
for d in revenue_data:
|
||||
dry = d.get("dry", {})
|
||||
total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
|
||||
total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
|
||||
|
||||
# Upsert week snapshot
|
||||
gp_target = float(settings.budget_gp_target) if settings.budget_gp_target else 65.0
|
||||
|
||||
await db.execute(text("""
|
||||
DELETE FROM forecast_week_snapshots
|
||||
WHERE kitchen_id = :kid AND week_start = :ws
|
||||
"""), {"kid": kitchen_id, "ws": week_start})
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO forecast_week_snapshots
|
||||
(kitchen_id, week_start, total_forecast_revenue, total_otb_revenue, gp_target)
|
||||
VALUES (:kid, :ws, :tfr, :tor, :gp)
|
||||
"""), {
|
||||
"kid": kitchen_id, "ws": week_start,
|
||||
"tfr": float(round(total_forecast_rev, 2)),
|
||||
"tor": float(round(total_otb_rev, 2)),
|
||||
"gp": gp_target,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Forecast snapshot created",
|
||||
"week_start": week_start.isoformat(),
|
||||
"total_forecast_revenue": float(round(total_forecast_rev, 2)),
|
||||
"total_otb_revenue": float(round(total_otb_rev, 2)),
|
||||
"days_snapshotted": len(week_dates),
|
||||
"periods_per_day": 3,
|
||||
}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def upsert_cover_override(
|
||||
req: CoverOverrideRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Set or update a cover override for a specific date+period."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
|
||||
if req.period not in ("lunch", "dinner"):
|
||||
raise HTTPException(status_code=400, detail="Period must be 'lunch' or 'dinner'")
|
||||
|
||||
if req.override_covers < 0:
|
||||
raise HTTPException(status_code=400, detail="Override covers cannot be negative")
|
||||
|
||||
override_date = date.fromisoformat(req.override_date)
|
||||
|
||||
# Check existing
|
||||
existing = await db.execute(text("""
|
||||
SELECT id FROM cover_overrides
|
||||
WHERE kitchen_id = :kid AND override_date = :od AND period = :p
|
||||
"""), {"kid": kitchen_id, "od": override_date, "p": req.period})
|
||||
row = existing.fetchone()
|
||||
|
||||
if row:
|
||||
await db.execute(text("""
|
||||
UPDATE cover_overrides
|
||||
SET override_covers = :oc, updated_by = :uid, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""), {"oc": req.override_covers, "uid": current_user.id, "id": row.id})
|
||||
else:
|
||||
# Get current forecast for snapshot
|
||||
settings = await get_settings(db, kitchen_id)
|
||||
original_forecast = None
|
||||
original_otb = None
|
||||
|
||||
if settings.forecast_api_url and settings.forecast_api_key:
|
||||
try:
|
||||
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
||||
covers = await client.get_covers_forecast(override_date, days=1)
|
||||
if covers:
|
||||
p = covers[0].get(req.period, {})
|
||||
original_forecast = p.get("forecast", 0) or 0
|
||||
original_otb = p.get("otb", 0) or 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get forecast for snapshot: {e}")
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO cover_overrides
|
||||
(kitchen_id, override_date, period, override_covers,
|
||||
original_forecast, original_otb, created_by, updated_by)
|
||||
VALUES (:kid, :od, :p, :oc, :of, :oo, :uid, :uid)
|
||||
"""), {
|
||||
"kid": kitchen_id, "od": override_date, "p": req.period,
|
||||
"oc": req.override_covers, "of": original_forecast, "oo": original_otb,
|
||||
"uid": current_user.id,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "date": req.override_date, "period": req.period, "covers": req.override_covers}
|
||||
|
||||
|
||||
@router.delete("/{override_id}")
|
||||
async def delete_cover_override(
|
||||
override_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove a cover override (revert to forecast)."""
|
||||
result = await db.execute(text("""
|
||||
DELETE FROM cover_overrides
|
||||
WHERE id = :id AND kitchen_id = :kid
|
||||
RETURNING id
|
||||
"""), {"id": override_id, "kid": current_user.kitchen_id})
|
||||
deleted = result.fetchone()
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Override not found")
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "deleted_id": override_id}
|
||||
|
||||
|
||||
@router.put("/spend-rates")
|
||||
async def upsert_spend_rate_override(
|
||||
req: SpendRateOverrideRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Set or update a spend rate override for a week+period."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
week_start, _, _ = get_week_dates(req.week_offset)
|
||||
|
||||
if req.period not in ("breakfast", "lunch", "dinner"):
|
||||
raise HTTPException(status_code=400, detail="Period must be 'breakfast', 'lunch', or 'dinner'")
|
||||
|
||||
# Check existing
|
||||
existing = await db.execute(text("""
|
||||
SELECT id FROM spend_rate_overrides
|
||||
WHERE kitchen_id = :kid AND week_start = :ws AND period = :p
|
||||
"""), {"kid": kitchen_id, "ws": week_start, "p": req.period})
|
||||
row = existing.fetchone()
|
||||
|
||||
if row:
|
||||
await db.execute(text("""
|
||||
UPDATE spend_rate_overrides
|
||||
SET food_spend = :fs, drinks_spend = :ds, updated_by = :uid, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""), {"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id, "id": row.id})
|
||||
else:
|
||||
await db.execute(text("""
|
||||
INSERT INTO spend_rate_overrides
|
||||
(kitchen_id, week_start, period, food_spend, drinks_spend, created_by, updated_by)
|
||||
VALUES (:kid, :ws, :p, :fs, :ds, :uid, :uid)
|
||||
"""), {
|
||||
"kid": kitchen_id, "ws": week_start, "p": req.period,
|
||||
"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "week_start": week_start.isoformat(), "period": req.period}
|
||||
|
||||
|
||||
@router.delete("/spend-rates/{override_id}")
|
||||
async def delete_spend_rate_override(
|
||||
override_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Remove a spend rate override (revert to snapshot/API value)."""
|
||||
result = await db.execute(text("""
|
||||
DELETE FROM spend_rate_overrides
|
||||
WHERE id = :id AND kitchen_id = :kid
|
||||
RETURNING id
|
||||
"""), {"id": override_id, "kid": current_user.kitchen_id})
|
||||
deleted = result.fetchone()
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Spend rate override not found")
|
||||
|
||||
await db.commit()
|
||||
return {"success": True, "deleted_id": override_id}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
"""
|
||||
API endpoints for credit note management.
|
||||
|
||||
Handles:
|
||||
- Credit note upload
|
||||
- Credit note download
|
||||
- Credit note CRUD operations
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import date as date_type
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.dispute import CreditNote, InvoiceDispute, DisputeStatus, DisputeActivity
|
||||
from models.invoice import Invoice
|
||||
from models.supplier import Supplier
|
||||
from services.dispute_archival_service import DisputeArchivalService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic Schemas
|
||||
|
||||
class CreditNoteResponse(BaseModel):
|
||||
id: int
|
||||
invoice_id: int
|
||||
supplier_id: int
|
||||
supplier_name: str
|
||||
credit_note_number: str
|
||||
credit_date: str
|
||||
credit_amount: float
|
||||
reason: Optional[str]
|
||||
notes: Optional[str]
|
||||
file_storage_location: str
|
||||
created_at: str
|
||||
created_by: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Endpoints
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_credit_note(
|
||||
file: UploadFile = File(...),
|
||||
invoice_id: int = Form(...),
|
||||
credit_note_number: str = Form(...),
|
||||
credit_date: str = Form(...),
|
||||
credit_amount: float = Form(...),
|
||||
dispute_id: Optional[int] = Form(None),
|
||||
reason: Optional[str] = Form(None),
|
||||
notes: Optional[str] = Form(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload credit note PDF"""
|
||||
|
||||
# Verify invoice exists and belongs to kitchen
|
||||
result = await db.execute(
|
||||
select(Invoice).where(
|
||||
and_(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
invoice = result.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
# Parse credit date
|
||||
try:
|
||||
credit_date_obj = date_type.fromisoformat(credit_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
|
||||
# Validate file type (should be PDF)
|
||||
if file.content_type and "pdf" not in file.content_type.lower():
|
||||
raise HTTPException(status_code=400, detail="Only PDF files are supported for credit notes")
|
||||
|
||||
# Save file
|
||||
archival_service = DisputeArchivalService(db, current_user.kitchen_id)
|
||||
success, file_path = await archival_service.save_credit_note(
|
||||
invoice,
|
||||
file_content,
|
||||
file.filename or "credit_note.pdf"
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save file: {file_path}")
|
||||
|
||||
# Create credit note record
|
||||
credit_note = CreditNote(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
invoice_id=invoice_id,
|
||||
supplier_id=invoice.supplier_id or 0, # Use 0 if no supplier (will need to handle)
|
||||
credit_note_number=credit_note_number,
|
||||
credit_date=credit_date_obj,
|
||||
credit_amount=Decimal(str(credit_amount)),
|
||||
reason=reason,
|
||||
notes=notes,
|
||||
file_path=file_path,
|
||||
file_storage_location="local",
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(credit_note)
|
||||
await db.flush() # Get credit_note.id
|
||||
|
||||
# If linked to dispute, update dispute
|
||||
if dispute_id:
|
||||
result = await db.execute(
|
||||
select(InvoiceDispute).where(
|
||||
and_(
|
||||
InvoiceDispute.id == dispute_id,
|
||||
InvoiceDispute.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if dispute:
|
||||
# Link credit note to dispute
|
||||
dispute.credit_note_id = credit_note.id
|
||||
dispute.resolved_amount = credit_note.credit_amount
|
||||
|
||||
# Update dispute status if still open/in_progress
|
||||
if dispute.status in [DisputeStatus.OPEN, DisputeStatus.CONTACTED, DisputeStatus.IN_PROGRESS]:
|
||||
dispute.status = DisputeStatus.AWAITING_CREDIT
|
||||
|
||||
# Log activity
|
||||
activity = DisputeActivity(
|
||||
dispute_id=dispute_id,
|
||||
activity_type="credit_note_added",
|
||||
description=f"Credit note {credit_note_number} (£{credit_amount:.2f}) linked to dispute",
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(activity)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(credit_note)
|
||||
|
||||
# Archive to Nextcloud if enabled
|
||||
success, result = await archival_service.archive_credit_note(credit_note)
|
||||
if success:
|
||||
await db.commit() # Update archived status
|
||||
|
||||
return {
|
||||
"id": credit_note.id,
|
||||
"credit_note_number": credit_note_number,
|
||||
"credit_amount": credit_amount,
|
||||
"archived": success
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{credit_note_id}")
|
||||
async def get_credit_note(
|
||||
credit_note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> CreditNoteResponse:
|
||||
"""Get credit note details"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).options(
|
||||
selectinload(CreditNote.supplier),
|
||||
selectinload(CreditNote.created_by_user)
|
||||
).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
return CreditNoteResponse(
|
||||
id=credit_note.id,
|
||||
invoice_id=credit_note.invoice_id,
|
||||
supplier_id=credit_note.supplier_id,
|
||||
supplier_name=credit_note.supplier.name if credit_note.supplier else "Unknown",
|
||||
credit_note_number=credit_note.credit_note_number,
|
||||
credit_date=credit_note.credit_date.isoformat(),
|
||||
credit_amount=float(credit_note.credit_amount),
|
||||
reason=credit_note.reason,
|
||||
notes=credit_note.notes,
|
||||
file_storage_location=credit_note.file_storage_location,
|
||||
created_at=credit_note.created_at.isoformat(),
|
||||
created_by=credit_note.created_by_user.name if credit_note.created_by_user else "Unknown"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{credit_note_id}/download")
|
||||
async def download_credit_note(
|
||||
credit_note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Download credit note PDF"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
# Get file content
|
||||
archival_service = DisputeArchivalService(db, current_user.kitchen_id)
|
||||
success, content = await archival_service.get_credit_note_content(credit_note)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{credit_note.credit_note_number}.pdf"'}
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{credit_note_id}")
|
||||
async def update_credit_note(
|
||||
credit_note_id: int,
|
||||
credit_note_number: Optional[str] = None,
|
||||
credit_date: Optional[str] = None,
|
||||
credit_amount: Optional[float] = None,
|
||||
reason: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update credit note details"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
if credit_note_number:
|
||||
credit_note.credit_note_number = credit_note_number
|
||||
|
||||
if credit_date:
|
||||
try:
|
||||
credit_note.credit_date = date_type.fromisoformat(credit_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if credit_amount is not None:
|
||||
credit_note.credit_amount = Decimal(str(credit_amount))
|
||||
|
||||
if reason is not None:
|
||||
credit_note.reason = reason
|
||||
|
||||
if notes is not None:
|
||||
credit_note.notes = notes
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/{credit_note_id}")
|
||||
async def delete_credit_note(
|
||||
credit_note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete credit note"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CreditNote).where(
|
||||
and_(
|
||||
CreditNote.id == credit_note_id,
|
||||
CreditNote.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
credit_note = result.scalar_one_or_none()
|
||||
|
||||
if not credit_note:
|
||||
raise HTTPException(status_code=404, detail="Credit note not found")
|
||||
|
||||
# Check if linked to dispute
|
||||
result = await db.execute(
|
||||
select(InvoiceDispute).where(InvoiceDispute.credit_note_id == credit_note_id)
|
||||
)
|
||||
dispute = result.scalar_one_or_none()
|
||||
|
||||
if dispute:
|
||||
# Unlink from dispute
|
||||
dispute.credit_note_id = None
|
||||
dispute.resolved_amount = None
|
||||
|
||||
await db.delete(credit_note)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted"}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,694 +0,0 @@
|
|||
"""
|
||||
Event Order API — create event orders, add recipes × quantities,
|
||||
generate aggregated shopping lists, and optionally create purchase orders.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.event_order import EventOrder, EventOrderItem
|
||||
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe
|
||||
from models.ingredient import Ingredient, IngredientSource, IngredientCategory
|
||||
from models.supplier import Supplier
|
||||
from auth import get_current_user, require_cap
|
||||
from api.ingredients import convert_to_standard, UNIT_CONVERSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
class EventOrderCreate(BaseModel):
|
||||
name: str
|
||||
event_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
class EventOrderUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
event_date: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
class EventOrderItemAdd(BaseModel):
|
||||
recipe_id: int
|
||||
quantity: int
|
||||
notes: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
class EventOrderItemUpdate(BaseModel):
|
||||
quantity: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
class BulkItemEntry(BaseModel):
|
||||
recipe_id: int
|
||||
quantity: int
|
||||
notes: Optional[str] = None
|
||||
|
||||
class EventOrderBulkAdd(BaseModel):
|
||||
items: list[BulkItemEntry]
|
||||
|
||||
class EventOrderResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
event_date: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str
|
||||
item_count: int = 0
|
||||
estimated_cost: Optional[float] = None
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
class EventOrderItemResponse(BaseModel):
|
||||
id: int
|
||||
recipe_id: int
|
||||
recipe_name: str = ""
|
||||
recipe_type: str = ""
|
||||
batch_portions: int = 1
|
||||
quantity: int
|
||||
cost_per_portion: Optional[float] = None
|
||||
subtotal: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
# ── Event Order CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_event_orders(
|
||||
status: Optional[str] = Query(None),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = (
|
||||
select(EventOrder)
|
||||
.options(selectinload(EventOrder.items))
|
||||
.where(EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
if status:
|
||||
query = query.where(EventOrder.status == status)
|
||||
|
||||
result = await db.execute(query.order_by(EventOrder.event_date.desc().nullslast(), EventOrder.created_at.desc()))
|
||||
orders = result.scalars().all()
|
||||
|
||||
responses = []
|
||||
for o in orders:
|
||||
responses.append(EventOrderResponse(
|
||||
id=o.id,
|
||||
name=o.name,
|
||||
event_date=str(o.event_date) if o.event_date else None,
|
||||
notes=o.notes,
|
||||
status=o.status,
|
||||
item_count=len(o.items) if o.items else 0,
|
||||
created_at=str(o.created_at) if o.created_at else "",
|
||||
updated_at=str(o.updated_at) if o.updated_at else "",
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_event_order(
|
||||
data: EventOrderCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order = EventOrder(
|
||||
kitchen_id=user.kitchen_id,
|
||||
name=data.name,
|
||||
event_date=data.event_date,
|
||||
notes=data.notes,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return {"id": order.id, "name": order.name}
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
async def get_event_order(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventOrder)
|
||||
.options(selectinload(EventOrder.items).selectinload(EventOrderItem.recipe))
|
||||
.where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
|
||||
items = []
|
||||
for item in sorted(order.items, key=lambda x: x.sort_order):
|
||||
recipe = item.recipe
|
||||
# Get cost per portion from latest snapshot
|
||||
from models.recipe import RecipeCostSnapshot
|
||||
snap_result = await db.execute(
|
||||
select(RecipeCostSnapshot)
|
||||
.where(RecipeCostSnapshot.recipe_id == item.recipe_id)
|
||||
.order_by(RecipeCostSnapshot.snapshot_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
snap = snap_result.scalar_one_or_none()
|
||||
cpp = float(snap.cost_per_portion) if snap else None
|
||||
subtotal = cpp * item.quantity if cpp else None
|
||||
|
||||
items.append(EventOrderItemResponse(
|
||||
id=item.id,
|
||||
recipe_id=item.recipe_id,
|
||||
recipe_name=recipe.name if recipe else "",
|
||||
recipe_type=recipe.recipe_type if recipe else "",
|
||||
batch_portions=recipe.batch_portions if recipe else 1,
|
||||
quantity=item.quantity,
|
||||
cost_per_portion=cpp,
|
||||
subtotal=round(subtotal, 2) if subtotal else None,
|
||||
notes=item.notes,
|
||||
sort_order=item.sort_order,
|
||||
))
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"name": order.name,
|
||||
"event_date": str(order.event_date) if order.event_date else None,
|
||||
"notes": order.notes,
|
||||
"status": order.status,
|
||||
"items": [i.model_dump() for i in items],
|
||||
"created_at": str(order.created_at) if order.created_at else "",
|
||||
"updated_at": str(order.updated_at) if order.updated_at else "",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{order_id}")
|
||||
async def update_event_order(
|
||||
order_id: int,
|
||||
data: EventOrderUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
if data.name is not None:
|
||||
order.name = data.name
|
||||
if data.event_date is not None:
|
||||
order.event_date = data.event_date
|
||||
if data.notes is not None:
|
||||
order.notes = data.notes
|
||||
if data.status is not None:
|
||||
order.status = data.status
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
async def delete_event_order(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
if order.status != "DRAFT":
|
||||
raise HTTPException(400, "Only DRAFT orders can be deleted")
|
||||
await db.delete(order)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Event Order Items ────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/items")
|
||||
async def add_item(
|
||||
order_id: int,
|
||||
data: EventOrderItemAdd,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
recipe = await db.execute(
|
||||
select(Recipe).where(Recipe.id == data.recipe_id, Recipe.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
if not recipe.scalar_one_or_none():
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
item = EventOrderItem(
|
||||
event_order_id=order_id,
|
||||
recipe_id=data.recipe_id,
|
||||
quantity=data.quantity,
|
||||
notes=data.notes,
|
||||
sort_order=data.sort_order,
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
return {"id": item.id}
|
||||
|
||||
|
||||
@router.post("/{order_id}/items/bulk")
|
||||
async def add_items_bulk(
|
||||
order_id: int,
|
||||
data: EventOrderBulkAdd,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add multiple recipes to an event order at once (e.g. from a menu)."""
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
|
||||
# Get existing recipe_ids on this order to detect duplicates
|
||||
existing_result = await db.execute(
|
||||
select(EventOrderItem.recipe_id).where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
existing_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
# Get max sort_order
|
||||
max_sort_result = await db.execute(
|
||||
select(func.coalesce(func.max(EventOrderItem.sort_order), -1))
|
||||
.where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
next_sort = (max_sort_result.scalar() or 0) + 1
|
||||
|
||||
# Validate all recipe_ids belong to this kitchen
|
||||
recipe_ids = [entry.recipe_id for entry in data.items]
|
||||
valid_result = await db.execute(
|
||||
select(Recipe.id).where(Recipe.id.in_(recipe_ids), Recipe.kitchen_id == user.kitchen_id)
|
||||
)
|
||||
valid_ids = {r[0] for r in valid_result.all()}
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
for entry in data.items:
|
||||
if entry.recipe_id not in valid_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
if entry.recipe_id in existing_ids:
|
||||
skipped += 1
|
||||
continue
|
||||
item = EventOrderItem(
|
||||
event_order_id=order_id,
|
||||
recipe_id=entry.recipe_id,
|
||||
quantity=entry.quantity,
|
||||
notes=entry.notes,
|
||||
sort_order=next_sort,
|
||||
)
|
||||
db.add(item)
|
||||
existing_ids.add(entry.recipe_id)
|
||||
next_sort += 1
|
||||
added += 1
|
||||
|
||||
await db.commit()
|
||||
return {"added": added, "skipped": skipped}
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}")
|
||||
async def update_item(
|
||||
item_id: int,
|
||||
data: EventOrderItemUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item not found")
|
||||
if data.quantity is not None:
|
||||
item.quantity = data.quantity
|
||||
if data.notes is not None:
|
||||
item.notes = data.notes
|
||||
if data.sort_order is not None:
|
||||
item.sort_order = data.sort_order
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}")
|
||||
async def delete_item(
|
||||
item_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(EventOrderItem).where(EventOrderItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Item not found")
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Shopping List ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _collect_ingredients_for_recipe(
|
||||
recipe_id: int,
|
||||
multiplier: float,
|
||||
db: AsyncSession,
|
||||
depth: int = 0,
|
||||
) -> dict[int, float]:
|
||||
"""Recursively collect ingredient quantities for a recipe × multiplier.
|
||||
Returns {ingredient_id: total_quantity_in_standard_unit}."""
|
||||
if depth > 5:
|
||||
return {}
|
||||
|
||||
result: dict[int, float] = defaultdict(float)
|
||||
|
||||
# Direct ingredients (yield-adjusted: divide by yield to get required purchase qty)
|
||||
ri_result = await db.execute(
|
||||
select(RecipeIngredient)
|
||||
.options(selectinload(RecipeIngredient.ingredient))
|
||||
.where(RecipeIngredient.recipe_id == recipe_id)
|
||||
)
|
||||
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
|
||||
for ri in ri_result.scalars().all():
|
||||
yld = float(ri.yield_percent) if ri.yield_percent else 100.0
|
||||
raw_qty = float(ri.quantity) * multiplier
|
||||
# Convert from display unit to standard unit if needed
|
||||
if ri.unit and ri.ingredient and ri.unit != ri.ingredient.standard_unit:
|
||||
from_base = _bases.get(ri.unit)
|
||||
to_base = _bases.get(ri.ingredient.standard_unit)
|
||||
if from_base and to_base:
|
||||
raw_qty = raw_qty * from_base / to_base
|
||||
adjusted_qty = raw_qty / (yld / 100) if yld > 0 else raw_qty
|
||||
result[ri.ingredient_id] += adjusted_qty
|
||||
|
||||
# Sub-recipe ingredients
|
||||
sr_result = await db.execute(
|
||||
select(RecipeSubRecipe).where(RecipeSubRecipe.parent_recipe_id == recipe_id)
|
||||
)
|
||||
for sr in sr_result.scalars().all():
|
||||
child_result = await db.execute(
|
||||
select(Recipe).where(Recipe.id == sr.child_recipe_id)
|
||||
)
|
||||
child_recipe = child_result.scalar_one_or_none()
|
||||
if not child_recipe:
|
||||
continue
|
||||
# Use unified output qty (handles both portioned and bulk)
|
||||
child_output_qty = float(child_recipe.batch_yield_qty) if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_qty else (child_recipe.batch_portions or 1)
|
||||
child_output_unit = child_recipe.batch_yield_unit if child_recipe.batch_output_type == "bulk" and child_recipe.batch_yield_unit else "portion"
|
||||
# Convert portions_needed to child output unit if different unit was used
|
||||
needed = float(sr.portions_needed)
|
||||
needed_unit = sr.portions_needed_unit or child_output_unit
|
||||
if needed_unit != child_output_unit:
|
||||
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
|
||||
if needed_unit in _bases and child_output_unit in _bases:
|
||||
needed = needed * _bases[needed_unit] / _bases[child_output_unit]
|
||||
child_multiplier = multiplier * (needed / child_output_qty)
|
||||
child_ings = await _collect_ingredients_for_recipe(sr.child_recipe_id, child_multiplier, db, depth + 1)
|
||||
for ing_id, qty in child_ings.items():
|
||||
result[ing_id] += qty
|
||||
|
||||
return dict(result)
|
||||
|
||||
|
||||
@router.get("/{order_id}/shopping-list")
|
||||
async def get_shopping_list(
|
||||
order_id: int,
|
||||
group_by_supplier: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Aggregated ingredient shopping list across all event order items."""
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
|
||||
# Get all items
|
||||
items_result = await db.execute(
|
||||
select(EventOrderItem)
|
||||
.options(selectinload(EventOrderItem.recipe))
|
||||
.where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
# Aggregate ingredient quantities across all recipes
|
||||
total_ingredients: dict[int, float] = defaultdict(float)
|
||||
recipe_breakdown: dict[int, list] = defaultdict(list) # ingredient_id -> [{recipe, qty}]
|
||||
|
||||
for item in items:
|
||||
recipe = item.recipe
|
||||
if not recipe:
|
||||
continue
|
||||
|
||||
# For dish: multiplier = quantity (servings) / output_qty
|
||||
# For component: multiplier = quantity (batches)
|
||||
if recipe.recipe_type == "component":
|
||||
multiplier = float(item.quantity) # each item.quantity = number of batches
|
||||
else:
|
||||
# Dishes are always portioned with batch_portions=1
|
||||
multiplier = float(item.quantity) / (recipe.batch_portions or 1)
|
||||
|
||||
ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
|
||||
for ing_id, qty in ings.items():
|
||||
total_ingredients[ing_id] += qty
|
||||
recipe_breakdown[ing_id].append({
|
||||
"recipe_name": recipe.name,
|
||||
"quantity": round(qty, 3),
|
||||
})
|
||||
|
||||
if not total_ingredients:
|
||||
return {"items": [], "by_supplier": {}}
|
||||
|
||||
# Load ingredient details and sources
|
||||
ing_ids = list(total_ingredients.keys())
|
||||
ing_result = await db.execute(
|
||||
select(Ingredient)
|
||||
.options(
|
||||
selectinload(Ingredient.category),
|
||||
selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
|
||||
)
|
||||
.where(Ingredient.id.in_(ing_ids))
|
||||
)
|
||||
ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
|
||||
|
||||
shopping_items = []
|
||||
by_supplier: dict[str, list] = defaultdict(list)
|
||||
|
||||
for ing_id, total_qty in sorted(total_ingredients.items(), key=lambda x: x[0]):
|
||||
ing = ingredients.get(ing_id)
|
||||
if not ing:
|
||||
continue
|
||||
|
||||
# Quantity already yield-adjusted during collection
|
||||
adjusted_qty = total_qty
|
||||
|
||||
# Source info
|
||||
sources = []
|
||||
for src in (ing.sources or []):
|
||||
pack_total = None
|
||||
suggested_packs = None
|
||||
cost_per_pack = None
|
||||
|
||||
if src.pack_quantity and src.unit_size and src.unit_size_type:
|
||||
pack_in_std = convert_to_standard(
|
||||
Decimal(str(src.pack_quantity)) * src.unit_size,
|
||||
src.unit_size_type,
|
||||
ing.standard_unit,
|
||||
)
|
||||
if pack_in_std and float(pack_in_std) > 0:
|
||||
pack_total = float(pack_in_std)
|
||||
suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
|
||||
if src.latest_unit_price:
|
||||
cost_per_pack = float(src.latest_unit_price)
|
||||
|
||||
source_info = {
|
||||
"supplier_id": src.supplier_id,
|
||||
"supplier_name": src.supplier.name if src.supplier else "",
|
||||
"product_code": src.product_code,
|
||||
"pack_description": f"{src.pack_quantity}×{src.unit_size}{src.unit_size_type}" if src.pack_quantity and src.unit_size else None,
|
||||
"pack_total_std_unit": pack_total,
|
||||
"suggested_packs": suggested_packs,
|
||||
"cost_per_pack": cost_per_pack,
|
||||
"subtotal": round(cost_per_pack * suggested_packs, 2) if cost_per_pack and suggested_packs else None,
|
||||
}
|
||||
sources.append(source_info)
|
||||
|
||||
if group_by_supplier:
|
||||
supplier_name = src.supplier.name if src.supplier else "Unknown"
|
||||
by_supplier[supplier_name].append({
|
||||
"ingredient_name": ing.name,
|
||||
"quantity_needed": round(adjusted_qty, 3),
|
||||
"unit": ing.standard_unit,
|
||||
**source_info,
|
||||
})
|
||||
|
||||
item_data = {
|
||||
"ingredient_id": ing.id,
|
||||
"ingredient_name": ing.name,
|
||||
"category": ing.category.name if ing.category else "Other",
|
||||
"total_quantity": round(total_qty, 3),
|
||||
"adjusted_quantity": round(adjusted_qty, 3),
|
||||
"unit": ing.standard_unit,
|
||||
"sources": sources,
|
||||
"recipe_breakdown": recipe_breakdown.get(ing_id, []),
|
||||
}
|
||||
shopping_items.append(item_data)
|
||||
|
||||
# Sort by category
|
||||
shopping_items.sort(key=lambda x: (x["category"], x["ingredient_name"]))
|
||||
|
||||
return {"items": shopping_items, "by_supplier": dict(by_supplier) if group_by_supplier else {}}
|
||||
|
||||
|
||||
# ── Generate Purchase Orders ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{order_id}/generate-po")
|
||||
async def generate_purchase_orders(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Generate purchase orders from shopping list, grouped by supplier."""
|
||||
from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
|
||||
|
||||
order = await _get_order(order_id, user.kitchen_id, db)
|
||||
|
||||
# Get shopping list grouped by supplier
|
||||
items_result = await db.execute(
|
||||
select(EventOrderItem)
|
||||
.options(selectinload(EventOrderItem.recipe))
|
||||
.where(EventOrderItem.event_order_id == order_id)
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
# Aggregate ingredient quantities
|
||||
total_ingredients: dict[int, float] = defaultdict(float)
|
||||
for item in items:
|
||||
recipe = item.recipe
|
||||
if not recipe:
|
||||
continue
|
||||
if recipe.recipe_type == "component":
|
||||
multiplier = float(item.quantity)
|
||||
else:
|
||||
multiplier = float(item.quantity) / (recipe.batch_portions or 1)
|
||||
ings = await _collect_ingredients_for_recipe(recipe.id, multiplier, db)
|
||||
for ing_id, qty in ings.items():
|
||||
total_ingredients[ing_id] += qty
|
||||
|
||||
if not total_ingredients:
|
||||
raise HTTPException(400, "No ingredients to order")
|
||||
|
||||
# Load ingredients with sources
|
||||
from sqlalchemy.orm import selectinload as si
|
||||
ing_result = await db.execute(
|
||||
select(Ingredient)
|
||||
.options(
|
||||
selectinload(Ingredient.sources).selectinload(IngredientSource.supplier),
|
||||
)
|
||||
.where(Ingredient.id.in_(list(total_ingredients.keys())))
|
||||
)
|
||||
ingredients = {ing.id: ing for ing in ing_result.scalars().all()}
|
||||
|
||||
# Group by supplier: {supplier_id: [{ingredient, qty, source}]}
|
||||
supplier_lines: dict[int, list] = defaultdict(list)
|
||||
unmapped = []
|
||||
|
||||
for ing_id, total_qty in total_ingredients.items():
|
||||
ing = ingredients.get(ing_id)
|
||||
if not ing:
|
||||
continue
|
||||
|
||||
# Quantity already yield-adjusted during collection
|
||||
adjusted_qty = total_qty
|
||||
|
||||
# Pick the most recent source (by latest_invoice_date)
|
||||
best_source = None
|
||||
for src in (ing.sources or []):
|
||||
if best_source is None or (src.latest_invoice_date and (
|
||||
not best_source.latest_invoice_date or src.latest_invoice_date > best_source.latest_invoice_date
|
||||
)):
|
||||
best_source = src
|
||||
|
||||
if best_source and best_source.supplier_id:
|
||||
pack_total = None
|
||||
suggested_packs = 1
|
||||
if best_source.pack_quantity and best_source.unit_size and best_source.unit_size_type:
|
||||
pack_in_std = convert_to_standard(
|
||||
Decimal(str(best_source.pack_quantity)) * best_source.unit_size,
|
||||
best_source.unit_size_type,
|
||||
ing.standard_unit,
|
||||
)
|
||||
if pack_in_std and float(pack_in_std) > 0:
|
||||
pack_total = float(pack_in_std)
|
||||
suggested_packs = int(adjusted_qty / pack_total) + (1 if adjusted_qty % pack_total > 0 else 0)
|
||||
|
||||
supplier_lines[best_source.supplier_id].append({
|
||||
"ingredient": ing,
|
||||
"source": best_source,
|
||||
"quantity": max(suggested_packs, 1),
|
||||
"unit_price": float(best_source.latest_unit_price) if best_source.latest_unit_price else 0,
|
||||
})
|
||||
else:
|
||||
unmapped.append(ing.name)
|
||||
|
||||
# Create one PO per supplier
|
||||
created_pos = []
|
||||
for supplier_id, lines in supplier_lines.items():
|
||||
total = sum(l["quantity"] * l["unit_price"] for l in lines)
|
||||
po = PurchaseOrder(
|
||||
kitchen_id=user.kitchen_id,
|
||||
supplier_id=supplier_id,
|
||||
order_date=order.event_date or date.today(),
|
||||
order_type="itemised",
|
||||
status="DRAFT",
|
||||
total_amount=Decimal(str(round(total, 2))),
|
||||
notes=f"Auto-generated from event order: {order.name}",
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(po)
|
||||
await db.flush()
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
line_total = round(line["quantity"] * line["unit_price"], 2)
|
||||
po_line = PurchaseOrderLineItem(
|
||||
purchase_order_id=po.id,
|
||||
kitchen_id=user.kitchen_id,
|
||||
product_code=line["source"].product_code,
|
||||
description=line["ingredient"].name,
|
||||
unit=line["ingredient"].standard_unit,
|
||||
unit_price=Decimal(str(line["unit_price"])),
|
||||
quantity=Decimal(str(line["quantity"])),
|
||||
total=Decimal(str(line_total)),
|
||||
line_number=idx + 1,
|
||||
source="event_order",
|
||||
)
|
||||
db.add(po_line)
|
||||
|
||||
created_pos.append({"id": po.id, "supplier_id": supplier_id})
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"created": len(created_pos),
|
||||
"purchase_orders": created_pos,
|
||||
"unmapped_ingredients": unmapped,
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_order(order_id: int, kitchen_id: int, db: AsyncSession) -> EventOrder:
|
||||
result = await db.execute(
|
||||
select(EventOrder).where(EventOrder.id == order_id, EventOrder.kitchen_id == kitchen_id)
|
||||
)
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Event order not found")
|
||||
return order
|
||||
|
|
@ -1,497 +0,0 @@
|
|||
"""
|
||||
Internal API for in-house apps — API key authentication, dish recipe data,
|
||||
food flag listings, menus. Prefix: /api/external/
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from database import get_db
|
||||
from models.settings import KitchenSettings
|
||||
from models.recipe import Recipe, RecipeIngredient, RecipeSubRecipe, RecipeImage
|
||||
from models.menu import Menu, MenuDivision, MenuItem
|
||||
from models.ingredient import Ingredient
|
||||
from models.food_flag import FoodFlagCategory, FoodFlag
|
||||
from api.food_flags import compute_recipe_flags
|
||||
from api.menus import _compute_staleness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def get_kitchen_from_api_key(
|
||||
x_api_key: str = Header(..., alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KitchenSettings:
|
||||
"""Authenticate via API key and return the kitchen settings."""
|
||||
if not x_api_key:
|
||||
raise HTTPException(401, "Missing X-API-Key header")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.api_key == x_api_key,
|
||||
KitchenSettings.api_key_enabled == True,
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
if not settings:
|
||||
raise HTTPException(401, "Invalid or disabled API key")
|
||||
return settings
|
||||
|
||||
|
||||
@router.get("/recipes/dishes")
|
||||
async def list_dish_recipes(
|
||||
include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
|
||||
include_costs: bool = Query(False),
|
||||
exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List non-archived dish recipes for external consumption."""
|
||||
query = (
|
||||
select(Recipe)
|
||||
.options(
|
||||
selectinload(Recipe.menu_section),
|
||||
selectinload(Recipe.images),
|
||||
)
|
||||
.where(
|
||||
Recipe.kitchen_id == kitchen.kitchen_id,
|
||||
Recipe.recipe_type == "dish",
|
||||
Recipe.is_archived == False,
|
||||
)
|
||||
.order_by(Recipe.name)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
recipes = result.scalars().all()
|
||||
|
||||
exclude_flag_ids = set()
|
||||
if exclude_flags:
|
||||
exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
|
||||
|
||||
items = []
|
||||
for r in recipes:
|
||||
# Get flags
|
||||
flags = await compute_recipe_flags(r.id, kitchen.kitchen_id, db)
|
||||
active_flags = [f for f in flags if f.is_active]
|
||||
|
||||
# Check exclude filter
|
||||
if exclude_flag_ids:
|
||||
recipe_flag_ids = {f.food_flag_id for f in active_flags}
|
||||
if recipe_flag_ids & exclude_flag_ids:
|
||||
continue
|
||||
|
||||
flag_data = [
|
||||
{
|
||||
"id": f.food_flag_id,
|
||||
"name": f.flag_name,
|
||||
"code": f.flag_code,
|
||||
"icon": f.flag_icon,
|
||||
"category": f.category_name,
|
||||
"propagation": f.propagation_type,
|
||||
"excludable": f.excludable_on_request,
|
||||
}
|
||||
for f in active_flags
|
||||
]
|
||||
|
||||
item = {
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"menu_section": r.menu_section.name if r.menu_section else None,
|
||||
"prep_time_minutes": r.prep_time_minutes,
|
||||
"cook_time_minutes": r.cook_time_minutes,
|
||||
"flags": flag_data,
|
||||
"images": [
|
||||
{"id": img.id, "caption": img.caption, "image_type": img.image_type}
|
||||
for img in (r.images or [])
|
||||
],
|
||||
}
|
||||
|
||||
# Include costs if requested
|
||||
if include_costs:
|
||||
from api.recipes import _calc_recipe_cost
|
||||
cost_data = await _calc_recipe_cost(r.id, db)
|
||||
item["cost_per_portion"] = cost_data.get("cost_per_portion")
|
||||
item["total_cost"] = cost_data.get("total_cost_recent")
|
||||
|
||||
# Include ingredients if requested
|
||||
if include_ingredients != "none":
|
||||
item["ingredients"] = await _get_recipe_ingredients(r.id, kitchen.kitchen_id, db, include_ingredients)
|
||||
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/recipes/plated")
|
||||
async def list_plated_recipes_compat(
|
||||
include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
|
||||
include_costs: bool = Query(False),
|
||||
exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude"),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Backward-compatible alias for /recipes/dishes."""
|
||||
return await list_dish_recipes(include_ingredients, include_costs, exclude_flags, kitchen, db)
|
||||
|
||||
|
||||
@router.get("/recipes/{recipe_id}")
|
||||
async def get_dish_recipe(
|
||||
recipe_id: int,
|
||||
include_ingredients: str = Query("none", regex="^(none|flat|nested)$"),
|
||||
include_costs: bool = Query(False),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single dish recipe for external consumption."""
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.options(selectinload(Recipe.menu_section), selectinload(Recipe.images))
|
||||
.where(
|
||||
Recipe.id == recipe_id,
|
||||
Recipe.kitchen_id == kitchen.kitchen_id,
|
||||
Recipe.recipe_type == "dish",
|
||||
Recipe.is_archived == False,
|
||||
)
|
||||
)
|
||||
recipe = result.scalar_one_or_none()
|
||||
if not recipe:
|
||||
raise HTTPException(404, "Recipe not found")
|
||||
|
||||
flags = await compute_recipe_flags(recipe.id, kitchen.kitchen_id, db)
|
||||
active_flags = [f for f in flags if f.is_active]
|
||||
|
||||
item = {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"description": recipe.description,
|
||||
"menu_section": recipe.menu_section.name if recipe.menu_section else None,
|
||||
"prep_time_minutes": recipe.prep_time_minutes,
|
||||
"cook_time_minutes": recipe.cook_time_minutes,
|
||||
"flags": [
|
||||
{
|
||||
"id": f.food_flag_id,
|
||||
"name": f.flag_name,
|
||||
"code": f.flag_code,
|
||||
"icon": f.flag_icon,
|
||||
"category": f.category_name,
|
||||
"propagation": f.propagation_type,
|
||||
"excludable": f.excludable_on_request,
|
||||
}
|
||||
for f in active_flags
|
||||
],
|
||||
"images": [
|
||||
{"id": img.id, "caption": img.caption, "image_type": img.image_type}
|
||||
for img in (recipe.images or [])
|
||||
],
|
||||
}
|
||||
|
||||
if include_costs:
|
||||
from api.recipes import _calc_recipe_cost
|
||||
cost_data = await _calc_recipe_cost(recipe.id, db)
|
||||
item["cost_per_portion"] = cost_data.get("cost_per_portion")
|
||||
item["total_cost"] = cost_data.get("total_cost_recent")
|
||||
|
||||
if include_ingredients != "none":
|
||||
item["ingredients"] = await _get_recipe_ingredients(recipe.id, kitchen.kitchen_id, db, include_ingredients)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/food-flags")
|
||||
async def list_food_flags(
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all flag categories and flags for external apps."""
|
||||
result = await db.execute(
|
||||
select(FoodFlagCategory)
|
||||
.options(selectinload(FoodFlagCategory.flags))
|
||||
.where(FoodFlagCategory.kitchen_id == kitchen.kitchen_id)
|
||||
.order_by(FoodFlagCategory.sort_order)
|
||||
)
|
||||
categories = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": cat.id,
|
||||
"name": cat.name,
|
||||
"propagation_type": cat.propagation_type,
|
||||
"flags": [
|
||||
{"id": f.id, "name": f.name, "code": f.code, "icon": f.icon}
|
||||
for f in sorted(cat.flags, key=lambda x: x.sort_order)
|
||||
],
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
|
||||
|
||||
async def _get_recipe_ingredients(recipe_id: int, kitchen_id: int, db: AsyncSession, mode: str) -> list:
|
||||
"""Get ingredient list for external API — flat (consolidated) or nested (sub-recipe breakdown)."""
|
||||
if mode == "flat":
|
||||
# Consolidated list
|
||||
from api.event_orders import _collect_ingredients_for_recipe
|
||||
ing_qtys = await _collect_ingredients_for_recipe(recipe_id, 1.0, db)
|
||||
if not ing_qtys:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Ingredient).where(Ingredient.id.in_(list(ing_qtys.keys())))
|
||||
)
|
||||
ingredients = {ing.id: ing for ing in result.scalars().all()}
|
||||
|
||||
return [
|
||||
{
|
||||
"ingredient_id": ing_id,
|
||||
"name": ingredients[ing_id].name if ing_id in ingredients else "?",
|
||||
"quantity": round(qty, 3),
|
||||
"unit": ingredients[ing_id].standard_unit if ing_id in ingredients else "",
|
||||
}
|
||||
for ing_id, qty in sorted(ing_qtys.items())
|
||||
]
|
||||
|
||||
elif mode == "nested":
|
||||
# Show sub-recipe breakdown
|
||||
ri_result = await db.execute(
|
||||
select(RecipeIngredient)
|
||||
.options(selectinload(RecipeIngredient.ingredient))
|
||||
.where(RecipeIngredient.recipe_id == recipe_id)
|
||||
.order_by(RecipeIngredient.sort_order)
|
||||
)
|
||||
direct = [
|
||||
{
|
||||
"ingredient_id": ri.ingredient_id,
|
||||
"name": ri.ingredient.name if ri.ingredient else "?",
|
||||
"quantity": float(ri.quantity),
|
||||
"unit": ri.ingredient.standard_unit if ri.ingredient else "",
|
||||
"source": "direct",
|
||||
}
|
||||
for ri in ri_result.scalars().all()
|
||||
]
|
||||
|
||||
sr_result = await db.execute(
|
||||
select(RecipeSubRecipe)
|
||||
.options(selectinload(RecipeSubRecipe.child_recipe))
|
||||
.where(RecipeSubRecipe.parent_recipe_id == recipe_id)
|
||||
)
|
||||
sub_recipe_ings = []
|
||||
for sr in sr_result.scalars().all():
|
||||
child = sr.child_recipe
|
||||
if not child:
|
||||
continue
|
||||
cri_result = await db.execute(
|
||||
select(RecipeIngredient)
|
||||
.options(selectinload(RecipeIngredient.ingredient))
|
||||
.where(RecipeIngredient.recipe_id == child.id)
|
||||
)
|
||||
# Use unified output qty for bulk/portioned child recipes
|
||||
child_output_qty = float(child.batch_yield_qty) if child.batch_output_type == "bulk" and child.batch_yield_qty else (child.batch_portions or 1)
|
||||
child_output_unit = child.batch_yield_unit if child.batch_output_type == "bulk" and child.batch_yield_unit else "portion"
|
||||
# Convert portions_needed to child output unit if different unit was used
|
||||
needed = float(sr.portions_needed)
|
||||
needed_unit = sr.portions_needed_unit or child_output_unit
|
||||
if needed_unit != child_output_unit:
|
||||
_bases = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "ltr": 1000.0}
|
||||
if needed_unit in _bases and child_output_unit in _bases:
|
||||
needed = needed * _bases[needed_unit] / _bases[child_output_unit]
|
||||
scale = needed / child_output_qty
|
||||
for cri in cri_result.scalars().all():
|
||||
sub_recipe_ings.append({
|
||||
"ingredient_id": cri.ingredient_id,
|
||||
"name": cri.ingredient.name if cri.ingredient else "?",
|
||||
"quantity": round(float(cri.quantity) * scale, 3),
|
||||
"unit": cri.ingredient.standard_unit if cri.ingredient else "",
|
||||
"source": f"sub-recipe: {child.name}",
|
||||
})
|
||||
|
||||
return direct + sub_recipe_ings
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ── Menu Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/menus")
|
||||
async def list_menus_external(
|
||||
exclude_flags: Optional[str] = Query(None, description="Comma-separated flag IDs to exclude items containing those allergens"),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List active menus with divisions and items served from snapshots."""
|
||||
result = await db.execute(
|
||||
select(Menu)
|
||||
.options(
|
||||
selectinload(Menu.divisions).selectinload(MenuDivision.items),
|
||||
selectinload(Menu.items),
|
||||
)
|
||||
.where(Menu.kitchen_id == kitchen.kitchen_id, Menu.is_active == True)
|
||||
.order_by(Menu.sort_order)
|
||||
)
|
||||
menus = result.scalars().all()
|
||||
|
||||
exclude_flag_ids = set()
|
||||
if exclude_flags:
|
||||
exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
|
||||
|
||||
menus_data = []
|
||||
for menu in menus:
|
||||
all_items = [i for i in (menu.items or []) if i.recipe_id is not None]
|
||||
staleness = await _compute_staleness(all_items, db)
|
||||
|
||||
divisions_data = []
|
||||
for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
|
||||
div_items = sorted(
|
||||
[i for i in all_items if i.division_id == div.id],
|
||||
key=lambda i: i.sort_order,
|
||||
)
|
||||
items_data = []
|
||||
for item in div_items:
|
||||
snapshot = item.snapshot_json or {}
|
||||
confirmed_flags = snapshot.get("confirmed_flags", [])
|
||||
|
||||
# Apply exclude filter
|
||||
if exclude_flag_ids:
|
||||
item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")}
|
||||
if item_flag_ids & exclude_flag_ids:
|
||||
continue
|
||||
|
||||
stale_info = staleness.get(item.id, {"is_stale": False})
|
||||
|
||||
items_data.append({
|
||||
"id": item.id,
|
||||
"display_name": snapshot.get("display_name", item.display_name),
|
||||
"description": snapshot.get("description", item.description),
|
||||
"price": snapshot.get("price", str(item.price) if item.price else None),
|
||||
"flags": [
|
||||
{"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"),
|
||||
"category": f.get("category"), "excludable": f.get("excludable", False)}
|
||||
for f in confirmed_flags
|
||||
],
|
||||
"is_stale": stale_info.get("is_stale", False),
|
||||
"has_image": bool(item.image_path),
|
||||
})
|
||||
|
||||
if items_data:
|
||||
divisions_data.append({
|
||||
"name": div.name,
|
||||
"items": items_data,
|
||||
})
|
||||
|
||||
menus_data.append({
|
||||
"id": menu.id,
|
||||
"name": menu.name,
|
||||
"description": menu.description,
|
||||
"divisions": divisions_data,
|
||||
})
|
||||
|
||||
return menus_data
|
||||
|
||||
|
||||
@router.get("/menus/{menu_id}")
|
||||
async def get_menu_external(
|
||||
menu_id: int,
|
||||
exclude_flags: Optional[str] = Query(None),
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Single menu detail for external consumption."""
|
||||
result = await db.execute(
|
||||
select(Menu)
|
||||
.options(
|
||||
selectinload(Menu.divisions).selectinload(MenuDivision.items),
|
||||
selectinload(Menu.items),
|
||||
)
|
||||
.where(
|
||||
Menu.id == menu_id,
|
||||
Menu.kitchen_id == kitchen.kitchen_id,
|
||||
Menu.is_active == True,
|
||||
)
|
||||
)
|
||||
menu = result.scalar_one_or_none()
|
||||
if not menu:
|
||||
raise HTTPException(404, "Menu not found")
|
||||
|
||||
exclude_flag_ids = set()
|
||||
if exclude_flags:
|
||||
exclude_flag_ids = {int(x.strip()) for x in exclude_flags.split(",") if x.strip().isdigit()}
|
||||
|
||||
all_items = [i for i in (menu.items or []) if i.recipe_id is not None]
|
||||
staleness = await _compute_staleness(all_items, db)
|
||||
|
||||
divisions_data = []
|
||||
for div in sorted(menu.divisions or [], key=lambda d: d.sort_order):
|
||||
div_items = sorted(
|
||||
[i for i in all_items if i.division_id == div.id],
|
||||
key=lambda i: i.sort_order,
|
||||
)
|
||||
items_data = []
|
||||
for item in div_items:
|
||||
snapshot = item.snapshot_json or {}
|
||||
confirmed_flags = snapshot.get("confirmed_flags", [])
|
||||
|
||||
if exclude_flag_ids:
|
||||
item_flag_ids = {f.get("id") for f in confirmed_flags if f.get("id")}
|
||||
if item_flag_ids & exclude_flag_ids:
|
||||
continue
|
||||
|
||||
stale_info = staleness.get(item.id, {"is_stale": False})
|
||||
|
||||
items_data.append({
|
||||
"id": item.id,
|
||||
"display_name": snapshot.get("display_name", item.display_name),
|
||||
"description": snapshot.get("description", item.description),
|
||||
"price": snapshot.get("price", str(item.price) if item.price else None),
|
||||
"flags": [
|
||||
{"name": f.get("name"), "code": f.get("code"), "icon": f.get("icon"),
|
||||
"category": f.get("category"), "excludable": f.get("excludable", False)}
|
||||
for f in confirmed_flags
|
||||
],
|
||||
"is_stale": stale_info.get("is_stale", False),
|
||||
"has_image": bool(item.image_path),
|
||||
})
|
||||
|
||||
if items_data:
|
||||
divisions_data.append({
|
||||
"name": div.name,
|
||||
"items": items_data,
|
||||
})
|
||||
|
||||
return {
|
||||
"id": menu.id,
|
||||
"name": menu.name,
|
||||
"description": menu.description,
|
||||
"divisions": divisions_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/menus/{menu_id}/items/{item_id}/image")
|
||||
async def serve_menu_item_image_external(
|
||||
menu_id: int,
|
||||
item_id: int,
|
||||
kitchen: KitchenSettings = Depends(get_kitchen_from_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Serve a menu item image via API key authentication."""
|
||||
result = await db.execute(
|
||||
select(MenuItem)
|
||||
.join(Menu, MenuItem.menu_id == Menu.id)
|
||||
.where(
|
||||
MenuItem.id == item_id,
|
||||
MenuItem.menu_id == menu_id,
|
||||
Menu.kitchen_id == kitchen.kitchen_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item or not item.image_path:
|
||||
raise HTTPException(404, "Image not found")
|
||||
|
||||
if not os.path.exists(item.image_path):
|
||||
raise HTTPException(404, "Image file not found")
|
||||
|
||||
return FileResponse(item.image_path)
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.field_mapping import (
|
||||
FieldMapping,
|
||||
AZURE_INVOICE_FIELDS,
|
||||
AZURE_LINE_ITEM_FIELDS,
|
||||
TARGET_INVOICE_FIELDS,
|
||||
TARGET_LINE_ITEM_FIELDS
|
||||
)
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FieldMappingResponse(BaseModel):
|
||||
id: int
|
||||
supplier_id: int | None
|
||||
source_field: str
|
||||
target_field: str
|
||||
field_type: str
|
||||
transform: str
|
||||
priority: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FieldMappingCreate(BaseModel):
|
||||
supplier_id: Optional[int] = None
|
||||
source_field: str
|
||||
target_field: str
|
||||
field_type: str = "invoice"
|
||||
transform: str = "direct"
|
||||
priority: int = 0
|
||||
|
||||
|
||||
class FieldMappingUpdate(BaseModel):
|
||||
source_field: Optional[str] = None
|
||||
target_field: Optional[str] = None
|
||||
field_type: Optional[str] = None
|
||||
transform: Optional[str] = None
|
||||
priority: Optional[int] = None
|
||||
|
||||
|
||||
class FieldOptionsResponse(BaseModel):
|
||||
azure_invoice_fields: list[str]
|
||||
azure_line_item_fields: list[str]
|
||||
target_invoice_fields: list[str]
|
||||
target_line_item_fields: list[str]
|
||||
|
||||
|
||||
@router.get("/options", response_model=FieldOptionsResponse)
|
||||
async def get_field_options():
|
||||
"""Get available field names for creating mappings"""
|
||||
return FieldOptionsResponse(
|
||||
azure_invoice_fields=AZURE_INVOICE_FIELDS,
|
||||
azure_line_item_fields=AZURE_LINE_ITEM_FIELDS,
|
||||
target_invoice_fields=TARGET_INVOICE_FIELDS,
|
||||
target_line_item_fields=TARGET_LINE_ITEM_FIELDS
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FieldMappingResponse])
|
||||
async def list_field_mappings(
|
||||
supplier_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List field mappings for the current kitchen, optionally filtered by supplier"""
|
||||
query = select(FieldMapping).where(
|
||||
FieldMapping.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
|
||||
if supplier_id is not None:
|
||||
# Get mappings for specific supplier OR global (supplier_id=null)
|
||||
query = query.where(
|
||||
(FieldMapping.supplier_id == supplier_id) |
|
||||
(FieldMapping.supplier_id.is_(None))
|
||||
)
|
||||
|
||||
query = query.order_by(FieldMapping.priority.desc(), FieldMapping.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
mappings = result.scalars().all()
|
||||
|
||||
return [
|
||||
FieldMappingResponse(
|
||||
id=m.id,
|
||||
supplier_id=m.supplier_id,
|
||||
source_field=m.source_field,
|
||||
target_field=m.target_field,
|
||||
field_type=m.field_type,
|
||||
transform=m.transform,
|
||||
priority=m.priority
|
||||
)
|
||||
for m in mappings
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=FieldMappingResponse)
|
||||
async def create_field_mapping(
|
||||
mapping: FieldMappingCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new field mapping"""
|
||||
# Validate target field
|
||||
valid_targets = (
|
||||
TARGET_INVOICE_FIELDS if mapping.field_type == "invoice"
|
||||
else TARGET_LINE_ITEM_FIELDS
|
||||
)
|
||||
if mapping.target_field not in valid_targets:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid target field. Valid options: {valid_targets}"
|
||||
)
|
||||
|
||||
new_mapping = FieldMapping(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
supplier_id=mapping.supplier_id,
|
||||
source_field=mapping.source_field,
|
||||
target_field=mapping.target_field,
|
||||
field_type=mapping.field_type,
|
||||
transform=mapping.transform,
|
||||
priority=mapping.priority
|
||||
)
|
||||
|
||||
db.add(new_mapping)
|
||||
await db.commit()
|
||||
await db.refresh(new_mapping)
|
||||
|
||||
return FieldMappingResponse(
|
||||
id=new_mapping.id,
|
||||
supplier_id=new_mapping.supplier_id,
|
||||
source_field=new_mapping.source_field,
|
||||
target_field=new_mapping.target_field,
|
||||
field_type=new_mapping.field_type,
|
||||
transform=new_mapping.transform,
|
||||
priority=new_mapping.priority
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{mapping_id}", response_model=FieldMappingResponse)
|
||||
async def update_field_mapping(
|
||||
mapping_id: int,
|
||||
update: FieldMappingUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a field mapping"""
|
||||
result = await db.execute(
|
||||
select(FieldMapping).where(
|
||||
FieldMapping.id == mapping_id,
|
||||
FieldMapping.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Field mapping not found")
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(mapping, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(mapping)
|
||||
|
||||
return FieldMappingResponse(
|
||||
id=mapping.id,
|
||||
supplier_id=mapping.supplier_id,
|
||||
source_field=mapping.source_field,
|
||||
target_field=mapping.target_field,
|
||||
field_type=mapping.field_type,
|
||||
transform=mapping.transform,
|
||||
priority=mapping.priority
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{mapping_id}")
|
||||
async def delete_field_mapping(
|
||||
mapping_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a field mapping"""
|
||||
result = await db.execute(
|
||||
select(FieldMapping).where(
|
||||
FieldMapping.id == mapping_id,
|
||||
FieldMapping.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Field mapping not found")
|
||||
|
||||
await db.delete(mapping)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Field mapping deleted"}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,341 +0,0 @@
|
|||
"""
|
||||
IMAP Email Inbox API endpoints for settings and sync control.
|
||||
"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from models.email_processing import EmailProcessingLog
|
||||
from services.imap_sync import ImapSyncService
|
||||
|
||||
router = APIRouter(prefix="/imap", tags=["IMAP"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class ImapSettingsResponse(BaseModel):
|
||||
imap_host: Optional[str]
|
||||
imap_port: Optional[int]
|
||||
imap_use_ssl: bool
|
||||
imap_username: Optional[str]
|
||||
imap_password_set: bool # Don't expose actual password
|
||||
imap_folder: Optional[str]
|
||||
imap_poll_interval: int
|
||||
imap_enabled: bool
|
||||
imap_confidence_threshold: Optional[float]
|
||||
imap_last_sync: Optional[str]
|
||||
|
||||
|
||||
class ImapSettingsUpdate(BaseModel):
|
||||
imap_host: Optional[str] = None
|
||||
imap_port: Optional[int] = None
|
||||
imap_use_ssl: Optional[bool] = None
|
||||
imap_username: Optional[str] = None
|
||||
imap_password: Optional[str] = None # Only set if provided
|
||||
imap_folder: Optional[str] = None
|
||||
imap_poll_interval: Optional[int] = None
|
||||
imap_enabled: Optional[bool] = None
|
||||
imap_confidence_threshold: Optional[float] = None
|
||||
|
||||
|
||||
class ImapTestRequest(BaseModel):
|
||||
imap_host: Optional[str] = None
|
||||
imap_port: Optional[int] = None
|
||||
imap_use_ssl: Optional[bool] = None
|
||||
imap_username: Optional[str] = None
|
||||
imap_password: Optional[str] = None
|
||||
|
||||
|
||||
class EmailLogResponse(BaseModel):
|
||||
id: int
|
||||
message_id: str
|
||||
email_subject: Optional[str]
|
||||
email_from: Optional[str]
|
||||
email_date: Optional[str]
|
||||
attachments_count: int
|
||||
invoices_created: int
|
||||
confident_invoices: int
|
||||
marked_as_read: bool
|
||||
processing_status: str
|
||||
error_message: Optional[str]
|
||||
invoice_ids: Optional[list[int]]
|
||||
processed_at: str
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings", response_model=ImapSettingsResponse)
|
||||
async def get_imap_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get IMAP settings (password masked)"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
return ImapSettingsResponse(
|
||||
imap_host=settings.imap_host,
|
||||
imap_port=settings.imap_port,
|
||||
imap_use_ssl=settings.imap_use_ssl,
|
||||
imap_username=settings.imap_username,
|
||||
imap_password_set=bool(settings.imap_password),
|
||||
imap_folder=settings.imap_folder,
|
||||
imap_poll_interval=settings.imap_poll_interval,
|
||||
imap_enabled=settings.imap_enabled,
|
||||
imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None,
|
||||
imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=ImapSettingsResponse)
|
||||
async def update_imap_settings(
|
||||
update: ImapSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update IMAP settings (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Update only provided fields
|
||||
if update.imap_host is not None:
|
||||
settings.imap_host = update.imap_host
|
||||
if update.imap_port is not None:
|
||||
settings.imap_port = update.imap_port
|
||||
if update.imap_use_ssl is not None:
|
||||
settings.imap_use_ssl = update.imap_use_ssl
|
||||
if update.imap_username is not None:
|
||||
settings.imap_username = update.imap_username
|
||||
if update.imap_password is not None and update.imap_password:
|
||||
settings.imap_password = update.imap_password
|
||||
if update.imap_folder is not None:
|
||||
settings.imap_folder = update.imap_folder
|
||||
if update.imap_poll_interval is not None:
|
||||
settings.imap_poll_interval = update.imap_poll_interval
|
||||
if update.imap_enabled is not None:
|
||||
settings.imap_enabled = update.imap_enabled
|
||||
if update.imap_confidence_threshold is not None:
|
||||
settings.imap_confidence_threshold = Decimal(str(update.imap_confidence_threshold))
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ImapSettingsResponse(
|
||||
imap_host=settings.imap_host,
|
||||
imap_port=settings.imap_port,
|
||||
imap_use_ssl=settings.imap_use_ssl,
|
||||
imap_username=settings.imap_username,
|
||||
imap_password_set=bool(settings.imap_password),
|
||||
imap_folder=settings.imap_folder,
|
||||
imap_poll_interval=settings.imap_poll_interval,
|
||||
imap_enabled=settings.imap_enabled,
|
||||
imap_confidence_threshold=float(settings.imap_confidence_threshold) if settings.imap_confidence_threshold else None,
|
||||
imap_last_sync=settings.imap_last_sync.isoformat() if settings.imap_last_sync else None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_imap_connection(
|
||||
request: ImapTestRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test IMAP connection with provided or saved settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Get current settings
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Use provided values or fall back to saved settings
|
||||
test_settings = KitchenSettings(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
imap_host=request.imap_host or settings.imap_host,
|
||||
imap_port=request.imap_port or settings.imap_port or 993,
|
||||
imap_use_ssl=request.imap_use_ssl if request.imap_use_ssl is not None else settings.imap_use_ssl,
|
||||
imap_username=request.imap_username or settings.imap_username,
|
||||
imap_password=request.imap_password or settings.imap_password
|
||||
)
|
||||
|
||||
if not test_settings.imap_host or not test_settings.imap_password:
|
||||
return {"success": False, "error": "IMAP host and password are required"}
|
||||
|
||||
# Test connection
|
||||
sync_service = ImapSyncService(current_user.kitchen_id, db)
|
||||
sync_service._settings = test_settings
|
||||
result = await sync_service.test_connection()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync-now")
|
||||
async def trigger_manual_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manually trigger inbox sync (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Get settings
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not settings.imap_host or not settings.imap_password:
|
||||
raise HTTPException(status_code=400, detail="IMAP settings not configured")
|
||||
|
||||
# Run sync
|
||||
try:
|
||||
sync_service = ImapSyncService(current_user.kitchen_id, db)
|
||||
results = await sync_service.process_inbox()
|
||||
return {
|
||||
"success": True,
|
||||
"results": results
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Manual IMAP sync failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# ============ Log Endpoints ============
|
||||
|
||||
@router.get("/logs", response_model=list[EmailLogResponse])
|
||||
async def get_processing_logs(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get recent email processing logs"""
|
||||
result = await db.execute(
|
||||
select(EmailProcessingLog)
|
||||
.where(EmailProcessingLog.kitchen_id == current_user.kitchen_id)
|
||||
.order_by(desc(EmailProcessingLog.processed_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
EmailLogResponse(
|
||||
id=log.id,
|
||||
message_id=log.message_id,
|
||||
email_subject=log.email_subject,
|
||||
email_from=log.email_from,
|
||||
email_date=log.email_date.isoformat() if log.email_date else None,
|
||||
attachments_count=log.attachments_count,
|
||||
invoices_created=log.invoices_created,
|
||||
confident_invoices=log.confident_invoices,
|
||||
marked_as_read=log.marked_as_read,
|
||||
processing_status=log.processing_status,
|
||||
error_message=log.error_message,
|
||||
invoice_ids=log.invoice_ids,
|
||||
processed_at=log.processed_at.isoformat()
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/logs/stats")
|
||||
async def get_sync_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get sync statistics"""
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Last 24 hours
|
||||
result_24h = await db.execute(
|
||||
select(
|
||||
func.count(EmailProcessingLog.id),
|
||||
func.sum(EmailProcessingLog.invoices_created),
|
||||
func.sum(EmailProcessingLog.confident_invoices)
|
||||
).where(
|
||||
EmailProcessingLog.kitchen_id == current_user.kitchen_id,
|
||||
EmailProcessingLog.processed_at >= now - timedelta(hours=24)
|
||||
)
|
||||
)
|
||||
stats_24h = result_24h.one()
|
||||
|
||||
# Last 7 days
|
||||
result_7d = await db.execute(
|
||||
select(
|
||||
func.count(EmailProcessingLog.id),
|
||||
func.sum(EmailProcessingLog.invoices_created),
|
||||
func.sum(EmailProcessingLog.confident_invoices)
|
||||
).where(
|
||||
EmailProcessingLog.kitchen_id == current_user.kitchen_id,
|
||||
EmailProcessingLog.processed_at >= now - timedelta(days=7)
|
||||
)
|
||||
)
|
||||
stats_7d = result_7d.one()
|
||||
|
||||
# Last 30 days
|
||||
result_30d = await db.execute(
|
||||
select(
|
||||
func.count(EmailProcessingLog.id),
|
||||
func.sum(EmailProcessingLog.invoices_created),
|
||||
func.sum(EmailProcessingLog.confident_invoices)
|
||||
).where(
|
||||
EmailProcessingLog.kitchen_id == current_user.kitchen_id,
|
||||
EmailProcessingLog.processed_at >= now - timedelta(days=30)
|
||||
)
|
||||
)
|
||||
stats_30d = result_30d.one()
|
||||
|
||||
return {
|
||||
"last_24h": {
|
||||
"emails_processed": stats_24h[0] or 0,
|
||||
"invoices_created": int(stats_24h[1] or 0),
|
||||
"confident_invoices": int(stats_24h[2] or 0)
|
||||
},
|
||||
"last_7d": {
|
||||
"emails_processed": stats_7d[0] or 0,
|
||||
"invoices_created": int(stats_7d[1] or 0),
|
||||
"confident_invoices": int(stats_7d[2] or 0)
|
||||
},
|
||||
"last_30d": {
|
||||
"emails_processed": stats_30d[0] or 0,
|
||||
"invoices_created": int(stats_30d[1] or 0),
|
||||
"confident_invoices": int(stats_30d[2] or 0)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,64 +0,0 @@
|
|||
"""
|
||||
Internal API — endpoints consumed by other stack apps (not public).
|
||||
nginx denies /kitchen/api/internal/ from the public side; these endpoints
|
||||
are called directly on the backend port (8000) from within the docker bridge.
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from database import get_db
|
||||
from auth import verify_internal_secret
|
||||
from models.resos import ResosBooking
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/internal/resos/bookings")
|
||||
async def internal_resos_bookings(
|
||||
booking_date: Optional[str] = Query(None, alias="date"),
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cached ResOS bookings for a given date — consumed by the KDS app bookings screen.
|
||||
KDS calls: GET http://10.10.10.110:8000/api/internal/resos/bookings?date=YYYY-MM-DD
|
||||
Authorization: Bearer {STACK_INTERNAL_SECRET}
|
||||
"""
|
||||
try:
|
||||
target_date = date.fromisoformat(booking_date) if booking_date else date.today()
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid date format — use YYYY-MM-DD")
|
||||
|
||||
result = await db.execute(
|
||||
select(ResosBooking)
|
||||
.where(
|
||||
ResosBooking.kitchen_id == 1,
|
||||
ResosBooking.booking_date == target_date,
|
||||
)
|
||||
.order_by(ResosBooking.booking_time)
|
||||
)
|
||||
bookings = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": b.id,
|
||||
"resos_booking_id": b.resos_booking_id,
|
||||
"booking_date": b.booking_date.isoformat(),
|
||||
"booking_time": b.booking_time.strftime("%H:%M"),
|
||||
"people": b.people,
|
||||
"status": b.status,
|
||||
"seating_area": b.seating_area,
|
||||
"table_name": b.table_name,
|
||||
"hotel_booking_number": b.hotel_booking_number,
|
||||
"is_hotel_guest": b.is_hotel_guest,
|
||||
"is_dbb": b.is_dbb,
|
||||
"allergies": b.allergies,
|
||||
"notes": b.notes,
|
||||
"opening_hour_name": b.opening_hour_name,
|
||||
}
|
||||
for b in bookings
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,745 +0,0 @@
|
|||
"""
|
||||
Logbook API for wastage, transfers, staff food, and manual adjustments.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
import os
|
||||
import logging
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.logbook import (
|
||||
LogbookEntry, LogbookLineItem, LogbookAttachment,
|
||||
EntryType, WastageReason, TransferStatus
|
||||
)
|
||||
# from models.products import Product # TODO: Add Product model
|
||||
|
||||
router = APIRouter(prefix="/logbook", tags=["Logbook"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class LineItemInput(BaseModel):
|
||||
product_id: Optional[int] = None
|
||||
product_name: str
|
||||
product_code: Optional[str] = None
|
||||
supplier_name: Optional[str] = None
|
||||
quantity: float
|
||||
unit: Optional[str] = None
|
||||
unit_price: Optional[float] = None
|
||||
total_cost: float
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WastageEntryInput(BaseModel):
|
||||
entry_date: date
|
||||
reason: WastageReason
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
reference_number: Optional[str] = None
|
||||
|
||||
|
||||
class TransferEntryInput(BaseModel):
|
||||
entry_date: date
|
||||
destination_kitchen_id: int
|
||||
status: TransferStatus = TransferStatus.PENDING
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
reference_number: Optional[str] = None
|
||||
|
||||
|
||||
class StaffFoodEntryInput(BaseModel):
|
||||
entry_date: date
|
||||
meal_type: str # breakfast, lunch, dinner, snack
|
||||
staff_count: Optional[int] = None
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class ManualAdjustmentInput(BaseModel):
|
||||
entry_date: date
|
||||
adjustment_reason: str
|
||||
original_invoice_id: Optional[int] = None
|
||||
line_items: List[LineItemInput]
|
||||
notes: Optional[str] = None
|
||||
reference_number: Optional[str] = None
|
||||
|
||||
|
||||
class LineItemResponse(BaseModel):
|
||||
id: int
|
||||
product_id: Optional[int]
|
||||
product_name: str
|
||||
product_code: Optional[str]
|
||||
supplier_name: Optional[str]
|
||||
quantity: float
|
||||
unit: Optional[str]
|
||||
unit_price: Optional[float]
|
||||
total_cost: float
|
||||
notes: Optional[str]
|
||||
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
id: int
|
||||
file_name: str
|
||||
file_path: str
|
||||
file_type: str
|
||||
file_size_bytes: int
|
||||
description: Optional[str]
|
||||
uploaded_at: str
|
||||
|
||||
|
||||
class LogbookEntryResponse(BaseModel):
|
||||
id: int
|
||||
entry_type: str
|
||||
entry_date: str
|
||||
reference_number: Optional[str]
|
||||
total_cost: float
|
||||
notes: Optional[str]
|
||||
type_data: dict
|
||||
created_by: int
|
||||
created_by_name: Optional[str]
|
||||
created_at: str
|
||||
line_items: List[LineItemResponse]
|
||||
attachments: List[AttachmentResponse]
|
||||
|
||||
|
||||
class LogbookSummary(BaseModel):
|
||||
total_entries: int
|
||||
total_cost: float
|
||||
by_type: dict
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
def build_entry_response(entry: LogbookEntry) -> LogbookEntryResponse:
|
||||
"""Convert LogbookEntry model to response"""
|
||||
return LogbookEntryResponse(
|
||||
id=entry.id,
|
||||
entry_type=entry.entry_type.value,
|
||||
entry_date=entry.entry_date.isoformat(),
|
||||
reference_number=entry.reference_number,
|
||||
total_cost=float(entry.total_cost),
|
||||
notes=entry.notes,
|
||||
type_data=entry.type_data or {},
|
||||
created_by=entry.created_by,
|
||||
created_by_name=entry.created_by_user.name if entry.created_by_user else None,
|
||||
created_at=entry.created_at.isoformat(),
|
||||
line_items=[
|
||||
LineItemResponse(
|
||||
id=item.id,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product_name,
|
||||
product_code=item.product_code,
|
||||
supplier_name=item.supplier_name,
|
||||
quantity=float(item.quantity),
|
||||
unit=item.unit,
|
||||
unit_price=float(item.unit_price) if item.unit_price else None,
|
||||
total_cost=float(item.total_cost),
|
||||
notes=item.notes
|
||||
)
|
||||
for item in entry.line_items
|
||||
],
|
||||
attachments=[
|
||||
AttachmentResponse(
|
||||
id=att.id,
|
||||
file_name=att.file_name,
|
||||
file_path=att.file_path,
|
||||
file_type=att.file_type,
|
||||
file_size_bytes=att.file_size_bytes,
|
||||
description=att.description,
|
||||
uploaded_at=att.uploaded_at.isoformat()
|
||||
)
|
||||
for att in entry.attachments
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def create_line_items(
|
||||
db: AsyncSession,
|
||||
entry_id: int,
|
||||
kitchen_id: int,
|
||||
items: List[LineItemInput]
|
||||
) -> Decimal:
|
||||
"""Create line items for an entry and return total cost"""
|
||||
total_cost = Decimal(0)
|
||||
|
||||
for item_input in items:
|
||||
line_item = LogbookLineItem(
|
||||
entry_id=entry_id,
|
||||
kitchen_id=kitchen_id,
|
||||
product_id=item_input.product_id,
|
||||
product_name=item_input.product_name,
|
||||
product_code=item_input.product_code,
|
||||
supplier_name=item_input.supplier_name,
|
||||
quantity=Decimal(str(item_input.quantity)),
|
||||
unit=item_input.unit,
|
||||
unit_price=Decimal(str(item_input.unit_price)) if item_input.unit_price else None,
|
||||
total_cost=Decimal(str(item_input.total_cost)),
|
||||
notes=item_input.notes
|
||||
)
|
||||
db.add(line_item)
|
||||
total_cost += line_item.total_cost
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
# ============ Endpoints ============
|
||||
|
||||
@router.get("")
|
||||
async def get_logbook_entries(
|
||||
entry_type: Optional[EntryType] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> List[LogbookEntryResponse]:
|
||||
"""Get logbook entries with filters"""
|
||||
|
||||
query = select(LogbookEntry).options(
|
||||
selectinload(LogbookEntry.line_items),
|
||||
selectinload(LogbookEntry.attachments),
|
||||
selectinload(LogbookEntry.created_by_user)
|
||||
).where(
|
||||
and_(
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
|
||||
if entry_type:
|
||||
query = query.where(LogbookEntry.entry_type == entry_type)
|
||||
|
||||
if date_from:
|
||||
query = query.where(LogbookEntry.entry_date >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.where(LogbookEntry.entry_date <= date_to)
|
||||
|
||||
if search:
|
||||
# Search in notes, reference number, and line item product names
|
||||
query = query.outerjoin(LogbookLineItem).where(
|
||||
or_(
|
||||
LogbookEntry.notes.ilike(f"%{search}%"),
|
||||
LogbookEntry.reference_number.ilike(f"%{search}%"),
|
||||
LogbookLineItem.product_name.ilike(f"%{search}%")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
query = query.order_by(LogbookEntry.entry_date.desc(), LogbookEntry.created_at.desc())
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
try:
|
||||
result = await db.execute(query)
|
||||
entries = result.scalars().unique().all()
|
||||
return [build_entry_response(entry) for entry in entries]
|
||||
except Exception as e:
|
||||
logger.exception(f"Error fetching logbook entries: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error fetching entries: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_logbook_summary(
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookSummary:
|
||||
"""Get summary statistics for logbook entries"""
|
||||
|
||||
query = select(
|
||||
LogbookEntry.entry_type,
|
||||
func.count(LogbookEntry.id).label('count'),
|
||||
func.sum(LogbookEntry.total_cost).label('total')
|
||||
).where(
|
||||
and_(
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
).group_by(LogbookEntry.entry_type)
|
||||
|
||||
if date_from:
|
||||
query = query.where(LogbookEntry.entry_date >= date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.where(LogbookEntry.entry_date <= date_to)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
by_type = {}
|
||||
total_entries = 0
|
||||
total_cost = Decimal(0)
|
||||
|
||||
for row in rows:
|
||||
entry_type, count, cost = row
|
||||
by_type[entry_type.value] = {
|
||||
'count': count,
|
||||
'total_cost': float(cost or 0)
|
||||
}
|
||||
total_entries += count
|
||||
total_cost += cost or 0
|
||||
|
||||
return LogbookSummary(
|
||||
total_entries=total_entries,
|
||||
total_cost=float(total_cost),
|
||||
by_type=by_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily-stats")
|
||||
async def get_daily_logbook_stats(
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
entry_type: Optional[EntryType] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get daily logbook statistics for a date range (for purchases chart integration)"""
|
||||
|
||||
query = select(
|
||||
LogbookEntry.entry_date,
|
||||
LogbookEntry.entry_type,
|
||||
func.count(LogbookEntry.id).label("count"),
|
||||
func.sum(LogbookEntry.total_cost).label("total_cost")
|
||||
).where(
|
||||
and_(
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
|
||||
if date_from:
|
||||
query = query.where(LogbookEntry.entry_date >= date_from)
|
||||
if date_to:
|
||||
query = query.where(LogbookEntry.entry_date <= date_to)
|
||||
if entry_type:
|
||||
query = query.where(LogbookEntry.entry_type == entry_type)
|
||||
|
||||
query = query.group_by(LogbookEntry.entry_date, LogbookEntry.entry_type)
|
||||
query = query.order_by(LogbookEntry.entry_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Group by date, then by type
|
||||
daily_stats = {}
|
||||
for row in rows:
|
||||
date_str = row.entry_date.isoformat()
|
||||
if date_str not in daily_stats:
|
||||
daily_stats[date_str] = {}
|
||||
daily_stats[date_str][row.entry_type.value] = {
|
||||
"count": row.count,
|
||||
"total_cost": float(row.total_cost or 0)
|
||||
}
|
||||
|
||||
return {"daily_stats": daily_stats}
|
||||
|
||||
|
||||
@router.get("/products/search")
|
||||
async def search_products(
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Search products from invoice line items for logbook entry"""
|
||||
from models.line_item import LineItem
|
||||
from models.invoice import Invoice
|
||||
from models.supplier import Supplier
|
||||
|
||||
# Search line items from invoices for this kitchen
|
||||
# Get distinct products with most recent price
|
||||
result = await db.execute(
|
||||
select(
|
||||
LineItem.description,
|
||||
LineItem.product_code,
|
||||
LineItem.unit,
|
||||
LineItem.unit_price,
|
||||
Supplier.name.label('supplier_name')
|
||||
)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(
|
||||
and_(
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
or_(
|
||||
LineItem.description.ilike(f"%{query}%"),
|
||||
LineItem.product_code.ilike(f"%{query}%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(Invoice.invoice_date.desc())
|
||||
.limit(limit * 3) # Get more to allow for deduplication
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# Deduplicate by description, keeping first (most recent) price
|
||||
seen = set()
|
||||
products = []
|
||||
for row in rows:
|
||||
key = (row.description or '').lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
products.append({
|
||||
"id": 0, # No persistent product ID
|
||||
"name": row.description,
|
||||
"product_code": row.product_code,
|
||||
"supplier_name": row.supplier_name,
|
||||
"unit": row.unit,
|
||||
"last_price": float(row.unit_price) if row.unit_price else None
|
||||
})
|
||||
if len(products) >= limit:
|
||||
break
|
||||
|
||||
return products
|
||||
|
||||
|
||||
@router.get("/{entry_id}")
|
||||
async def get_logbook_entry(
|
||||
entry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Get single logbook entry with details"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
return build_entry_response(entry)
|
||||
|
||||
|
||||
@router.post("/wastage")
|
||||
async def create_wastage_entry(
|
||||
entry_input: WastageEntryInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create wastage entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.WASTAGE,
|
||||
entry_date=entry_input.entry_date,
|
||||
reference_number=entry_input.reference_number,
|
||||
notes=entry_input.notes,
|
||||
type_data={"reason": entry_input.reason.value},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush() # Get entry.id
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created wastage entry {entry.id} for kitchen {current_user.kitchen_id}, cost: {total_cost}")
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/transfer")
|
||||
async def create_transfer_entry(
|
||||
entry_input: TransferEntryInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create transfer entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.TRANSFER,
|
||||
entry_date=entry_input.entry_date,
|
||||
reference_number=entry_input.reference_number,
|
||||
notes=entry_input.notes,
|
||||
type_data={
|
||||
"destination_kitchen_id": entry_input.destination_kitchen_id,
|
||||
"status": entry_input.status.value
|
||||
},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created transfer entry {entry.id} for kitchen {current_user.kitchen_id} -> {entry_input.destination_kitchen_id}")
|
||||
|
||||
# TODO: Send notification to destination kitchen
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/staff-food")
|
||||
async def create_staff_food_entry(
|
||||
entry_input: StaffFoodEntryInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create staff food entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.STAFF_FOOD,
|
||||
entry_date=entry_input.entry_date,
|
||||
notes=entry_input.notes,
|
||||
type_data={
|
||||
"meal_type": entry_input.meal_type,
|
||||
"staff_count": entry_input.staff_count
|
||||
},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created staff food entry {entry.id} for kitchen {current_user.kitchen_id}, meal: {entry_input.meal_type}")
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.post("/manual-adjustment")
|
||||
async def create_manual_adjustment_entry(
|
||||
entry_input: ManualAdjustmentInput,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> LogbookEntryResponse:
|
||||
"""Create manual adjustment entry"""
|
||||
|
||||
if not entry_input.line_items:
|
||||
raise HTTPException(status_code=400, detail="At least one line item is required")
|
||||
|
||||
entry = LogbookEntry(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_type=EntryType.MANUAL_ADJUSTMENT,
|
||||
entry_date=entry_input.entry_date,
|
||||
reference_number=entry_input.reference_number,
|
||||
notes=entry_input.notes,
|
||||
type_data={
|
||||
"adjustment_reason": entry_input.adjustment_reason,
|
||||
"original_invoice_id": entry_input.original_invoice_id
|
||||
},
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
total_cost = await create_line_items(db, entry.id, current_user.kitchen_id, entry_input.line_items)
|
||||
entry.total_cost = total_cost
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(entry)
|
||||
|
||||
logger.info(f"Created manual adjustment entry {entry.id} for kitchen {current_user.kitchen_id}")
|
||||
|
||||
return await get_logbook_entry(entry.id, current_user, db)
|
||||
|
||||
|
||||
@router.patch("/{entry_id}")
|
||||
async def update_logbook_entry(
|
||||
entry_id: int,
|
||||
notes: Optional[str] = None,
|
||||
reference_number: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update logbook entry (limited fields)"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
if notes is not None:
|
||||
entry.notes = notes
|
||||
|
||||
if reference_number is not None:
|
||||
entry.reference_number = reference_number
|
||||
|
||||
entry.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/{entry_id}")
|
||||
async def delete_logbook_entry(
|
||||
entry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Soft delete logbook entry"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
entry.is_deleted = True
|
||||
entry.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Deleted logbook entry {entry_id} for kitchen {current_user.kitchen_id}")
|
||||
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{entry_id}/attachments")
|
||||
async def upload_attachment(
|
||||
entry_id: int,
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = Form(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Upload photo or document to logbook entry"""
|
||||
|
||||
# Verify entry exists and belongs to user's kitchen
|
||||
result = await db.execute(
|
||||
select(LogbookEntry).where(
|
||||
and_(
|
||||
LogbookEntry.id == entry_id,
|
||||
LogbookEntry.kitchen_id == current_user.kitchen_id,
|
||||
LogbookEntry.is_deleted == False
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
# Validate file type
|
||||
allowed_types = ["image/jpeg", "image/png", "image/heic", "image/webp", "application/pdf"]
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(status_code=400, detail=f"File type {file.content_type} not allowed. Allowed: {allowed_types}")
|
||||
|
||||
# Save file
|
||||
upload_dir = f"/app/attachments/logbook/kitchen_{current_user.kitchen_id}"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
file_extension = os.path.splitext(file.filename)[1] if file.filename else ".jpg"
|
||||
file_name = f"entry_{entry_id}_{timestamp}{file_extension}"
|
||||
file_path = f"{upload_dir}/{file_name}"
|
||||
|
||||
content = await file.read()
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Create attachment record
|
||||
attachment = LogbookAttachment(
|
||||
entry_id=entry_id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
file_name=file.filename or file_name,
|
||||
file_path=file_path,
|
||||
file_type=file.content_type,
|
||||
file_size_bytes=len(content),
|
||||
description=description,
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(attachment)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Uploaded attachment {attachment.id} to entry {entry_id}")
|
||||
|
||||
return {
|
||||
"id": attachment.id,
|
||||
"file_name": attachment.file_name,
|
||||
"file_path": attachment.file_path
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{entry_id}/attachments/{attachment_id}")
|
||||
async def delete_attachment(
|
||||
entry_id: int,
|
||||
attachment_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete attachment from logbook entry"""
|
||||
|
||||
result = await db.execute(
|
||||
select(LogbookAttachment).where(
|
||||
and_(
|
||||
LogbookAttachment.id == attachment_id,
|
||||
LogbookAttachment.entry_id == entry_id,
|
||||
LogbookAttachment.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Delete file from disk
|
||||
if os.path.exists(attachment.file_path):
|
||||
os.remove(attachment.file_path)
|
||||
|
||||
await db.delete(attachment)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "deleted"}
|
||||
1308
backend/api/menus.py
1308
backend/api/menus.py
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,118 +0,0 @@
|
|||
"""
|
||||
Public API endpoints - NO AUTHENTICATION REQUIRED.
|
||||
|
||||
These endpoints are designed for sharing with external parties (e.g., suppliers)
|
||||
via hash-based URLs that don't require login.
|
||||
"""
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from fastapi import Depends
|
||||
|
||||
from database import get_db
|
||||
from models.dispute import DisputeAttachment
|
||||
from models.settings import KitchenSettings
|
||||
from services.nextcloud_service import NextcloudService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/attachments/{public_hash}")
|
||||
async def get_public_attachment(
|
||||
public_hash: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
View a dispute attachment publicly via its hash.
|
||||
|
||||
This endpoint does NOT require authentication, allowing suppliers
|
||||
to view attached images/documents via shareable links in emails.
|
||||
"""
|
||||
# Find attachment by public hash
|
||||
result = await db.execute(
|
||||
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get file content
|
||||
content = None
|
||||
|
||||
# Try local file first
|
||||
if attachment.file_storage_location == "local" and attachment.file_path:
|
||||
if os.path.exists(attachment.file_path):
|
||||
with open(attachment.file_path, 'rb') as f:
|
||||
content = f.read()
|
||||
|
||||
# Try Nextcloud if local not found
|
||||
if content is None and attachment.file_storage_location == "nextcloud" and attachment.nextcloud_path:
|
||||
# Get kitchen settings for Nextcloud credentials
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == attachment.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if settings and settings.nextcloud_enabled:
|
||||
nc = NextcloudService(
|
||||
settings.nextcloud_host,
|
||||
settings.nextcloud_username,
|
||||
settings.nextcloud_password,
|
||||
""
|
||||
)
|
||||
success, nc_content = await nc.download_file(attachment.nextcloud_path)
|
||||
await nc.close()
|
||||
|
||||
if success:
|
||||
content = nc_content
|
||||
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Determine if browser should display inline or download
|
||||
# Images and PDFs display inline, others download
|
||||
inline_types = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf'
|
||||
]
|
||||
|
||||
disposition = "inline" if attachment.file_type in inline_types else "attachment"
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.file_type,
|
||||
headers={
|
||||
"Content-Disposition": f'{disposition}; filename="{attachment.file_name}"',
|
||||
"Cache-Control": "private, max-age=3600" # Cache for 1 hour
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/attachments/{public_hash}/info")
|
||||
async def get_public_attachment_info(
|
||||
public_hash: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get attachment metadata without downloading the file.
|
||||
Useful for email previews or link unfurling.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DisputeAttachment).where(DisputeAttachment.public_hash == public_hash)
|
||||
)
|
||||
attachment = result.scalar_one_or_none()
|
||||
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
return {
|
||||
"file_name": attachment.file_name,
|
||||
"file_type": attachment.file_type,
|
||||
"file_size_bytes": attachment.file_size_bytes,
|
||||
"attachment_type": attachment.attachment_type,
|
||||
"description": attachment.description,
|
||||
"uploaded_at": attachment.uploaded_at.isoformat() if attachment.uploaded_at else None
|
||||
}
|
||||
|
|
@ -1,835 +0,0 @@
|
|||
"""
|
||||
Purchase Order API endpoints — full CRUD, attachment, product search, budget view,
|
||||
preview (HTML), and email sending.
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from html import escape as html_escape
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, func, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from pydantic import BaseModel, field_serializer
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, Kitchen
|
||||
from models.purchase_order import PurchaseOrder, PurchaseOrderLineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.line_item import LineItem
|
||||
from models.invoice import Invoice
|
||||
from auth import get_current_user, require_cap, get_current_user_from_token
|
||||
from services.email_service import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_DIR = "data/po_attachments"
|
||||
|
||||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineItemIn(BaseModel):
|
||||
product_id: Optional[int] = None
|
||||
product_code: Optional[str] = None
|
||||
description: str
|
||||
unit: Optional[str] = None
|
||||
unit_price: Decimal
|
||||
quantity: Decimal
|
||||
total: Decimal
|
||||
line_number: int = 0
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class PurchaseOrderCreate(BaseModel):
|
||||
supplier_id: int
|
||||
order_date: date
|
||||
order_type: str # 'itemised' or 'single_value'
|
||||
total_amount: Optional[Decimal] = None
|
||||
order_reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: str = "DRAFT"
|
||||
line_items: list[LineItemIn] = []
|
||||
|
||||
|
||||
class PurchaseOrderUpdate(BaseModel):
|
||||
supplier_id: Optional[int] = None
|
||||
order_date: Optional[date] = None
|
||||
order_type: Optional[str] = None
|
||||
total_amount: Optional[Decimal] = None
|
||||
order_reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
line_items: Optional[list[LineItemIn]] = None
|
||||
|
||||
|
||||
class StatusUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class LineItemOut(BaseModel):
|
||||
id: int
|
||||
product_id: Optional[int]
|
||||
product_code: Optional[str]
|
||||
description: str
|
||||
unit: Optional[str]
|
||||
unit_price: Decimal
|
||||
quantity: Decimal
|
||||
total: Decimal
|
||||
line_number: int
|
||||
source: str
|
||||
|
||||
@field_serializer('unit_price', 'quantity', 'total')
|
||||
def ser(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class PurchaseOrderOut(BaseModel):
|
||||
id: int
|
||||
kitchen_id: int
|
||||
supplier_id: int
|
||||
supplier_name: Optional[str] = None
|
||||
order_date: date
|
||||
order_type: str
|
||||
status: str
|
||||
total_amount: Optional[Decimal]
|
||||
order_reference: Optional[str]
|
||||
notes: Optional[str]
|
||||
attachment_path: Optional[str]
|
||||
attachment_original_name: Optional[str]
|
||||
linked_invoice_id: Optional[int]
|
||||
created_by: int
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
line_items: list[LineItemOut] = []
|
||||
|
||||
@field_serializer('total_amount')
|
||||
def ser_amount(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class BudgetPO(BaseModel):
|
||||
id: int
|
||||
order_type: str
|
||||
status: str
|
||||
total_amount: Optional[float]
|
||||
order_reference: Optional[str]
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def po_to_out(po: PurchaseOrder) -> PurchaseOrderOut:
|
||||
return PurchaseOrderOut(
|
||||
id=po.id,
|
||||
kitchen_id=po.kitchen_id,
|
||||
supplier_id=po.supplier_id,
|
||||
supplier_name=po.supplier.name if po.supplier else None,
|
||||
order_date=po.order_date,
|
||||
order_type=po.order_type,
|
||||
status=po.status,
|
||||
total_amount=po.total_amount,
|
||||
order_reference=po.order_reference,
|
||||
notes=po.notes,
|
||||
attachment_path=po.attachment_path,
|
||||
attachment_original_name=po.attachment_original_name,
|
||||
linked_invoice_id=po.linked_invoice_id,
|
||||
created_by=po.created_by,
|
||||
created_by_name=po.created_by_user.name if po.created_by_user else None,
|
||||
created_at=po.created_at.isoformat() if po.created_at else "",
|
||||
updated_at=po.updated_at.isoformat() if po.updated_at else "",
|
||||
line_items=[
|
||||
LineItemOut(
|
||||
id=li.id,
|
||||
product_id=li.product_id,
|
||||
product_code=li.product_code,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
unit_price=li.unit_price,
|
||||
quantity=li.quantity,
|
||||
total=li.total,
|
||||
line_number=li.line_number,
|
||||
source=li.source,
|
||||
)
|
||||
for li in (po.line_items or [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _calc_itemised_total(items: list[LineItemIn]) -> Decimal:
|
||||
return sum((i.total for i in items), Decimal("0"))
|
||||
|
||||
|
||||
async def _load_po(db: AsyncSession, po_id: int, kitchen_id: int) -> PurchaseOrder:
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder)
|
||||
.where(
|
||||
PurchaseOrder.id == po_id,
|
||||
PurchaseOrder.kitchen_id == kitchen_id,
|
||||
)
|
||||
.options(
|
||||
selectinload(PurchaseOrder.line_items),
|
||||
selectinload(PurchaseOrder.supplier),
|
||||
selectinload(PurchaseOrder.created_by_user),
|
||||
)
|
||||
)
|
||||
po = result.scalar_one_or_none()
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="Purchase order not found")
|
||||
return po
|
||||
|
||||
|
||||
# ── CRUD Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/", response_model=PurchaseOrderOut)
|
||||
async def create_purchase_order(
|
||||
data: PurchaseOrderCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# Validate supplier belongs to this kitchen
|
||||
sup = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == data.supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
if not sup.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Invalid supplier")
|
||||
|
||||
total = data.total_amount if data.order_type == "single_value" else _calc_itemised_total(data.line_items)
|
||||
|
||||
po = PurchaseOrder(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
supplier_id=data.supplier_id,
|
||||
order_date=data.order_date,
|
||||
order_type=data.order_type,
|
||||
status=data.status if data.status in ("DRAFT", "PENDING") else "DRAFT",
|
||||
total_amount=total,
|
||||
order_reference=data.order_reference,
|
||||
notes=data.notes,
|
||||
created_by=current_user.id,
|
||||
updated_by=current_user.id,
|
||||
)
|
||||
db.add(po)
|
||||
await db.flush()
|
||||
|
||||
for idx, li in enumerate(data.line_items):
|
||||
db.add(PurchaseOrderLineItem(
|
||||
purchase_order_id=po.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
product_id=li.product_id,
|
||||
product_code=li.product_code,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
unit_price=li.unit_price,
|
||||
quantity=li.quantity,
|
||||
total=li.total,
|
||||
line_number=li.line_number or idx,
|
||||
source=li.source,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.get("/", response_model=list[PurchaseOrderOut])
|
||||
async def list_purchase_orders(
|
||||
status: Optional[str] = None,
|
||||
supplier_id: Optional[int] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
q = (
|
||||
select(PurchaseOrder)
|
||||
.where(PurchaseOrder.kitchen_id == current_user.kitchen_id)
|
||||
.options(
|
||||
selectinload(PurchaseOrder.line_items),
|
||||
selectinload(PurchaseOrder.supplier),
|
||||
selectinload(PurchaseOrder.created_by_user),
|
||||
)
|
||||
.order_by(PurchaseOrder.order_date.desc(), PurchaseOrder.id.desc())
|
||||
)
|
||||
|
||||
if status:
|
||||
statuses = [s.strip().upper() for s in status.split(",")]
|
||||
q = q.where(PurchaseOrder.status.in_(statuses))
|
||||
if supplier_id:
|
||||
q = q.where(PurchaseOrder.supplier_id == supplier_id)
|
||||
if date_from:
|
||||
q = q.where(PurchaseOrder.order_date >= date_from)
|
||||
if date_to:
|
||||
q = q.where(PurchaseOrder.order_date <= date_to)
|
||||
|
||||
q = q.offset(offset).limit(limit)
|
||||
result = await db.execute(q)
|
||||
return [po_to_out(po) for po in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/products/search")
|
||||
async def search_products_for_po(
|
||||
query: str,
|
||||
supplier_id: Optional[int] = None,
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Search products from invoice line items, optionally filtered by supplier."""
|
||||
q = (
|
||||
select(
|
||||
LineItem.description,
|
||||
LineItem.product_code,
|
||||
LineItem.unit,
|
||||
LineItem.unit_price,
|
||||
Supplier.name.label("supplier_name"),
|
||||
Invoice.supplier_id.label("sup_id"),
|
||||
)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(
|
||||
and_(
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
or_(
|
||||
LineItem.description.ilike(f"%{query}%"),
|
||||
LineItem.product_code.ilike(f"%{query}%"),
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(Invoice.invoice_date.desc())
|
||||
)
|
||||
|
||||
if supplier_id:
|
||||
q = q.where(Invoice.supplier_id == supplier_id)
|
||||
|
||||
q = q.limit(limit * 3)
|
||||
result = await db.execute(q)
|
||||
rows = result.all()
|
||||
|
||||
seen: set[str] = set()
|
||||
products = []
|
||||
for row in rows:
|
||||
key = (row.description or "").lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
products.append({
|
||||
"id": 0,
|
||||
"name": row.description,
|
||||
"product_code": row.product_code,
|
||||
"supplier_name": row.supplier_name,
|
||||
"unit": row.unit,
|
||||
"last_price": float(row.unit_price) if row.unit_price else None,
|
||||
})
|
||||
if len(products) >= limit:
|
||||
break
|
||||
|
||||
return products
|
||||
|
||||
|
||||
@router.get("/by-date")
|
||||
async def get_pos_by_date(
|
||||
week_start: date,
|
||||
week_end: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""POs grouped by supplier_id → date for budget table integration."""
|
||||
result = await db.execute(
|
||||
select(PurchaseOrder)
|
||||
.where(
|
||||
PurchaseOrder.kitchen_id == current_user.kitchen_id,
|
||||
PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
|
||||
PurchaseOrder.order_date >= week_start,
|
||||
PurchaseOrder.order_date <= week_end,
|
||||
)
|
||||
.options(selectinload(PurchaseOrder.line_items))
|
||||
)
|
||||
pos = result.scalars().all()
|
||||
|
||||
grouped: dict[int, dict[str, list]] = {}
|
||||
for po in pos:
|
||||
sid = po.supplier_id
|
||||
ds = po.order_date.isoformat()
|
||||
if sid not in grouped:
|
||||
grouped[sid] = {}
|
||||
if ds not in grouped[sid]:
|
||||
grouped[sid][ds] = []
|
||||
grouped[sid][ds].append(BudgetPO(
|
||||
id=po.id,
|
||||
order_type=po.order_type,
|
||||
status=po.status,
|
||||
total_amount=float(po.total_amount) if po.total_amount else None,
|
||||
order_reference=po.order_reference,
|
||||
).model_dump())
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
@router.get("/{po_id}", response_model=PurchaseOrderOut)
|
||||
async def get_purchase_order(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return po_to_out(await _load_po(db, po_id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.put("/{po_id}", response_model=PurchaseOrderOut)
|
||||
async def update_purchase_order(
|
||||
po_id: int,
|
||||
data: PurchaseOrderUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
|
||||
if po.status in ("LINKED", "CLOSED", "CANCELLED"):
|
||||
raise HTTPException(status_code=400, detail=f"Cannot edit PO with status {po.status}")
|
||||
|
||||
if data.supplier_id is not None:
|
||||
po.supplier_id = data.supplier_id
|
||||
if data.order_date is not None:
|
||||
po.order_date = data.order_date
|
||||
if data.order_type is not None:
|
||||
po.order_type = data.order_type
|
||||
if data.order_reference is not None:
|
||||
po.order_reference = data.order_reference
|
||||
if data.notes is not None:
|
||||
po.notes = data.notes
|
||||
if data.status is not None and data.status in ("DRAFT", "PENDING"):
|
||||
po.status = data.status
|
||||
|
||||
# Replace line items if provided
|
||||
if data.line_items is not None:
|
||||
await db.execute(
|
||||
delete(PurchaseOrderLineItem).where(
|
||||
PurchaseOrderLineItem.purchase_order_id == po.id
|
||||
)
|
||||
)
|
||||
for idx, li in enumerate(data.line_items):
|
||||
db.add(PurchaseOrderLineItem(
|
||||
purchase_order_id=po.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
product_id=li.product_id,
|
||||
product_code=li.product_code,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
unit_price=li.unit_price,
|
||||
quantity=li.quantity,
|
||||
total=li.total,
|
||||
line_number=li.line_number or idx,
|
||||
source=li.source,
|
||||
))
|
||||
|
||||
# Recalculate total
|
||||
if po.order_type == "single_value":
|
||||
if data.total_amount is not None:
|
||||
po.total_amount = data.total_amount
|
||||
else:
|
||||
items = data.line_items if data.line_items is not None else []
|
||||
po.total_amount = _calc_itemised_total(items) if items else po.total_amount
|
||||
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.delete("/{po_id}")
|
||||
async def delete_purchase_order(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
allowed_statuses = ("DRAFT", "CANCELLED", "PENDING") if current_user.is_admin else ("DRAFT", "CANCELLED")
|
||||
if po.status not in allowed_statuses:
|
||||
raise HTTPException(status_code=400, detail="Only DRAFT or CANCELLED POs can be deleted")
|
||||
|
||||
await db.delete(po)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{po_id}/status", response_model=PurchaseOrderOut)
|
||||
async def update_po_status(
|
||||
po_id: int,
|
||||
data: StatusUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
allowed = {
|
||||
"DRAFT": ["PENDING", "CANCELLED"],
|
||||
"PENDING": ["DRAFT", "CLOSED", "CANCELLED"],
|
||||
"LINKED": ["CLOSED"],
|
||||
"CLOSED": [],
|
||||
"CANCELLED": ["DRAFT"],
|
||||
}
|
||||
if data.status not in allowed.get(po.status, []):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot change status from {po.status} to {data.status}",
|
||||
)
|
||||
po.status = data.status
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
# ── Attachment ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{po_id}/attachment", response_model=PurchaseOrderOut)
|
||||
async def upload_attachment(
|
||||
po_id: int,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(file.filename or "file")[1]
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
contents = await file.read()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
po.attachment_path = filepath
|
||||
po.attachment_original_name = file.filename
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.delete("/{po_id}/attachment", response_model=PurchaseOrderOut)
|
||||
async def remove_attachment(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
if po.attachment_path and os.path.exists(po.attachment_path):
|
||||
os.remove(po.attachment_path)
|
||||
po.attachment_path = None
|
||||
po.attachment_original_name = None
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
# ── Preview & Email ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_po_html(po: PurchaseOrder, kitchen: KitchenSettings, currency: str = "£") -> str:
|
||||
"""Generate a clean HTML page for PO preview / email body."""
|
||||
supplier = po.supplier
|
||||
esc = html_escape
|
||||
|
||||
# Kitchen letterhead
|
||||
kitchen_name = esc(kitchen.kitchen_display_name or "")
|
||||
addr_parts = [
|
||||
kitchen.kitchen_address_line1,
|
||||
kitchen.kitchen_address_line2,
|
||||
kitchen.kitchen_city,
|
||||
kitchen.kitchen_postcode,
|
||||
]
|
||||
addr_html = "<br>".join(esc(p) for p in addr_parts if p)
|
||||
kitchen_phone = esc(kitchen.kitchen_phone or "")
|
||||
kitchen_email = esc(kitchen.kitchen_email or "")
|
||||
|
||||
# Supplier details
|
||||
supplier_name = esc(supplier.name) if supplier else "Unknown"
|
||||
account_number = esc(supplier.account_number or "") if supplier else ""
|
||||
|
||||
# PO metadata
|
||||
po_number = f"PO-{po.id}"
|
||||
order_date = po.order_date.strftime("%d/%m/%Y") if po.order_date else ""
|
||||
notes = esc(po.notes or "").replace("\n", "<br>") if po.notes else ""
|
||||
|
||||
# Line items table
|
||||
items_html = ""
|
||||
if po.order_type == "itemised" and po.line_items:
|
||||
rows = ""
|
||||
for li in sorted(po.line_items, key=lambda x: x.line_number):
|
||||
rows += f"""<tr>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.product_code or "")}</td>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.description or "")}</td>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #ddd;">{esc(li.unit or "")}</td>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{currency}{li.unit_price:.2f}</td>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{li.quantity:g}</td>
|
||||
<td style="padding:6px 10px;border-bottom:1px solid #ddd;text-align:right;">{currency}{li.total:.2f}</td>
|
||||
</tr>"""
|
||||
items_html = f"""
|
||||
<table style="width:100%;border-collapse:collapse;margin-top:20px;font-size:14px;">
|
||||
<thead>
|
||||
<tr style="background:#f5f5f5;">
|
||||
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Code</th>
|
||||
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Description</th>
|
||||
<th style="padding:8px 10px;text-align:left;border-bottom:2px solid #ccc;">Unit</th>
|
||||
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Price</th>
|
||||
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Qty</th>
|
||||
<th style="padding:8px 10px;text-align:right;border-bottom:2px solid #ccc;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>"""
|
||||
elif po.order_type == "single_value":
|
||||
items_html = f"""
|
||||
<div style="margin-top:20px;padding:12px;background:#f9f9f9;border-radius:6px;">
|
||||
<strong>Order Value:</strong> {currency}{float(po.total_amount or 0):.2f}
|
||||
{f'<br><strong>Order Ref:</strong> {esc(po.order_reference)}' if po.order_reference else ''}
|
||||
</div>"""
|
||||
|
||||
total_amount = float(po.total_amount or 0)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Purchase Order {po_number}</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, Helvetica, sans-serif; color: #333; margin: 0; padding: 0; }}
|
||||
.page {{ max-width: 800px; margin: 20px auto; padding: 40px; }}
|
||||
@media print {{
|
||||
body {{ margin: 0; }}
|
||||
.page {{ max-width: 100%; margin: 0; padding: 20px; }}
|
||||
.no-print {{ display: none !important; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<!-- Letterhead -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:30px;padding-bottom:20px;border-bottom:3px solid #1a1a2e;">
|
||||
<div>
|
||||
<h1 style="margin:0;color:#1a1a2e;font-size:22px;">{kitchen_name}</h1>
|
||||
<div style="margin-top:6px;font-size:13px;color:#666;line-height:1.5;">{addr_html}</div>
|
||||
{f'<div style="margin-top:4px;font-size:13px;color:#666;">Tel: {kitchen_phone}</div>' if kitchen_phone else ''}
|
||||
{f'<div style="font-size:13px;color:#666;">{kitchen_email}</div>' if kitchen_email else ''}
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<h2 style="margin:0;color:#1a1a2e;font-size:24px;">PURCHASE ORDER</h2>
|
||||
<div style="margin-top:8px;font-size:16px;font-weight:bold;color:#555;">{po_number}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PO Details -->
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:20px;">
|
||||
<div>
|
||||
<div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;">Supplier</div>
|
||||
<div style="font-size:16px;font-weight:bold;margin-top:4px;">{supplier_name}</div>
|
||||
{f'<div style="font-size:13px;color:#666;margin-top:2px;">Account: {account_number}</div>' if account_number else ''}
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.5px;">Date</div>
|
||||
<div style="font-size:16px;font-weight:bold;margin-top:4px;">{order_date}</div>
|
||||
<div style="font-size:13px;color:#666;margin-top:2px;">Status: {po.status}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
{items_html}
|
||||
|
||||
<!-- Total -->
|
||||
<div style="text-align:right;margin-top:16px;padding:12px 10px;border-top:2px solid #1a1a2e;font-size:18px;">
|
||||
<strong>Total: {currency}{total_amount:.2f}</strong>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{f'<div style="margin-top:20px;padding:12px;background:#fffef0;border-left:3px solid #e6c200;border-radius:4px;font-size:13px;"><strong>Notes:</strong><br>{notes}</div>' if notes else ''}
|
||||
|
||||
<!-- Print button (hidden on print) -->
|
||||
<div class="no-print" style="margin-top:30px;text-align:center;">
|
||||
<button onclick="window.print()" style="padding:10px 24px;background:#1a1a2e;color:white;border:none;border-radius:6px;font-size:14px;cursor:pointer;">
|
||||
Print / Save PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/{po_id}/preview")
|
||||
async def preview_purchase_order(
|
||||
po_id: int,
|
||||
token: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return a print-friendly HTML preview of the purchase order (query-param auth)."""
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Token required — use ?token=your_jwt_token")
|
||||
current_user = await get_current_user_from_token(token, db)
|
||||
if not current_user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
|
||||
# Load kitchen settings for letterhead
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
kitchen = settings_result.scalar_one_or_none()
|
||||
if not kitchen:
|
||||
kitchen = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
|
||||
currency = kitchen.currency_symbol or "£"
|
||||
html = _build_po_html(po, kitchen, currency)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.post("/{po_id}/send-email")
|
||||
async def send_po_email(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Email the PO to the supplier's order_email address."""
|
||||
po = await _load_po(db, po_id, current_user.kitchen_id)
|
||||
|
||||
# Validate supplier has an order email
|
||||
if not po.supplier or not po.supplier.order_email:
|
||||
raise HTTPException(status_code=400, detail="Supplier does not have an order email address configured")
|
||||
|
||||
# Load kitchen settings for SMTP + letterhead
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
kitchen = settings_result.scalar_one_or_none()
|
||||
if not kitchen or not kitchen.smtp_host or not kitchen.smtp_from_email:
|
||||
raise HTTPException(status_code=400, detail="SMTP email is not configured in Settings")
|
||||
|
||||
currency = kitchen.currency_symbol or "£"
|
||||
html = _build_po_html(po, kitchen, currency)
|
||||
|
||||
po_number = f"PO-{po.id}"
|
||||
kitchen_name = kitchen.kitchen_display_name or "Kitchen"
|
||||
subject = f"Purchase Order {po_number} from {kitchen_name}"
|
||||
|
||||
email_service = EmailService(kitchen)
|
||||
success = email_service.send_email(
|
||||
to_email=po.supplier.order_email,
|
||||
subject=subject,
|
||||
html_body=html,
|
||||
plain_body=f"Please find attached Purchase Order {po_number}. Total: {currency}{float(po.total_amount or 0):.2f}",
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="Failed to send email. Check SMTP settings.")
|
||||
|
||||
# Update status to PENDING if currently DRAFT
|
||||
if po.status == "DRAFT":
|
||||
po.status = "PENDING"
|
||||
po.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "message": f"PO emailed to {po.supplier.order_email}", "new_status": po.status}
|
||||
|
||||
|
||||
# ── Invoice Matching ─────────────────────────────────────────────────────────
|
||||
|
||||
class LinkRequest(BaseModel):
|
||||
invoice_id: int
|
||||
|
||||
|
||||
@router.get("/matching/for-invoice")
|
||||
async def get_matching_pos(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Find pending POs that match a given invoice (by supplier)."""
|
||||
from services.po_matching import find_matching_pos
|
||||
|
||||
# Load the invoice
|
||||
inv_result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = inv_result.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if not invoice.supplier_id:
|
||||
return {"matches": [], "linked_po": None}
|
||||
|
||||
# Check if invoice already has a linked PO
|
||||
linked_result = await db.execute(
|
||||
select(PurchaseOrder)
|
||||
.where(
|
||||
PurchaseOrder.kitchen_id == current_user.kitchen_id,
|
||||
PurchaseOrder.linked_invoice_id == invoice_id,
|
||||
PurchaseOrder.status == "LINKED",
|
||||
)
|
||||
.options(
|
||||
selectinload(PurchaseOrder.supplier),
|
||||
selectinload(PurchaseOrder.line_items),
|
||||
)
|
||||
)
|
||||
linked_po = linked_result.scalar_one_or_none()
|
||||
|
||||
if linked_po:
|
||||
return {
|
||||
"matches": [],
|
||||
"linked_po": {
|
||||
"po_id": linked_po.id,
|
||||
"order_date": linked_po.order_date.isoformat() if linked_po.order_date else None,
|
||||
"total_amount": float(linked_po.total_amount) if linked_po.total_amount else None,
|
||||
"order_reference": linked_po.order_reference,
|
||||
"status": linked_po.status,
|
||||
"order_type": linked_po.order_type,
|
||||
},
|
||||
}
|
||||
|
||||
# Find matching POs
|
||||
matches = await find_matching_pos(
|
||||
db,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
supplier_id=invoice.supplier_id,
|
||||
invoice_date=invoice.invoice_date,
|
||||
invoice_total=invoice.total,
|
||||
)
|
||||
|
||||
return {"matches": matches, "linked_po": None}
|
||||
|
||||
|
||||
@router.post("/{po_id}/link", response_model=PurchaseOrderOut)
|
||||
async def link_po_to_invoice(
|
||||
po_id: int,
|
||||
data: LinkRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Link a PO to an invoice. Sets PO status to LINKED."""
|
||||
from services.po_matching import link_po_to_invoice as do_link
|
||||
|
||||
po = await do_link(db, po_id, data.invoice_id, current_user.kitchen_id, current_user.id)
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="PO or invoice not found")
|
||||
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
|
||||
|
||||
@router.post("/{po_id}/unlink", response_model=PurchaseOrderOut)
|
||||
async def unlink_po_from_invoice(
|
||||
po_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Unlink a PO from its invoice. Returns PO to PENDING status."""
|
||||
from services.po_matching import unlink_po as do_unlink
|
||||
|
||||
po = await do_unlink(db, po_id, current_user.kitchen_id, current_user.id)
|
||||
if not po:
|
||||
raise HTTPException(status_code=404, detail="PO not found")
|
||||
|
||||
return po_to_out(await _load_po(db, po.id, current_user.kitchen_id))
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,422 +0,0 @@
|
|||
"""
|
||||
Residents Table Chart API
|
||||
|
||||
Gantt-style visualization showing hotel bookings with restaurant table indicators.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import get_current_user, require_cap
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.newbook import NewbookDailyOccupancy
|
||||
from models.resos import ResosBooking
|
||||
|
||||
router = APIRouter(prefix="/residents-table-chart", tags=["Residents Table Chart"])
|
||||
|
||||
|
||||
class RestaurantBookingDetail(BaseModel):
|
||||
has_booking: bool
|
||||
time: Optional[str] = None
|
||||
people: Optional[int] = None
|
||||
table_name: Optional[str] = None
|
||||
opening_hour_name: Optional[str] = None
|
||||
is_group_match: Optional[bool] = None # True if matched via group/exclude field (not the lead booking)
|
||||
|
||||
|
||||
class BookingSegment(BaseModel):
|
||||
booking_id: str | None
|
||||
bookings_group_id: Optional[str] = None
|
||||
check_in: str
|
||||
check_out: str
|
||||
nights: list[str]
|
||||
is_dbb: Optional[bool] = None
|
||||
is_package: Optional[bool] = None
|
||||
restaurant_bookings: dict[str, RestaurantBookingDetail]
|
||||
|
||||
|
||||
class RoomRow(BaseModel):
|
||||
room_number: str | None
|
||||
bookings: list[BookingSegment] # Multiple bookings in the same room
|
||||
|
||||
|
||||
class ResidentsTableChartResponse(BaseModel):
|
||||
date_range: dict
|
||||
rooms: list[RoomRow] # Changed from 'bookings' to 'rooms'
|
||||
summary: dict
|
||||
metrics: Optional[dict] = None # Aggregated metrics for different time periods
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_residents_table_chart(
|
||||
start_date: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResidentsTableChartResponse:
|
||||
"""
|
||||
Get Gantt-style chart data showing hotel bookings with restaurant table indicators.
|
||||
|
||||
Args:
|
||||
start_date: First day of 7-day period (defaults to today)
|
||||
|
||||
Returns:
|
||||
Chart data with hotel stays and restaurant booking indicators
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if start_date is None:
|
||||
start_date = date.today()
|
||||
|
||||
logger.info(f"ResidentsTableChart API called with start_date={start_date}")
|
||||
|
||||
end_date = start_date + timedelta(days=6) # 7-day period
|
||||
date_range = {
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat(),
|
||||
"dates": [(start_date + timedelta(days=i)).isoformat() for i in range(7)]
|
||||
}
|
||||
|
||||
# Fetch Newbook occupancy data for 7-day period
|
||||
result = await db.execute(
|
||||
select(NewbookDailyOccupancy).where(
|
||||
and_(
|
||||
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
||||
NewbookDailyOccupancy.date >= start_date,
|
||||
NewbookDailyOccupancy.date <= end_date,
|
||||
NewbookDailyOccupancy.rooms_breakdown.isnot(None) # Only records with room breakdown
|
||||
)
|
||||
).order_by(NewbookDailyOccupancy.date)
|
||||
)
|
||||
occupancy_records = result.scalars().all()
|
||||
logger.info(f"Found {len(occupancy_records)} occupancy records")
|
||||
|
||||
# Group by room number only (one row per room in Gantt chart)
|
||||
# Key: room_number, Value: dict of bookings for that room
|
||||
rooms_dict = {}
|
||||
for record in occupancy_records:
|
||||
# Parse JSONB array - each element is a room object for this date
|
||||
rooms = record.rooms_breakdown or []
|
||||
|
||||
for room in rooms:
|
||||
room_number = room.get("room_number")
|
||||
booking_id = room.get("booking_id")
|
||||
|
||||
if room_number not in rooms_dict:
|
||||
rooms_dict[room_number] = {}
|
||||
|
||||
# Track each booking within this room
|
||||
if booking_id not in rooms_dict[room_number]:
|
||||
rooms_dict[room_number][booking_id] = {
|
||||
'booking_id': booking_id,
|
||||
'bookings_group_id': room.get("bookings_group_id"),
|
||||
'nights': [],
|
||||
'is_dbb': room.get("is_dbb", False),
|
||||
'is_package': room.get("is_package", False)
|
||||
}
|
||||
|
||||
rooms_dict[room_number][booking_id]['nights'].append(record.date)
|
||||
|
||||
# Log rooms with multiple bookings to diagnose stacking issue
|
||||
for room_number, bookings in rooms_dict.items():
|
||||
if len(bookings) > 1:
|
||||
logger.warning(f"Room {room_number} has {len(bookings)} different bookings:")
|
||||
for booking_id, booking_data in bookings.items():
|
||||
nights_str = ', '.join(sorted([n.isoformat() for n in booking_data['nights']]))
|
||||
logger.warning(f" - Booking {booking_id}: nights={nights_str}")
|
||||
|
||||
# Convert to list - one entry per room with all its bookings
|
||||
hotel_stays = []
|
||||
for room_number, bookings in rooms_dict.items():
|
||||
# Collect all bookings for this room
|
||||
room_bookings = []
|
||||
all_nights = []
|
||||
|
||||
for booking_data in bookings.values():
|
||||
nights = sorted(booking_data['nights'])
|
||||
if nights:
|
||||
all_nights.extend(nights)
|
||||
check_in = nights[0]
|
||||
check_out = nights[-1] + timedelta(days=1)
|
||||
|
||||
room_bookings.append({
|
||||
'booking_id': booking_data['booking_id'],
|
||||
'bookings_group_id': booking_data.get('bookings_group_id'),
|
||||
'check_in': check_in.isoformat(),
|
||||
'check_out': check_out.isoformat(),
|
||||
'nights': [n.isoformat() for n in nights],
|
||||
'is_dbb': booking_data['is_dbb'],
|
||||
'is_package': booking_data['is_package']
|
||||
})
|
||||
|
||||
# Create one entry per room with all bookings
|
||||
if room_bookings:
|
||||
all_nights_sorted = sorted(set(all_nights))
|
||||
hotel_stays.append({
|
||||
'room_number': room_number,
|
||||
'bookings': room_bookings, # Array of all bookings in this room
|
||||
'all_nights': [n.isoformat() for n in all_nights_sorted] # All occupied nights for this room
|
||||
})
|
||||
|
||||
logger.info(f"Built {len(hotel_stays)} room entries")
|
||||
|
||||
# Fetch Resos bookings for hotel guests in this period
|
||||
result = await db.execute(
|
||||
select(ResosBooking).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= start_date,
|
||||
ResosBooking.booking_date <= end_date,
|
||||
ResosBooking.is_hotel_guest == True,
|
||||
ResosBooking.hotel_booking_number.isnot(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
resos_bookings = result.scalars().all()
|
||||
|
||||
# Build lookup: booking_id -> {date -> resos_booking}
|
||||
# Also handle group bookings via exclude_flag field (format: "#32990,#32991")
|
||||
resos_lookup = {}
|
||||
import re
|
||||
|
||||
for resos_booking in resos_bookings:
|
||||
booking_id = resos_booking.hotel_booking_number
|
||||
booking_date = resos_booking.booking_date.isoformat()
|
||||
|
||||
if booking_id not in resos_lookup:
|
||||
resos_lookup[booking_id] = {}
|
||||
|
||||
# Direct match for the lead/primary booking
|
||||
resos_lookup[booking_id][booking_date] = {
|
||||
'has_booking': True,
|
||||
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
|
||||
'people': resos_booking.people,
|
||||
'table_name': resos_booking.table_name,
|
||||
'opening_hour_name': resos_booking.opening_hour_name,
|
||||
'is_group_match': False # Direct match, not a group member
|
||||
}
|
||||
|
||||
# Parse exclude_flag for group bookings (format: "#32990,#32991")
|
||||
if resos_booking.exclude_flag:
|
||||
# Extract all booking numbers from the exclude_flag field
|
||||
group_booking_ids = re.findall(r'#(\d+)', resos_booking.exclude_flag)
|
||||
|
||||
for group_id in group_booking_ids:
|
||||
# Skip the lead booking itself (already added above)
|
||||
if group_id == booking_id:
|
||||
continue
|
||||
|
||||
# Add group member with is_group_match=True
|
||||
if group_id not in resos_lookup:
|
||||
resos_lookup[group_id] = {}
|
||||
|
||||
# Only add if not already present (don't overwrite direct matches)
|
||||
if booking_date not in resos_lookup[group_id]:
|
||||
resos_lookup[group_id][booking_date] = {
|
||||
'has_booking': True,
|
||||
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
|
||||
'people': resos_booking.people,
|
||||
'table_name': resos_booking.table_name,
|
||||
'opening_hour_name': resos_booking.opening_hour_name,
|
||||
'is_group_match': True # Matched via group, not direct
|
||||
}
|
||||
|
||||
logger.info(f"Built resos_lookup with {len(resos_lookup)} booking IDs (including group matches)")
|
||||
|
||||
# Combine rooms with restaurant bookings
|
||||
room_rows = []
|
||||
total_room_nights = 0
|
||||
nights_with_restaurant = 0
|
||||
|
||||
try:
|
||||
for room_data in hotel_stays:
|
||||
booking_segments = []
|
||||
|
||||
# Process each booking within this room
|
||||
for booking_data in room_data['bookings']:
|
||||
# Build restaurant bookings dict for each night in the 7-day period
|
||||
restaurant_bookings = {}
|
||||
|
||||
for date_str in date_range['dates']:
|
||||
# Check if this date is within this specific booking's nights
|
||||
if date_str in booking_data['nights']:
|
||||
total_room_nights += 1
|
||||
|
||||
# Check if there's a restaurant booking for this date
|
||||
resos_data = resos_lookup.get(booking_data['booking_id'], {}).get(date_str)
|
||||
|
||||
if resos_data:
|
||||
restaurant_bookings[date_str] = resos_data
|
||||
nights_with_restaurant += 1
|
||||
else:
|
||||
restaurant_bookings[date_str] = {'has_booking': False}
|
||||
else:
|
||||
# Not staying this night
|
||||
restaurant_bookings[date_str] = {'has_booking': False}
|
||||
|
||||
# Create booking segment with restaurant data
|
||||
booking_segments.append(BookingSegment(
|
||||
booking_id=booking_data['booking_id'],
|
||||
bookings_group_id=booking_data.get('bookings_group_id'),
|
||||
check_in=booking_data['check_in'],
|
||||
check_out=booking_data['check_out'],
|
||||
nights=booking_data['nights'],
|
||||
is_dbb=booking_data['is_dbb'],
|
||||
is_package=booking_data['is_package'],
|
||||
restaurant_bookings=restaurant_bookings
|
||||
))
|
||||
|
||||
# Create room row with all its bookings
|
||||
room_rows.append(RoomRow(
|
||||
room_number=room_data['room_number'],
|
||||
bookings=booking_segments
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Error building room_rows: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
logger.info(f"Built {len(room_rows)} room rows")
|
||||
|
||||
# Sort rooms by room number (natural sort for numeric rooms)
|
||||
def natural_sort_key(room: RoomRow):
|
||||
"""Natural sort key for room numbers (handles both numeric and alphanumeric)"""
|
||||
if not room.room_number:
|
||||
return (float('inf'), '') # Put None/empty at end
|
||||
|
||||
# Extract numeric part for sorting (e.g., "102" -> 102, "A-12" -> 12)
|
||||
import re
|
||||
numbers = re.findall(r'\d+', room.room_number)
|
||||
if numbers:
|
||||
return (int(numbers[0]), room.room_number)
|
||||
return (float('inf'), room.room_number)
|
||||
|
||||
room_rows.sort(key=natural_sort_key)
|
||||
|
||||
# Calculate summary
|
||||
coverage_pct = (nights_with_restaurant / total_room_nights * 100) if total_room_nights > 0 else 0.0
|
||||
|
||||
# Count total bookings across all rooms
|
||||
total_bookings = sum(len(room.bookings) for room in room_rows)
|
||||
|
||||
summary = {
|
||||
'total_rooms': len(room_rows),
|
||||
'total_bookings': total_bookings,
|
||||
'total_room_nights': total_room_nights,
|
||||
'nights_with_restaurant': nights_with_restaurant,
|
||||
'coverage_percentage': round(coverage_pct, 1)
|
||||
}
|
||||
|
||||
# Calculate aggregated metrics for different time periods
|
||||
def get_week_start(d: date) -> date:
|
||||
"""Get Monday of the week containing date d"""
|
||||
return d - timedelta(days=d.weekday())
|
||||
|
||||
async def calculate_period_metrics(period_start: date, period_end: date, is_forecast: Optional[bool] = None) -> dict:
|
||||
"""Calculate metrics for a specific date range"""
|
||||
query = select(NewbookDailyOccupancy).where(
|
||||
and_(
|
||||
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
||||
NewbookDailyOccupancy.date >= period_start,
|
||||
NewbookDailyOccupancy.date <= period_end,
|
||||
NewbookDailyOccupancy.rooms_breakdown.isnot(None)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by forecast status if specified
|
||||
if is_forecast is not None:
|
||||
query = query.where(NewbookDailyOccupancy.is_forecast == is_forecast)
|
||||
|
||||
result = await db.execute(query.order_by(NewbookDailyOccupancy.date))
|
||||
records = result.scalars().all()
|
||||
|
||||
# Count metrics
|
||||
total_room_nights_period = 0
|
||||
unique_bookings = set()
|
||||
nights_with_rest = 0
|
||||
|
||||
for record in records:
|
||||
rooms = record.rooms_breakdown or []
|
||||
for room in rooms:
|
||||
booking_id = room.get("booking_id")
|
||||
if booking_id:
|
||||
unique_bookings.add(booking_id)
|
||||
total_room_nights_period += 1
|
||||
|
||||
# Check if has restaurant booking for this date
|
||||
date_str = record.date.isoformat()
|
||||
resos_data = resos_lookup.get(booking_id, {}).get(date_str)
|
||||
if resos_data:
|
||||
nights_with_rest += 1
|
||||
|
||||
coverage_pct_period = (nights_with_rest / total_room_nights_period * 100) if total_room_nights_period > 0 else 0.0
|
||||
|
||||
# Calculate average occupancy
|
||||
total_available = 0
|
||||
total_occupied = 0
|
||||
for record in records:
|
||||
if record.total_rooms and record.occupied_rooms:
|
||||
total_available += record.total_rooms
|
||||
total_occupied += record.occupied_rooms
|
||||
|
||||
avg_occupancy = (total_occupied / total_available * 100) if total_available > 0 else 0.0
|
||||
|
||||
return {
|
||||
'total_bookings': len(unique_bookings),
|
||||
'total_room_nights': total_room_nights_period,
|
||||
'nights_with_restaurant': nights_with_rest,
|
||||
'coverage_percentage': round(coverage_pct_period, 1),
|
||||
'avg_occupancy_percentage': round(avg_occupancy, 1)
|
||||
}
|
||||
|
||||
today = date.today()
|
||||
|
||||
# This week (Monday to Sunday)
|
||||
this_week_start = get_week_start(today)
|
||||
this_week_end = this_week_start + timedelta(days=6)
|
||||
|
||||
# Last week (previous Monday to Sunday)
|
||||
last_week_start = this_week_start - timedelta(days=7)
|
||||
last_week_end = last_week_start + timedelta(days=6)
|
||||
|
||||
# Last 30 days rolling (from yesterday)
|
||||
yesterday = today - timedelta(days=1)
|
||||
rolling_30_start = yesterday - timedelta(days=29)
|
||||
rolling_30_end = yesterday
|
||||
|
||||
# Calculate metrics for each period - always return metrics with default values
|
||||
default_metrics = {
|
||||
'total_bookings': 0,
|
||||
'total_room_nights': 0,
|
||||
'nights_with_restaurant': 0,
|
||||
'coverage_percentage': 0.0,
|
||||
'avg_occupancy_percentage': 0.0
|
||||
}
|
||||
|
||||
try:
|
||||
metrics = {
|
||||
'this_week_actual': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=False),
|
||||
'this_week_forecast': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=True),
|
||||
'last_week_actual': await calculate_period_metrics(last_week_start, last_week_end, is_forecast=False),
|
||||
'last_30_days_rolling': await calculate_period_metrics(rolling_30_start, rolling_30_end, is_forecast=False),
|
||||
}
|
||||
logger.info(f"Calculated metrics: {metrics}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating metrics: {e}", exc_info=True)
|
||||
# Return default metrics structure instead of None
|
||||
metrics = {
|
||||
'this_week_actual': default_metrics.copy(),
|
||||
'this_week_forecast': default_metrics.copy(),
|
||||
'last_week_actual': default_metrics.copy(),
|
||||
'last_30_days_rolling': default_metrics.copy(),
|
||||
}
|
||||
|
||||
return ResidentsTableChartResponse(
|
||||
date_range=date_range,
|
||||
rooms=room_rows,
|
||||
summary=summary,
|
||||
metrics=metrics
|
||||
)
|
||||
|
|
@ -1,809 +0,0 @@
|
|||
"""
|
||||
Resos API Endpoints
|
||||
|
||||
Handles Resos configuration, sync operations, and booking data retrieval.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, func, Date, case, or_
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
|
||||
from auth import get_current_user, require_cap
|
||||
from services.resos_sync import ResosSyncService
|
||||
from services.resos_api import ResosAPIClient, ResosAPIError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class ResosSettingsResponse(BaseModel):
|
||||
resos_api_key_set: bool # Masked
|
||||
resos_last_sync: datetime | None
|
||||
resos_auto_sync_enabled: bool
|
||||
resos_upcoming_sync_enabled: bool
|
||||
resos_upcoming_sync_interval: int
|
||||
resos_last_upcoming_sync: datetime | None
|
||||
resos_large_group_threshold: int
|
||||
resos_note_keywords: str | None
|
||||
resos_allergy_keywords: str | None
|
||||
resos_custom_field_mapping: dict | None
|
||||
resos_opening_hours_mapping: list | None
|
||||
resos_restaurant_table_entities: str | None
|
||||
resos_enable_manual_breakfast: bool
|
||||
resos_manual_breakfast_periods: list | None
|
||||
resos_flag_icon_mapping: dict | None
|
||||
resos_arrival_widget_service_filter: str | None # Service type: breakfast/lunch/dinner/other
|
||||
sambapos_food_gl_codes: str | None # Phase 8.1
|
||||
sambapos_beverage_gl_codes: str | None # Phase 8.1
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResosSettingsUpdate(BaseModel):
|
||||
resos_api_key: str | None = None
|
||||
resos_auto_sync_enabled: bool | None = None
|
||||
resos_upcoming_sync_enabled: bool | None = None
|
||||
resos_upcoming_sync_interval: int | None = None
|
||||
resos_large_group_threshold: int | None = None
|
||||
resos_note_keywords: str | None = None
|
||||
resos_allergy_keywords: str | None = None
|
||||
resos_custom_field_mapping: dict | None = None
|
||||
resos_opening_hours_mapping: list | None = None
|
||||
resos_restaurant_table_entities: str | None = None
|
||||
resos_enable_manual_breakfast: bool | None = None
|
||||
resos_manual_breakfast_periods: list | None = None
|
||||
resos_flag_icon_mapping: dict | None = None
|
||||
resos_arrival_widget_service_filter: str | None = None # Opening hour ID for arrivals widget filter
|
||||
sambapos_food_gl_codes: str | None = None # Phase 8.1
|
||||
sambapos_beverage_gl_codes: str | None = None # Phase 8.1
|
||||
|
||||
|
||||
class DailyStatsResponse(BaseModel):
|
||||
date: str
|
||||
total_bookings: int
|
||||
total_covers: int
|
||||
service_breakdown: list[dict]
|
||||
flagged_booking_count: int
|
||||
unique_flag_types: list[str] | None
|
||||
is_forecast: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BookingResponse(BaseModel):
|
||||
id: int
|
||||
resos_booking_id: str
|
||||
booking_date: str
|
||||
booking_time: str
|
||||
people: int
|
||||
status: str
|
||||
seating_area: str | None
|
||||
hotel_booking_number: str | None
|
||||
is_hotel_guest: bool | None
|
||||
is_dbb: bool | None
|
||||
is_package: bool | None
|
||||
allergies: str | None
|
||||
notes: str | None
|
||||
opening_hour_name: str | None
|
||||
is_flagged: bool
|
||||
flag_reasons: str | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardCoversResponse(BaseModel):
|
||||
date: str
|
||||
total_bookings: int
|
||||
total_covers: int
|
||||
service_breakdown: list[dict]
|
||||
has_flagged_bookings: bool
|
||||
unique_flag_types: list[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_resos_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> ResosSettingsResponse:
|
||||
"""Get Resos settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
logger.info(f"[Resos GET] Returning upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
|
||||
|
||||
return ResosSettingsResponse(
|
||||
resos_api_key_set=bool(settings.resos_api_key),
|
||||
resos_last_sync=settings.resos_last_sync,
|
||||
resos_auto_sync_enabled=settings.resos_auto_sync_enabled or False,
|
||||
resos_upcoming_sync_enabled=settings.resos_upcoming_sync_enabled or False,
|
||||
resos_upcoming_sync_interval=settings.resos_upcoming_sync_interval or 15,
|
||||
resos_last_upcoming_sync=settings.resos_last_upcoming_sync,
|
||||
resos_large_group_threshold=settings.resos_large_group_threshold or 8,
|
||||
resos_note_keywords=settings.resos_note_keywords,
|
||||
resos_allergy_keywords=settings.resos_allergy_keywords,
|
||||
resos_custom_field_mapping=settings.resos_custom_field_mapping,
|
||||
resos_opening_hours_mapping=settings.resos_opening_hours_mapping,
|
||||
resos_restaurant_table_entities=settings.resos_restaurant_table_entities,
|
||||
resos_enable_manual_breakfast=settings.resos_enable_manual_breakfast or False,
|
||||
resos_manual_breakfast_periods=settings.resos_manual_breakfast_periods,
|
||||
resos_flag_icon_mapping=settings.resos_flag_icon_mapping,
|
||||
resos_arrival_widget_service_filter=settings.resos_arrival_widget_service_filter,
|
||||
sambapos_food_gl_codes=settings.sambapos_food_gl_codes, # Phase 8.1
|
||||
sambapos_beverage_gl_codes=settings.sambapos_beverage_gl_codes # Phase 8.1
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_resos_settings(
|
||||
update: ResosSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update Resos settings"""
|
||||
logger.info(f"[Resos PATCH] Received update: {update.model_dump(exclude_unset=True)}")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
logger.info(f"[Resos PATCH] Before update - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
|
||||
|
||||
if update.resos_api_key is not None:
|
||||
settings.resos_api_key = update.resos_api_key
|
||||
if update.resos_auto_sync_enabled is not None:
|
||||
settings.resos_auto_sync_enabled = update.resos_auto_sync_enabled
|
||||
if update.resos_upcoming_sync_enabled is not None:
|
||||
logger.info(f"[Resos PATCH] Setting upcoming_sync_enabled to {update.resos_upcoming_sync_enabled}")
|
||||
settings.resos_upcoming_sync_enabled = update.resos_upcoming_sync_enabled
|
||||
if update.resos_upcoming_sync_interval is not None:
|
||||
settings.resos_upcoming_sync_interval = update.resos_upcoming_sync_interval
|
||||
if update.resos_large_group_threshold is not None:
|
||||
settings.resos_large_group_threshold = update.resos_large_group_threshold
|
||||
if update.resos_note_keywords is not None:
|
||||
settings.resos_note_keywords = update.resos_note_keywords
|
||||
if update.resos_allergy_keywords is not None:
|
||||
settings.resos_allergy_keywords = update.resos_allergy_keywords
|
||||
if update.resos_custom_field_mapping is not None:
|
||||
settings.resos_custom_field_mapping = update.resos_custom_field_mapping
|
||||
if update.resos_opening_hours_mapping is not None:
|
||||
settings.resos_opening_hours_mapping = update.resos_opening_hours_mapping
|
||||
if update.resos_restaurant_table_entities is not None:
|
||||
settings.resos_restaurant_table_entities = update.resos_restaurant_table_entities
|
||||
if update.resos_enable_manual_breakfast is not None:
|
||||
settings.resos_enable_manual_breakfast = update.resos_enable_manual_breakfast
|
||||
if update.resos_manual_breakfast_periods is not None:
|
||||
settings.resos_manual_breakfast_periods = update.resos_manual_breakfast_periods
|
||||
if update.resos_flag_icon_mapping is not None:
|
||||
settings.resos_flag_icon_mapping = update.resos_flag_icon_mapping
|
||||
if update.resos_arrival_widget_service_filter is not None:
|
||||
settings.resos_arrival_widget_service_filter = update.resos_arrival_widget_service_filter
|
||||
# Phase 8.1: GL codes for food/beverage spend split
|
||||
if update.sambapos_food_gl_codes is not None:
|
||||
settings.sambapos_food_gl_codes = update.sambapos_food_gl_codes
|
||||
if update.sambapos_beverage_gl_codes is not None:
|
||||
settings.sambapos_beverage_gl_codes = update.sambapos_beverage_gl_codes
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"[Resos PATCH] After commit - upcoming_sync_enabled={settings.resos_upcoming_sync_enabled}")
|
||||
return {"message": "Settings updated successfully"}
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_resos_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Resos API connection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
if not settings.resos_api_key:
|
||||
raise HTTPException(status_code=400, detail="Resos API key not configured")
|
||||
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
success = await client.test_connection()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Connection failed")
|
||||
|
||||
return {"message": "Connection successful"}
|
||||
|
||||
|
||||
@router.get("/debug-upcoming-sync")
|
||||
async def debug_upcoming_sync(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to check upcoming sync settings directly"""
|
||||
from sqlalchemy import text
|
||||
|
||||
# Get value via ORM
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
orm_value = settings.resos_upcoming_sync_enabled
|
||||
|
||||
# Get value via raw SQL
|
||||
raw_result = await db.execute(
|
||||
text("SELECT resos_upcoming_sync_enabled, resos_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"),
|
||||
{"kid": current_user.kitchen_id}
|
||||
)
|
||||
raw_row = raw_result.fetchone()
|
||||
|
||||
return {
|
||||
"orm_value": orm_value,
|
||||
"raw_db_enabled": raw_row[0] if raw_row else None,
|
||||
"raw_db_interval": raw_row[1] if raw_row else None,
|
||||
"column_exists": raw_row is not None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/custom-fields")
|
||||
async def fetch_custom_fields(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch custom field definitions from Resos API (GET request only)"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
if not settings.resos_api_key:
|
||||
raise HTTPException(status_code=400, detail="Resos API key not configured")
|
||||
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
fields = await client.get_custom_field_definitions()
|
||||
|
||||
return {"custom_fields": fields}
|
||||
|
||||
|
||||
@router.get("/opening-hours")
|
||||
async def fetch_opening_hours(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch opening hours/service periods from Resos API (GET request only)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one()
|
||||
|
||||
if not settings.resos_api_key:
|
||||
raise HTTPException(status_code=400, detail="Resos API key not configured")
|
||||
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
hours = await client.get_opening_hours()
|
||||
|
||||
# Log the raw response to understand structure
|
||||
logger.info(f"Raw opening hours from Resos API: {len(hours)} periods")
|
||||
|
||||
# Filter out special/one-off periods - only return regular service periods
|
||||
# Filter on 'special' field: True = one-off events, False = recurring service periods
|
||||
regular_hours = [h for h in hours if h.get('special') == False]
|
||||
|
||||
# Day of week mapping (Resos uses 1=Monday, 7=Sunday)
|
||||
day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
# Transform time format: Resos uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00)
|
||||
# Convert to 'startTime' and 'endTime' in HH:MM format for frontend
|
||||
for hour in regular_hours:
|
||||
# Add day of week name
|
||||
day_num = hour.get('day', 0)
|
||||
if 1 <= day_num <= 7:
|
||||
hour['dayName'] = day_names[day_num]
|
||||
else:
|
||||
hour['dayName'] = 'Unknown'
|
||||
|
||||
if 'open' in hour:
|
||||
open_val = hour['open']
|
||||
hours_part = open_val // 100
|
||||
mins_part = open_val % 100
|
||||
hour['startTime'] = f"{hours_part:02d}:{mins_part:02d}"
|
||||
|
||||
if 'close' in hour:
|
||||
close_val = hour['close']
|
||||
hours_part = close_val // 100
|
||||
mins_part = close_val % 100
|
||||
hour['endTime'] = f"{hours_part:02d}:{mins_part:02d}"
|
||||
|
||||
# Auto-calculate actual end time by subtracting booking duration
|
||||
# Resos extends close time to allow late bookings
|
||||
seating = hour.get('seating', {})
|
||||
duration = seating.get('duration', 0) # Duration in minutes
|
||||
if duration > 0:
|
||||
# Convert close time to minutes
|
||||
close_minutes = hours_part * 60 + mins_part
|
||||
# Subtract booking duration
|
||||
actual_end_minutes = close_minutes - duration
|
||||
# Convert back to HH:MM
|
||||
actual_hours = actual_end_minutes // 60
|
||||
actual_mins = actual_end_minutes % 60
|
||||
hour['actualEnd'] = f"{actual_hours:02d}:{actual_mins:02d}"
|
||||
hour['bookingDuration'] = duration
|
||||
|
||||
# Sort by day of week first, then by open time within each day
|
||||
regular_hours.sort(key=lambda h: (h.get('day', 0), h.get('open', 0)))
|
||||
|
||||
logger.info(f"After filtering: {len(regular_hours)} regular periods (filtered out {len(hours) - len(regular_hours)} special periods)")
|
||||
|
||||
return {"opening_hours": regular_hours}
|
||||
|
||||
|
||||
@router.get("/opening-hours/{date}")
|
||||
async def get_opening_hours_for_date(
|
||||
date: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get opening hours configuration for a specific date
|
||||
|
||||
Returns opening hour periods with their times and intervals.
|
||||
Used by Gantt chart to determine time range and closed periods.
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Parse date
|
||||
try:
|
||||
query_date = datetime.fromisoformat(date).date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format")
|
||||
|
||||
# Get kitchen settings
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.resos_api_key:
|
||||
logger.warning("No Resos API key configured")
|
||||
return []
|
||||
|
||||
# Fetch opening hours from Resos API
|
||||
async with ResosAPIClient(settings.resos_api_key) as client:
|
||||
hours = await client.get_opening_hours()
|
||||
|
||||
# Filter to regular (non-special) hours and format for Gantt chart
|
||||
day_of_week = query_date.isoweekday() # Monday=1, Sunday=7
|
||||
|
||||
formatted_hours = []
|
||||
for hour in hours:
|
||||
# Skip special/one-off periods
|
||||
if hour.get('special') == True:
|
||||
continue
|
||||
|
||||
# Check if this opening hour applies to the query date's day of week
|
||||
hour_day = hour.get('day', 0)
|
||||
if hour_day != day_of_week:
|
||||
continue
|
||||
|
||||
# Get open and close times (already in HHMM format from API)
|
||||
open_time = hour.get('open', 0)
|
||||
close_time = hour.get('close', 0)
|
||||
|
||||
# Find service type from mapping
|
||||
resos_id = hour.get('id', '')
|
||||
service_type = None
|
||||
if settings.resos_opening_hours_mapping:
|
||||
for mapping in settings.resos_opening_hours_mapping:
|
||||
if isinstance(mapping, dict) and mapping.get('resos_id') == resos_id:
|
||||
service_type = mapping.get('service_type', '')
|
||||
break
|
||||
|
||||
formatted_hours.append({
|
||||
"name": hour.get('name', ''),
|
||||
"service_type": service_type or hour.get('name', ''),
|
||||
"open": open_time,
|
||||
"close": close_time,
|
||||
"is_special": False
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(formatted_hours)} opening hours for {query_date} (day {day_of_week})")
|
||||
return formatted_hours
|
||||
|
||||
|
||||
# ============ Sync Endpoints ============
|
||||
|
||||
@router.post("/sync/upcoming")
|
||||
async def sync_upcoming(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manual upcoming sync (next 7 days) - triggers immediately and updates last_upcoming_sync timestamp"""
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
result = await sync_service.run_upcoming_sync()
|
||||
logger.info(f"[Resos] Manual upcoming sync completed for kitchen {current_user.kitchen_id}")
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync/forecast")
|
||||
async def sync_forecast(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manual forecast sync (next 60 days)"""
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
|
||||
today = date.today()
|
||||
to_date = today + timedelta(days=60)
|
||||
|
||||
result = await sync_service.sync_bookings(today, to_date, is_forecast=True)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync/historical")
|
||||
async def sync_historical(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manual historical sync"""
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
result = await sync_service.sync_bookings(from_date, to_date, is_forecast=False)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/sync/opening-hours")
|
||||
async def sync_opening_hours(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Sync opening hours from Resos API to local database"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info(f"Starting opening hours sync for kitchen {current_user.kitchen_id}")
|
||||
|
||||
sync_service = ResosSyncService(current_user.kitchen_id, db)
|
||||
count = await sync_service.sync_opening_hours()
|
||||
|
||||
logger.info(f"Successfully synced {count} opening hours to database")
|
||||
|
||||
return {
|
||||
"message": f"Successfully synced {count} opening hours",
|
||||
"count": count
|
||||
}
|
||||
|
||||
|
||||
# ============ Data Retrieval Endpoints ============
|
||||
|
||||
@router.get("/daily-stats")
|
||||
async def get_daily_stats(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[DailyStatsResponse]:
|
||||
"""Get daily stats for date range"""
|
||||
result = await db.execute(
|
||||
select(ResosDailyStats).where(
|
||||
and_(
|
||||
ResosDailyStats.kitchen_id == current_user.kitchen_id,
|
||||
ResosDailyStats.date >= from_date,
|
||||
ResosDailyStats.date <= to_date
|
||||
)
|
||||
).order_by(ResosDailyStats.date)
|
||||
)
|
||||
|
||||
stats = result.scalars().all()
|
||||
|
||||
return [
|
||||
DailyStatsResponse(
|
||||
date=stat.date.isoformat(),
|
||||
total_bookings=stat.total_bookings,
|
||||
total_covers=stat.total_covers,
|
||||
service_breakdown=stat.service_breakdown or [],
|
||||
flagged_booking_count=stat.flagged_booking_count,
|
||||
unique_flag_types=stat.unique_flag_types,
|
||||
is_forecast=stat.is_forecast
|
||||
)
|
||||
for stat in stats
|
||||
]
|
||||
|
||||
|
||||
@router.get("/bookings/{booking_date}")
|
||||
async def get_bookings_for_date(
|
||||
booking_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> list[BookingResponse]:
|
||||
"""Get all bookings for a specific date, excluding cancelled/deleted"""
|
||||
excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected']
|
||||
result = await db.execute(
|
||||
select(ResosBooking).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date == booking_date,
|
||||
~func.lower(ResosBooking.status).in_(excluded_statuses)
|
||||
)
|
||||
).order_by(ResosBooking.booking_time)
|
||||
)
|
||||
|
||||
bookings = result.scalars().all()
|
||||
|
||||
return [
|
||||
BookingResponse(
|
||||
id=b.id,
|
||||
resos_booking_id=b.resos_booking_id,
|
||||
booking_date=b.booking_date.isoformat(),
|
||||
booking_time=b.booking_time.isoformat(),
|
||||
people=b.people,
|
||||
status=b.status,
|
||||
seating_area=b.seating_area,
|
||||
hotel_booking_number=b.hotel_booking_number,
|
||||
is_hotel_guest=b.is_hotel_guest,
|
||||
is_dbb=b.is_dbb,
|
||||
is_package=b.is_package,
|
||||
allergies=b.allergies,
|
||||
notes=b.notes,
|
||||
opening_hour_name=b.opening_hour_name,
|
||||
is_flagged=b.is_flagged,
|
||||
flag_reasons=b.flag_reasons
|
||||
)
|
||||
for b in bookings
|
||||
]
|
||||
|
||||
|
||||
# ============ Dashboard Endpoint ============
|
||||
|
||||
@router.get("/dashboard/today-tomorrow")
|
||||
async def get_dashboard_covers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Get today, tomorrow, and day after tomorrow covers for dashboard"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
today = date.today()
|
||||
tomorrow = today + timedelta(days=1)
|
||||
day_after = today + timedelta(days=2)
|
||||
|
||||
# Day names for display
|
||||
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
result = await db.execute(
|
||||
select(ResosDailyStats).where(
|
||||
and_(
|
||||
ResosDailyStats.kitchen_id == current_user.kitchen_id,
|
||||
ResosDailyStats.date.in_([today, tomorrow, day_after])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
stats = {stat.date: stat for stat in result.scalars().all()}
|
||||
|
||||
def build_response(target_date: date, day_label: str) -> Optional[dict]:
|
||||
if target_date not in stats:
|
||||
return {
|
||||
'date': target_date.isoformat(),
|
||||
'day_label': day_label,
|
||||
'total_bookings': 0,
|
||||
'total_covers': 0,
|
||||
'service_breakdown': [],
|
||||
'has_flagged_bookings': False,
|
||||
'unique_flag_types': []
|
||||
}
|
||||
stat = stats[target_date]
|
||||
logger.info(f"Date {target_date}: unique_flag_types={stat.unique_flag_types}, type={type(stat.unique_flag_types)}, flagged_count={stat.flagged_booking_count}")
|
||||
return {
|
||||
'date': stat.date.isoformat(),
|
||||
'day_label': day_label,
|
||||
'total_bookings': stat.total_bookings,
|
||||
'total_covers': stat.total_covers,
|
||||
'service_breakdown': stat.service_breakdown or [],
|
||||
'has_flagged_bookings': stat.flagged_booking_count > 0,
|
||||
'unique_flag_types': stat.unique_flag_types or []
|
||||
}
|
||||
|
||||
return {
|
||||
'today': build_response(today, 'Today'),
|
||||
'tomorrow': build_response(tomorrow, 'Tomorrow'),
|
||||
'day_after': build_response(day_after, day_names[day_after.weekday()])
|
||||
}
|
||||
|
||||
|
||||
# ============ Stats Report Endpoint (Phase 8) ============
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_bookings_stats(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""
|
||||
Get comprehensive booking statistics with spend analysis (Phase 8).
|
||||
|
||||
Calculates:
|
||||
- Total bookings and covers
|
||||
- Average lead time (days between booking and reservation)
|
||||
- Spend analysis (food/beverage/total) matched from SambaPOS
|
||||
- Resident vs non-resident split (by covers and spend)
|
||||
- Daily breakdown
|
||||
- Service period breakdown with avg spend per cover
|
||||
"""
|
||||
from services.resos_stats import ResosStatsService
|
||||
|
||||
# Get summary metrics from resos_bookings
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(ResosBooking.id).label('total_bookings'),
|
||||
func.sum(ResosBooking.people).label('total_covers'),
|
||||
func.avg(
|
||||
ResosBooking.booking_date - func.cast(ResosBooking.booked_at, Date)
|
||||
).label('avg_lead_time_days')
|
||||
).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= from_date,
|
||||
ResosBooking.booking_date <= to_date,
|
||||
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved'])
|
||||
)
|
||||
)
|
||||
)
|
||||
summary = result.first()
|
||||
|
||||
total_bookings = summary.total_bookings or 0
|
||||
total_covers = summary.total_covers or 0
|
||||
avg_lead_time_days = float(summary.avg_lead_time_days) if summary.avg_lead_time_days else 0.0
|
||||
|
||||
# Get resident/non-resident cover counts
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.sum(case((ResosBooking.is_hotel_guest == True, ResosBooking.people), else_=0)).label('resident_covers'),
|
||||
func.sum(case((ResosBooking.is_hotel_guest == False, ResosBooking.people), else_=0)).label('non_resident_covers')
|
||||
).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= from_date,
|
||||
ResosBooking.booking_date <= to_date,
|
||||
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived', 'confirmed', 'approved'])
|
||||
)
|
||||
)
|
||||
)
|
||||
resident_split = result.first()
|
||||
resident_covers_booking = resident_split.resident_covers or 0
|
||||
non_resident_covers_booking = resident_split.non_resident_covers or 0
|
||||
|
||||
# Get spend statistics (Phase 8 integration)
|
||||
stats_service = ResosStatsService(current_user.kitchen_id, db)
|
||||
spend_stats = await stats_service.get_spend_statistics(from_date, to_date)
|
||||
|
||||
# Calculate resident percentages
|
||||
resident_pct_covers = (resident_covers_booking / total_covers * 100) if total_covers > 0 else 0.0
|
||||
total_spend = spend_stats['total_spend']
|
||||
resident_pct_spend = (spend_stats['resident_spend'] / total_spend * 100) if total_spend > 0 else 0.0
|
||||
|
||||
# Combine booking data with spend data
|
||||
return {
|
||||
'summary': {
|
||||
'total_bookings': total_bookings,
|
||||
'total_covers': total_covers,
|
||||
'avg_lead_time_days': round(avg_lead_time_days, 1),
|
||||
'resident_covers': resident_covers_booking,
|
||||
'non_resident_covers': non_resident_covers_booking,
|
||||
'resident_pct_covers': round(resident_pct_covers, 1),
|
||||
'resident_pct_spend': round(resident_pct_spend, 1)
|
||||
},
|
||||
'spend': {
|
||||
'total_spend': spend_stats['total_spend'],
|
||||
'food_spend': spend_stats['food_spend'],
|
||||
'beverage_spend': spend_stats['beverage_spend'],
|
||||
'resident_spend': spend_stats['resident_spend'],
|
||||
'non_resident_spend': spend_stats['non_resident_spend'],
|
||||
'total_tickets': spend_stats['total_tickets'],
|
||||
'resident_tickets': spend_stats['resident_tickets'],
|
||||
'non_resident_tickets': spend_stats['non_resident_tickets'],
|
||||
'matched_to_resos': spend_stats['matched_to_resos'],
|
||||
'unmatched_to_resos': spend_stats['unmatched_to_resos'],
|
||||
'classification': spend_stats['classification']
|
||||
},
|
||||
'daily_breakdown': spend_stats['daily_breakdown'],
|
||||
'service_period_breakdown': spend_stats['service_period_breakdown'],
|
||||
'daily_service_breakdown': spend_stats['daily_service_breakdown']
|
||||
}
|
||||
|
||||
|
||||
# ============ Resident Covers for Budget ============
|
||||
|
||||
@router.get("/resident-covers")
|
||||
async def get_resident_covers(
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> dict:
|
||||
"""
|
||||
Get per-date, per-service-period hotel guest (resident) cover counts.
|
||||
Used by the Budget page to show 'inc Residents' row in the forecast table.
|
||||
"""
|
||||
excluded_statuses = ['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected']
|
||||
|
||||
# Query bookings grouped by date and opening_hour
|
||||
result = await db.execute(
|
||||
select(
|
||||
ResosBooking.booking_date,
|
||||
ResosBooking.opening_hour_id,
|
||||
ResosBooking.opening_hour_name,
|
||||
func.sum(ResosBooking.people).label('resident_covers'),
|
||||
func.count(ResosBooking.id).label('resident_bookings'),
|
||||
).where(
|
||||
and_(
|
||||
ResosBooking.kitchen_id == current_user.kitchen_id,
|
||||
ResosBooking.booking_date >= start_date,
|
||||
ResosBooking.booking_date <= end_date,
|
||||
ResosBooking.is_hotel_guest == True,
|
||||
~func.lower(ResosBooking.status).in_(excluded_statuses)
|
||||
)
|
||||
).group_by(
|
||||
ResosBooking.booking_date,
|
||||
ResosBooking.opening_hour_id,
|
||||
ResosBooking.opening_hour_name
|
||||
)
|
||||
)
|
||||
|
||||
# Get kitchen settings for service type mapping
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None
|
||||
|
||||
service_type_map = {}
|
||||
if opening_hours_mapping:
|
||||
for mapping in opening_hours_mapping:
|
||||
if isinstance(mapping, dict):
|
||||
resos_id = mapping.get('resos_id', '')
|
||||
service_type = mapping.get('service_type', '')
|
||||
if resos_id and service_type:
|
||||
service_type_map[resos_id] = service_type
|
||||
|
||||
# Build per-date, per-period response with covers and booking counts
|
||||
dates: dict[str, dict[str, dict[str, int]]] = {}
|
||||
for row in result:
|
||||
date_str = row.booking_date.isoformat()
|
||||
if date_str not in dates:
|
||||
dates[date_str] = {}
|
||||
|
||||
opening_hour_id = row.opening_hour_id
|
||||
opening_hour_name = row.opening_hour_name or 'Unknown'
|
||||
service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name
|
||||
period = service_type.lower() if service_type else 'unknown'
|
||||
|
||||
# Accumulate in case multiple opening hours map to same period
|
||||
if period not in dates[date_str]:
|
||||
dates[date_str][period] = {"covers": 0, "bookings": 0}
|
||||
dates[date_str][period]["covers"] += row.resident_covers or 0
|
||||
dates[date_str][period]["bookings"] += row.resident_bookings or 0
|
||||
|
||||
return {"dates": dates}
|
||||
|
|
@ -1,690 +0,0 @@
|
|||
"""
|
||||
SambaPOS API Endpoints
|
||||
|
||||
Handles SambaPOS MSSQL configuration and category management.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
from services.sambapos_api import SambaPOSClient
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class SambaPOSSettingsResponse(BaseModel):
|
||||
sambapos_db_host: str | None
|
||||
sambapos_db_port: int | None
|
||||
sambapos_db_name: str | None
|
||||
sambapos_db_username: str | None
|
||||
sambapos_db_password_set: bool
|
||||
sambapos_tracked_categories: list[str]
|
||||
sambapos_excluded_items: list[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SambaPOSSettingsUpdate(BaseModel):
|
||||
sambapos_db_host: str | None = None
|
||||
sambapos_db_port: int | None = None
|
||||
sambapos_db_name: str | None = None
|
||||
sambapos_db_username: str | None = None
|
||||
sambapos_db_password: str | None = None
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class MenuItemResponse(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
|
||||
|
||||
class TrackedCategoriesUpdate(BaseModel):
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class ExcludedItemsUpdate(BaseModel):
|
||||
items: list[str]
|
||||
|
||||
|
||||
# ============ Settings Endpoints ============
|
||||
|
||||
@router.get("/settings", response_model=SambaPOSSettingsResponse)
|
||||
async def get_sambapos_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get SambaPOS connection settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse tracked categories from comma-separated string
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
# Parse excluded items from comma-separated string
|
||||
excluded_items = []
|
||||
if settings.sambapos_excluded_items:
|
||||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||||
|
||||
return SambaPOSSettingsResponse(
|
||||
sambapos_db_host=settings.sambapos_db_host,
|
||||
sambapos_db_port=settings.sambapos_db_port,
|
||||
sambapos_db_name=settings.sambapos_db_name,
|
||||
sambapos_db_username=settings.sambapos_db_username,
|
||||
sambapos_db_password_set=bool(settings.sambapos_db_password),
|
||||
sambapos_tracked_categories=tracked_categories,
|
||||
sambapos_excluded_items=excluded_items
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SambaPOSSettingsResponse)
|
||||
async def update_sambapos_settings(
|
||||
update: SambaPOSSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update SambaPOS connection settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
# Parse tracked categories from comma-separated string
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
# Parse excluded items from pipe-separated string
|
||||
excluded_items = []
|
||||
if settings.sambapos_excluded_items:
|
||||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||||
|
||||
return SambaPOSSettingsResponse(
|
||||
sambapos_db_host=settings.sambapos_db_host,
|
||||
sambapos_db_port=settings.sambapos_db_port,
|
||||
sambapos_db_name=settings.sambapos_db_name,
|
||||
sambapos_db_username=settings.sambapos_db_username,
|
||||
sambapos_db_password_set=bool(settings.sambapos_db_password),
|
||||
sambapos_tracked_categories=tracked_categories,
|
||||
sambapos_excluded_items=excluded_items
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_sambapos_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test SambaPOS database connection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
result = await client.test_connection()
|
||||
|
||||
if result["success"]:
|
||||
return {"status": "success", "message": "SambaPOS connection successful"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}")
|
||||
|
||||
|
||||
# ============ Categories Endpoints ============
|
||||
|
||||
@router.get("/categories", response_model=list[CategoryResponse])
|
||||
async def get_sambapos_categories(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all menu categories from SambaPOS database"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
categories = await client.get_categories()
|
||||
return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tracked-categories")
|
||||
async def get_tracked_categories(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of category names enabled for top sellers"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse tracked categories from comma-separated string
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
return {"categories": tracked_categories}
|
||||
|
||||
|
||||
@router.patch("/tracked-categories")
|
||||
async def update_tracked_categories(
|
||||
update: TrackedCategoriesUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update which categories are included in top sellers"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Store as comma-separated string
|
||||
settings.sambapos_tracked_categories = ','.join(update.categories)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "categories": update.categories}
|
||||
|
||||
|
||||
@router.get("/debug/menuitems")
|
||||
async def debug_menuitems(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to explore MenuItems table structure"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.sambapos_db_password:
|
||||
raise HTTPException(status_code=400, detail="SambaPOS not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_menu_items()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ============ Menu Items Endpoints ============
|
||||
|
||||
@router.get("/menu-items", response_model=list[MenuItemResponse])
|
||||
async def get_menu_items(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all unique menu item names with their categories for exclusion selection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
# Get tracked categories to filter menu items
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None)
|
||||
return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}")
|
||||
|
||||
|
||||
class MenuItemWithPortionResponse(BaseModel):
|
||||
menu_item_name: str
|
||||
portion_name: str
|
||||
category: str
|
||||
on_pos_menu: bool
|
||||
|
||||
|
||||
@router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse])
|
||||
async def get_menu_items_with_portions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all menu items with their portion names, grouped by Kitchen Course category."""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
# Get tracked categories to filter
|
||||
tracked_categories = []
|
||||
if settings.sambapos_tracked_categories:
|
||||
tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()]
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
items = await client.get_menu_items_with_portions(
|
||||
categories=tracked_categories if tracked_categories else None
|
||||
)
|
||||
return [
|
||||
MenuItemWithPortionResponse(
|
||||
menu_item_name=item["menu_item_name"],
|
||||
portion_name=item["portion_name"],
|
||||
category=item["category"],
|
||||
on_pos_menu=item.get("on_pos_menu", False)
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/excluded-items")
|
||||
async def get_excluded_items(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of menu item names excluded from top sellers report"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse excluded items from pipe-separated string
|
||||
excluded_items = []
|
||||
if settings.sambapos_excluded_items:
|
||||
excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()]
|
||||
|
||||
return {"items": excluded_items}
|
||||
|
||||
|
||||
@router.patch("/excluded-items")
|
||||
async def update_excluded_items(
|
||||
update: ExcludedItemsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update which menu item GroupCodes are excluded from top sellers report"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Store as pipe-separated string (to allow commas in names)
|
||||
settings.sambapos_excluded_items = '|'.join(update.items)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "items": update.items}
|
||||
|
||||
|
||||
# ============ Group Codes Endpoints ============
|
||||
|
||||
class GroupCodeResponse(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.get("/group-codes", response_model=list[GroupCodeResponse])
|
||||
async def get_group_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all distinct GroupCode values from MenuItems table for exclusion selection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
group_codes = await client.get_menu_group_codes()
|
||||
return [GroupCodeResponse(name=gc["name"]) for gc in group_codes]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}")
|
||||
|
||||
|
||||
# ============ GL Codes Endpoints (Phase 8) ============
|
||||
|
||||
class GLCodeResponse(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class GLCodesUpdate(BaseModel):
|
||||
food_codes: list[str]
|
||||
beverage_codes: list[str]
|
||||
|
||||
|
||||
@router.get("/gl-codes", response_model=list[GLCodeResponse])
|
||||
async def get_gl_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Fetch all unique GL codes from ProductTag custom tags for food/beverage classification"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
gl_codes = await client.get_gl_codes()
|
||||
return [GLCodeResponse(code=gc["code"]) for gc in gl_codes]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/gl-codes/selected")
|
||||
async def get_selected_gl_codes(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get currently selected food and beverage GL codes"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Parse food GL codes from comma-separated string
|
||||
food_codes = []
|
||||
if settings.sambapos_food_gl_codes:
|
||||
food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()]
|
||||
|
||||
# Parse beverage GL codes from comma-separated string
|
||||
beverage_codes = []
|
||||
if settings.sambapos_beverage_gl_codes:
|
||||
beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()]
|
||||
|
||||
return {
|
||||
"food_codes": food_codes,
|
||||
"beverage_codes": beverage_codes
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/gl-codes")
|
||||
async def update_gl_codes(
|
||||
update: GLCodesUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update food and beverage GL code selections"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
# Store as comma-separated strings
|
||||
settings.sambapos_food_gl_codes = ','.join(update.food_codes)
|
||||
settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"food_codes": update.food_codes,
|
||||
"beverage_codes": update.beverage_codes
|
||||
}
|
||||
|
||||
|
||||
@router.get("/debug/custom-tags")
|
||||
async def debug_custom_tags(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to see sample CustomTags from MenuItems"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_menu_items()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/debug/zero-price-order-states")
|
||||
async def debug_zero_price_order_states(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Debug endpoint: returns sample OrderStates blobs for zero-priced orders.
|
||||
Use this to discover the JSON structure and identify where original/package price is stored.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.sambapos_db_password:
|
||||
raise HTTPException(status_code=400, detail="SambaPOS not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_zero_price_order_states()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/debug/table-schema")
|
||||
async def debug_table_schema(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Debug endpoint to inspect column names in SambaPOS tables"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Settings not found")
|
||||
|
||||
if not all([
|
||||
settings.sambapos_db_host,
|
||||
settings.sambapos_db_name,
|
||||
settings.sambapos_db_username,
|
||||
settings.sambapos_db_password
|
||||
]):
|
||||
raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured")
|
||||
|
||||
client = SambaPOSClient(
|
||||
host=settings.sambapos_db_host,
|
||||
port=settings.sambapos_db_port or 1433,
|
||||
database=settings.sambapos_db_name,
|
||||
username=settings.sambapos_db_username,
|
||||
password=settings.sambapos_db_password
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.debug_table_schema()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}")
|
||||
|
|
@ -1,744 +0,0 @@
|
|||
"""
|
||||
Search API endpoints for searching invoices, line items, and product definitions.
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_, desc
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.invoice import Invoice, InvoiceStatus
|
||||
from models.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.product_definition import ProductDefinition
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
from services.price_history import PriceHistoryService
|
||||
|
||||
router = APIRouter(prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
# ============ Response Models ============
|
||||
|
||||
class GroupSummary(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
total: Optional[Decimal] = None
|
||||
|
||||
|
||||
class InvoiceSearchItem(BaseModel):
|
||||
id: int
|
||||
invoice_number: Optional[str]
|
||||
invoice_date: Optional[date]
|
||||
total: Optional[Decimal]
|
||||
net_total: Optional[Decimal]
|
||||
supplier_id: Optional[int]
|
||||
supplier_name: Optional[str]
|
||||
vendor_name: Optional[str]
|
||||
status: str
|
||||
document_type: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class InvoiceSearchResponse(BaseModel):
|
||||
items: List[InvoiceSearchItem]
|
||||
total_count: int
|
||||
grouped_by: Optional[str]
|
||||
groups: Optional[List[GroupSummary]]
|
||||
|
||||
|
||||
class LineItemSearchItem(BaseModel):
|
||||
product_code: Optional[str]
|
||||
description: Optional[str]
|
||||
supplier_id: Optional[int]
|
||||
supplier_name: Optional[str]
|
||||
unit: Optional[str]
|
||||
most_recent_price: Optional[Decimal]
|
||||
earliest_price_in_period: Optional[Decimal]
|
||||
price_change_percent: Optional[float]
|
||||
price_change_status: str
|
||||
total_quantity: Optional[Decimal]
|
||||
occurrence_count: int
|
||||
most_recent_invoice_id: Optional[int]
|
||||
most_recent_invoice_number: Optional[str]
|
||||
most_recent_date: Optional[date]
|
||||
has_definition: bool
|
||||
portions_per_unit: Optional[int]
|
||||
pack_quantity: Optional[int]
|
||||
most_recent_line_item_id: Optional[int] = None
|
||||
most_recent_line_number: Optional[int] = None
|
||||
most_recent_raw_content: Optional[str] = None
|
||||
most_recent_pack_quantity: Optional[int] = None
|
||||
most_recent_unit_size: Optional[Decimal] = None
|
||||
most_recent_unit_size_type: Optional[str] = None
|
||||
# Ingredient mapping info
|
||||
ingredient_id: Optional[int] = None
|
||||
ingredient_name: Optional[str] = None
|
||||
ingredient_standard_unit: Optional[str] = None
|
||||
price_per_std_unit: Optional[Decimal] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LineItemSearchResponse(BaseModel):
|
||||
items: List[LineItemSearchItem]
|
||||
total_count: int
|
||||
grouped_by: Optional[str]
|
||||
groups: Optional[List[GroupSummary]]
|
||||
|
||||
|
||||
class DefinitionSearchItem(BaseModel):
|
||||
id: int
|
||||
product_code: Optional[str]
|
||||
description_pattern: Optional[str]
|
||||
supplier_id: Optional[int]
|
||||
supplier_name: Optional[str]
|
||||
pack_quantity: Optional[int]
|
||||
unit_size: Optional[Decimal]
|
||||
unit_size_type: Optional[str]
|
||||
portions_per_unit: Optional[int]
|
||||
portion_description: Optional[str]
|
||||
source_invoice_id: Optional[int]
|
||||
source_invoice_number: Optional[str]
|
||||
most_recent_price: Optional[Decimal]
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DefinitionSearchResponse(BaseModel):
|
||||
items: List[DefinitionSearchItem]
|
||||
total_count: int
|
||||
|
||||
|
||||
class PriceHistoryPointResponse(BaseModel):
|
||||
date: date
|
||||
price: Decimal
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
quantity: Optional[Decimal]
|
||||
|
||||
|
||||
class LineItemHistoryResponse(BaseModel):
|
||||
product_code: Optional[str]
|
||||
description: Optional[str]
|
||||
supplier_id: int
|
||||
supplier_name: Optional[str]
|
||||
price_history: List[PriceHistoryPointResponse]
|
||||
total_occurrences: int
|
||||
total_quantity: Decimal
|
||||
avg_qty_per_invoice: Decimal
|
||||
avg_qty_per_week: Decimal
|
||||
avg_qty_per_month: Decimal
|
||||
current_price: Optional[Decimal]
|
||||
price_change_status: str
|
||||
|
||||
|
||||
class AcknowledgePriceRequest(BaseModel):
|
||||
product_code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
supplier_id: int
|
||||
new_price: Decimal
|
||||
source_invoice_id: Optional[int] = None
|
||||
source_line_item_id: Optional[int] = None
|
||||
|
||||
|
||||
class AcknowledgePriceResponse(BaseModel):
|
||||
id: int
|
||||
acknowledged_price: Decimal
|
||||
acknowledged_at: datetime
|
||||
|
||||
|
||||
# ============ Invoice Search ============
|
||||
|
||||
@router.get("/invoices", response_model=InvoiceSearchResponse)
|
||||
async def search_invoices(
|
||||
q: str = "",
|
||||
include_line_items: bool = False,
|
||||
supplier_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
group_by: Optional[str] = None,
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search invoices with optional filters.
|
||||
|
||||
- q: Search term (invoice_number, vendor_name)
|
||||
- include_line_items: Also search line item product_code/description
|
||||
- supplier_id: Filter by supplier
|
||||
- status: Filter by status (pending, confirmed, etc.)
|
||||
- date_from/date_to: Date range (default: last 30 days)
|
||||
- group_by: "supplier" or "month" for grouped results
|
||||
"""
|
||||
# Default date range: last 30 days
|
||||
if date_to is None:
|
||||
date_to = date.today()
|
||||
if date_from is None:
|
||||
date_from = date_to - timedelta(days=30)
|
||||
|
||||
# Build base conditions
|
||||
conditions = [
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
Invoice.invoice_date >= date_from,
|
||||
Invoice.invoice_date <= date_to,
|
||||
]
|
||||
|
||||
if supplier_id:
|
||||
conditions.append(Invoice.supplier_id == supplier_id)
|
||||
|
||||
if status:
|
||||
conditions.append(Invoice.status == status)
|
||||
|
||||
# Search filter
|
||||
if q:
|
||||
search_pattern = f"%{q}%"
|
||||
search_conditions = [
|
||||
Invoice.invoice_number.ilike(search_pattern),
|
||||
Invoice.vendor_name.ilike(search_pattern),
|
||||
]
|
||||
|
||||
if include_line_items:
|
||||
# Need to join line items and search there too
|
||||
line_item_subquery = (
|
||||
select(LineItem.invoice_id)
|
||||
.where(or_(
|
||||
LineItem.product_code.ilike(search_pattern),
|
||||
LineItem.description.ilike(search_pattern)
|
||||
))
|
||||
.distinct()
|
||||
)
|
||||
search_conditions.append(Invoice.id.in_(line_item_subquery))
|
||||
|
||||
conditions.append(or_(*search_conditions))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count(Invoice.id)).where(and_(*conditions))
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Get invoices with supplier name
|
||||
query = (
|
||||
select(Invoice, Supplier.name.label('supplier_name'))
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = [
|
||||
InvoiceSearchItem(
|
||||
id=row.Invoice.id,
|
||||
invoice_number=row.Invoice.invoice_number,
|
||||
invoice_date=row.Invoice.invoice_date,
|
||||
total=row.Invoice.total,
|
||||
net_total=row.Invoice.net_total,
|
||||
supplier_id=row.Invoice.supplier_id,
|
||||
supplier_name=row.supplier_name,
|
||||
vendor_name=row.Invoice.vendor_name,
|
||||
status=row.Invoice.status.value if isinstance(row.Invoice.status, InvoiceStatus) else row.Invoice.status,
|
||||
document_type=row.Invoice.document_type
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# Handle grouping
|
||||
groups = None
|
||||
if group_by == "supplier":
|
||||
group_query = (
|
||||
select(
|
||||
Supplier.name,
|
||||
func.count(Invoice.id).label('count'),
|
||||
func.sum(Invoice.net_total).label('total')
|
||||
)
|
||||
.outerjoin(Supplier, Invoice.supplier_id == Supplier.id)
|
||||
.where(and_(*conditions))
|
||||
.group_by(Supplier.name)
|
||||
.order_by(desc('total'))
|
||||
)
|
||||
group_result = await db.execute(group_query)
|
||||
groups = [
|
||||
GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2])
|
||||
for row in group_result.fetchall()
|
||||
]
|
||||
elif group_by == "month":
|
||||
group_query = (
|
||||
select(
|
||||
func.to_char(Invoice.invoice_date, 'YYYY-MM').label('month'),
|
||||
func.count(Invoice.id).label('count'),
|
||||
func.sum(Invoice.net_total).label('total')
|
||||
)
|
||||
.where(and_(*conditions))
|
||||
.group_by(func.to_char(Invoice.invoice_date, 'YYYY-MM'))
|
||||
.order_by(desc('month'))
|
||||
)
|
||||
group_result = await db.execute(group_query)
|
||||
groups = [
|
||||
GroupSummary(name=row[0] or "Unknown", count=row[1], total=row[2])
|
||||
for row in group_result.fetchall()
|
||||
]
|
||||
|
||||
return InvoiceSearchResponse(
|
||||
items=items,
|
||||
total_count=total_count,
|
||||
grouped_by=group_by,
|
||||
groups=groups
|
||||
)
|
||||
|
||||
|
||||
# ============ Line Items Search (Consolidated) ============
|
||||
|
||||
@router.get("/line-items", response_model=LineItemSearchResponse)
|
||||
async def search_line_items(
|
||||
q: str = "",
|
||||
supplier_id: Optional[int] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
group_by: Optional[str] = None,
|
||||
mapped: Optional[str] = Query(default=None, description="Filter by ingredient mapping: 'yes', 'no'"),
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search line items with consolidation.
|
||||
|
||||
Returns DISTINCT line items by (product_code OR description + supplier),
|
||||
with most recent price, price change status, total quantity, occurrence count.
|
||||
"""
|
||||
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
||||
|
||||
items_data, total_count = await price_service.get_consolidated_line_items(
|
||||
search_query=q if q else None,
|
||||
supplier_id=supplier_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
items = [LineItemSearchItem(**item) for item in items_data]
|
||||
|
||||
# Filter by ingredient mapping status
|
||||
if mapped == 'yes':
|
||||
items = [i for i in items if i.ingredient_id is not None]
|
||||
total_count = len(items)
|
||||
elif mapped == 'no':
|
||||
items = [i for i in items if i.ingredient_id is None]
|
||||
total_count = len(items)
|
||||
|
||||
# Handle grouping (for UI display)
|
||||
groups = None
|
||||
if group_by == "supplier":
|
||||
# Group items by supplier
|
||||
supplier_groups = {}
|
||||
for item in items:
|
||||
name = item.supplier_name or "Unknown"
|
||||
if name not in supplier_groups:
|
||||
supplier_groups[name] = {"count": 0, "total": Decimal(0)}
|
||||
supplier_groups[name]["count"] += item.occurrence_count
|
||||
if item.total_quantity:
|
||||
supplier_groups[name]["total"] += item.total_quantity
|
||||
|
||||
groups = [
|
||||
GroupSummary(name=name, count=data["count"], total=data["total"])
|
||||
for name, data in sorted(supplier_groups.items(), key=lambda x: -x[1]["count"])
|
||||
]
|
||||
|
||||
return LineItemSearchResponse(
|
||||
items=items,
|
||||
total_count=total_count,
|
||||
grouped_by=group_by,
|
||||
groups=groups
|
||||
)
|
||||
|
||||
|
||||
# ============ Definitions Search ============
|
||||
|
||||
@router.get("/definitions", response_model=DefinitionSearchResponse)
|
||||
async def search_definitions(
|
||||
q: str = "",
|
||||
supplier_id: Optional[int] = None,
|
||||
has_portions: Optional[bool] = None,
|
||||
limit: int = Query(default=100, le=500),
|
||||
offset: int = 0,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search product definitions.
|
||||
|
||||
- q: Search term (product_code, description_pattern)
|
||||
- supplier_id: Filter by supplier
|
||||
- has_portions: Filter by whether portions_per_unit is set
|
||||
"""
|
||||
conditions = [ProductDefinition.kitchen_id == current_user.kitchen_id]
|
||||
|
||||
if supplier_id:
|
||||
conditions.append(ProductDefinition.supplier_id == supplier_id)
|
||||
|
||||
if q:
|
||||
search_pattern = f"%{q}%"
|
||||
conditions.append(or_(
|
||||
ProductDefinition.product_code.ilike(search_pattern),
|
||||
ProductDefinition.description_pattern.ilike(search_pattern)
|
||||
))
|
||||
|
||||
if has_portions is not None:
|
||||
if has_portions:
|
||||
conditions.append(ProductDefinition.portions_per_unit.isnot(None))
|
||||
else:
|
||||
conditions.append(ProductDefinition.portions_per_unit.is_(None))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count(ProductDefinition.id)).where(and_(*conditions))
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Get definitions with supplier name and source invoice number
|
||||
query = (
|
||||
select(
|
||||
ProductDefinition,
|
||||
Supplier.name.label('supplier_name'),
|
||||
Invoice.invoice_number.label('source_invoice_number')
|
||||
)
|
||||
.outerjoin(Supplier, ProductDefinition.supplier_id == Supplier.id)
|
||||
.outerjoin(Invoice, ProductDefinition.source_invoice_id == Invoice.id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(ProductDefinition.updated_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
rows = result.fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
definition = row.ProductDefinition
|
||||
|
||||
# Get most recent price from matching line items
|
||||
most_recent_price = None
|
||||
price_conditions = [Invoice.kitchen_id == current_user.kitchen_id]
|
||||
|
||||
if definition.supplier_id:
|
||||
price_conditions.append(Invoice.supplier_id == definition.supplier_id)
|
||||
|
||||
if definition.product_code:
|
||||
price_conditions.append(LineItem.product_code == definition.product_code)
|
||||
elif definition.description_pattern:
|
||||
price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%"))
|
||||
|
||||
if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id
|
||||
price_query = (
|
||||
select(LineItem.unit_price)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.where(and_(*price_conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(1)
|
||||
)
|
||||
price_result = await db.execute(price_query)
|
||||
price_row = price_result.scalar_one_or_none()
|
||||
if price_row is not None:
|
||||
most_recent_price = price_row
|
||||
|
||||
items.append(DefinitionSearchItem(
|
||||
id=definition.id,
|
||||
product_code=definition.product_code,
|
||||
description_pattern=definition.description_pattern,
|
||||
supplier_id=definition.supplier_id,
|
||||
supplier_name=row.supplier_name,
|
||||
pack_quantity=definition.pack_quantity,
|
||||
unit_size=definition.unit_size,
|
||||
unit_size_type=definition.unit_size_type,
|
||||
portions_per_unit=definition.portions_per_unit,
|
||||
portion_description=definition.portion_description,
|
||||
source_invoice_id=definition.source_invoice_id,
|
||||
source_invoice_number=row.source_invoice_number,
|
||||
most_recent_price=most_recent_price,
|
||||
updated_at=definition.updated_at
|
||||
))
|
||||
|
||||
return DefinitionSearchResponse(items=items, total_count=total_count)
|
||||
|
||||
|
||||
# ============ Line Item History ============
|
||||
|
||||
@router.get("/line-items/history", response_model=LineItemHistoryResponse)
|
||||
async def get_line_item_history(
|
||||
supplier_id: int,
|
||||
product_code: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
unit: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get price and quantity history for a specific line item.
|
||||
|
||||
Used by the history modal to show price chart and stats.
|
||||
"""
|
||||
if not product_code and not description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either product_code or description is required"
|
||||
)
|
||||
|
||||
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
||||
history = await price_service.get_history(
|
||||
supplier_id=supplier_id,
|
||||
product_code=product_code,
|
||||
description=description,
|
||||
unit=unit,
|
||||
date_from=date_from,
|
||||
date_to=date_to
|
||||
)
|
||||
|
||||
return LineItemHistoryResponse(
|
||||
product_code=history.product_code,
|
||||
description=history.description,
|
||||
supplier_id=history.supplier_id,
|
||||
supplier_name=history.supplier_name,
|
||||
price_history=[
|
||||
PriceHistoryPointResponse(
|
||||
date=point.date,
|
||||
price=point.price,
|
||||
invoice_id=point.invoice_id,
|
||||
invoice_number=point.invoice_number,
|
||||
quantity=point.quantity
|
||||
)
|
||||
for point in history.price_history
|
||||
],
|
||||
total_occurrences=history.total_occurrences,
|
||||
total_quantity=history.total_quantity,
|
||||
avg_qty_per_invoice=history.avg_qty_per_invoice,
|
||||
avg_qty_per_week=history.avg_qty_per_week,
|
||||
avg_qty_per_month=history.avg_qty_per_month,
|
||||
current_price=history.current_price,
|
||||
price_change_status=history.price_change_status
|
||||
)
|
||||
|
||||
|
||||
# ============ Price Acknowledgement ============
|
||||
|
||||
@router.post("/line-items/acknowledge-price", response_model=AcknowledgePriceResponse)
|
||||
async def acknowledge_price_change(
|
||||
request: AcknowledgePriceRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Acknowledge a price change for a line item.
|
||||
|
||||
Creates or updates the AcknowledgedPrice record so the price
|
||||
won't be flagged as changed in future.
|
||||
"""
|
||||
if not request.product_code and not request.description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either product_code or description is required"
|
||||
)
|
||||
|
||||
price_service = PriceHistoryService(db, current_user.kitchen_id)
|
||||
acknowledged = await price_service.acknowledge_price(
|
||||
user_id=current_user.id,
|
||||
supplier_id=request.supplier_id,
|
||||
product_code=request.product_code,
|
||||
description=request.description,
|
||||
new_price=request.new_price,
|
||||
source_invoice_id=request.source_invoice_id,
|
||||
source_line_item_id=request.source_line_item_id
|
||||
)
|
||||
|
||||
return AcknowledgePriceResponse(
|
||||
id=acknowledged.id,
|
||||
acknowledged_price=acknowledged.acknowledged_price,
|
||||
acknowledged_at=acknowledged.acknowledged_at
|
||||
)
|
||||
|
||||
|
||||
# ============ Search Settings ============
|
||||
|
||||
class SearchSettingsResponse(BaseModel):
|
||||
price_change_lookback_days: int
|
||||
price_change_amber_threshold: int
|
||||
price_change_red_threshold: int
|
||||
|
||||
|
||||
class SearchSettingsUpdate(BaseModel):
|
||||
price_change_lookback_days: Optional[int] = None
|
||||
price_change_amber_threshold: Optional[int] = None
|
||||
price_change_red_threshold: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SearchSettingsResponse)
|
||||
async def get_search_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get search/price change settings."""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
return SearchSettingsResponse(
|
||||
price_change_lookback_days=settings.price_change_lookback_days if settings else 30,
|
||||
price_change_amber_threshold=settings.price_change_amber_threshold if settings else 10,
|
||||
price_change_red_threshold=settings.price_change_red_threshold if settings else 20
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SearchSettingsResponse)
|
||||
async def update_search_settings(
|
||||
update: SearchSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update search/price change settings."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(
|
||||
KitchenSettings.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return SearchSettingsResponse(
|
||||
price_change_lookback_days=settings.price_change_lookback_days,
|
||||
price_change_amber_threshold=settings.price_change_amber_threshold,
|
||||
price_change_red_threshold=settings.price_change_red_threshold
|
||||
)
|
||||
|
||||
|
||||
# ============ Definition Update ============
|
||||
|
||||
class DefinitionUpdateRequest(BaseModel):
|
||||
pack_quantity: Optional[int] = None
|
||||
unit_size: Optional[Decimal] = None
|
||||
unit_size_type: Optional[str] = None
|
||||
portions_per_unit: Optional[int] = None
|
||||
portion_description: Optional[str] = None
|
||||
|
||||
|
||||
@router.patch("/definitions/{definition_id}", response_model=DefinitionSearchItem)
|
||||
async def update_definition(
|
||||
definition_id: int,
|
||||
update: DefinitionUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a product definition."""
|
||||
# Fetch the definition
|
||||
result = await db.execute(
|
||||
select(ProductDefinition).where(
|
||||
ProductDefinition.id == definition_id,
|
||||
ProductDefinition.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
definition = result.scalar_one_or_none()
|
||||
|
||||
if not definition:
|
||||
raise HTTPException(status_code=404, detail="Definition not found")
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(definition, field, value)
|
||||
|
||||
# Update saved_by metadata
|
||||
definition.saved_by_user_id = current_user.id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(definition)
|
||||
|
||||
# Get supplier name for response
|
||||
supplier_name = None
|
||||
if definition.supplier_id:
|
||||
supplier_result = await db.execute(
|
||||
select(Supplier.name).where(Supplier.id == definition.supplier_id)
|
||||
)
|
||||
supplier_name = supplier_result.scalar_one_or_none()
|
||||
|
||||
# Get source invoice number
|
||||
source_invoice_number = None
|
||||
if definition.source_invoice_id:
|
||||
invoice_result = await db.execute(
|
||||
select(Invoice.invoice_number).where(Invoice.id == definition.source_invoice_id)
|
||||
)
|
||||
source_invoice_number = invoice_result.scalar_one_or_none()
|
||||
|
||||
# Get most recent price
|
||||
most_recent_price = None
|
||||
price_conditions = [Invoice.kitchen_id == current_user.kitchen_id]
|
||||
|
||||
if definition.supplier_id:
|
||||
price_conditions.append(Invoice.supplier_id == definition.supplier_id)
|
||||
|
||||
if definition.product_code:
|
||||
price_conditions.append(LineItem.product_code == definition.product_code)
|
||||
elif definition.description_pattern:
|
||||
price_conditions.append(LineItem.description.ilike(f"%{definition.description_pattern}%"))
|
||||
|
||||
if len(price_conditions) > 1: # Has at least one matching condition beyond kitchen_id
|
||||
price_query = (
|
||||
select(LineItem.unit_price)
|
||||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||||
.where(and_(*price_conditions))
|
||||
.order_by(desc(Invoice.invoice_date))
|
||||
.limit(1)
|
||||
)
|
||||
price_result = await db.execute(price_query)
|
||||
price_row = price_result.scalar_one_or_none()
|
||||
if price_row is not None:
|
||||
most_recent_price = price_row
|
||||
|
||||
return DefinitionSearchItem(
|
||||
id=definition.id,
|
||||
product_code=definition.product_code,
|
||||
description_pattern=definition.description_pattern,
|
||||
supplier_id=definition.supplier_id,
|
||||
supplier_name=supplier_name,
|
||||
pack_quantity=definition.pack_quantity,
|
||||
unit_size=definition.unit_size,
|
||||
unit_size_type=definition.unit_size_type,
|
||||
portions_per_unit=definition.portions_per_unit,
|
||||
portion_description=definition.portion_description,
|
||||
source_invoice_id=definition.source_invoice_id,
|
||||
source_invoice_number=source_invoice_number,
|
||||
most_recent_price=most_recent_price,
|
||||
updated_at=definition.updated_at
|
||||
)
|
||||
|
|
@ -1,798 +0,0 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.settings import KitchenSettings
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
azure_endpoint: str | None
|
||||
azure_key_set: bool # Don't expose the actual key, just whether it's set
|
||||
currency_symbol: str
|
||||
date_format: str
|
||||
high_quantity_threshold: int
|
||||
# SMTP settings
|
||||
smtp_host: str | None
|
||||
smtp_port: int | None
|
||||
smtp_username: str | None
|
||||
smtp_password_set: bool # Don't expose the actual password
|
||||
smtp_use_tls: bool
|
||||
smtp_from_email: str | None
|
||||
smtp_from_name: str | None
|
||||
support_email: str | None
|
||||
# Dext integration
|
||||
dext_email: str | None
|
||||
dext_include_notes: bool
|
||||
dext_include_non_stock: bool
|
||||
dext_auto_send_enabled: bool
|
||||
dext_manual_send_enabled: bool
|
||||
dext_include_annotations: bool
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled: bool
|
||||
pdf_preview_show_annotations: bool
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes: bool
|
||||
ocr_filter_subtotal_rows: bool
|
||||
ocr_use_weight_as_quantity: bool
|
||||
# Cost distribution settings
|
||||
cost_distribution_max_days: int
|
||||
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled: bool = False
|
||||
anthropic_api_key_set: bool = False # Don't expose the actual key
|
||||
llm_model: str | None = None
|
||||
llm_confidence_threshold: float | None = None
|
||||
llm_monthly_token_limit: int = 500000
|
||||
llm_features_enabled: dict | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
azure_endpoint: str | None = None
|
||||
azure_key: str | None = None
|
||||
currency_symbol: str | None = None
|
||||
date_format: str | None = None
|
||||
high_quantity_threshold: int | None = None
|
||||
# SMTP settings
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None # Only set if provided
|
||||
smtp_use_tls: bool | None = None
|
||||
smtp_from_email: str | None = None
|
||||
smtp_from_name: str | None = None
|
||||
support_email: str | None = None
|
||||
# Dext integration
|
||||
dext_email: str | None = None
|
||||
dext_include_notes: bool | None = None
|
||||
dext_include_non_stock: bool | None = None
|
||||
dext_auto_send_enabled: bool | None = None
|
||||
dext_manual_send_enabled: bool | None = None
|
||||
dext_include_annotations: bool | None = None
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled: bool | None = None
|
||||
pdf_preview_show_annotations: bool | None = None
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes: bool | None = None
|
||||
ocr_filter_subtotal_rows: bool | None = None
|
||||
ocr_use_weight_as_quantity: bool | None = None
|
||||
# Cost distribution settings
|
||||
cost_distribution_max_days: int | None = None
|
||||
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled: bool | None = None
|
||||
anthropic_api_key: str | None = None # Only set if provided
|
||||
llm_model: str | None = None
|
||||
llm_confidence_threshold: float | None = None
|
||||
llm_monthly_token_limit: int | None = None
|
||||
llm_features_enabled: dict | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=SettingsResponse)
|
||||
async def get_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current kitchen settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
# Create default settings if none exist
|
||||
settings = KitchenSettings(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
currency_symbol="£",
|
||||
date_format="DD/MM/YYYY"
|
||||
)
|
||||
db.add(settings)
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return _build_settings_response(settings)
|
||||
|
||||
|
||||
def _build_settings_response(settings: KitchenSettings) -> SettingsResponse:
|
||||
"""Build SettingsResponse from a KitchenSettings model instance."""
|
||||
return SettingsResponse(
|
||||
azure_endpoint=settings.azure_endpoint,
|
||||
azure_key_set=bool(settings.azure_key),
|
||||
currency_symbol=settings.currency_symbol,
|
||||
date_format=settings.date_format,
|
||||
high_quantity_threshold=settings.high_quantity_threshold,
|
||||
# SMTP settings
|
||||
smtp_host=settings.smtp_host,
|
||||
smtp_port=settings.smtp_port,
|
||||
smtp_username=settings.smtp_username,
|
||||
smtp_password_set=bool(settings.smtp_password),
|
||||
smtp_use_tls=settings.smtp_use_tls,
|
||||
smtp_from_email=settings.smtp_from_email,
|
||||
smtp_from_name=settings.smtp_from_name,
|
||||
support_email=settings.support_email,
|
||||
# Dext integration
|
||||
dext_email=settings.dext_email,
|
||||
dext_include_notes=settings.dext_include_notes,
|
||||
dext_include_non_stock=settings.dext_include_non_stock,
|
||||
dext_auto_send_enabled=settings.dext_auto_send_enabled,
|
||||
dext_manual_send_enabled=settings.dext_manual_send_enabled,
|
||||
dext_include_annotations=settings.dext_include_annotations,
|
||||
# PDF annotation settings
|
||||
pdf_annotations_enabled=settings.pdf_annotations_enabled,
|
||||
pdf_preview_show_annotations=settings.pdf_preview_show_annotations,
|
||||
# OCR post-processing options
|
||||
ocr_clean_product_codes=settings.ocr_clean_product_codes,
|
||||
ocr_filter_subtotal_rows=settings.ocr_filter_subtotal_rows,
|
||||
ocr_use_weight_as_quantity=settings.ocr_use_weight_as_quantity,
|
||||
cost_distribution_max_days=settings.cost_distribution_max_days,
|
||||
# LLM settings — see LLM-MANIFEST.md for removal instructions
|
||||
llm_enabled=settings.llm_enabled,
|
||||
anthropic_api_key_set=bool(settings.anthropic_api_key),
|
||||
llm_model=settings.llm_model,
|
||||
llm_confidence_threshold=float(settings.llm_confidence_threshold) if settings.llm_confidence_threshold else None,
|
||||
llm_monthly_token_limit=settings.llm_monthly_token_limit,
|
||||
llm_features_enabled=settings.llm_features_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/", response_model=SettingsResponse)
|
||||
async def update_settings(
|
||||
update: SettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update kitchen settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
# Update fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return _build_settings_response(settings)
|
||||
|
||||
|
||||
@router.post("/test-azure")
|
||||
async def test_azure_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Azure Document Intelligence connection"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.azure_endpoint or not settings.azure_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Azure credentials not configured"
|
||||
)
|
||||
|
||||
try:
|
||||
from azure.ai.formrecognizer import DocumentAnalysisClient
|
||||
from azure.core.credentials import AzureKeyCredential
|
||||
|
||||
client = DocumentAnalysisClient(
|
||||
endpoint=settings.azure_endpoint,
|
||||
credential=AzureKeyCredential(settings.azure_key)
|
||||
)
|
||||
# Simple connection test - this will validate credentials
|
||||
# The actual analysis would happen during invoice processing
|
||||
return {"status": "success", "message": "Azure connection successful"}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Azure connection failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-smtp")
|
||||
async def test_smtp_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test SMTP connection with current settings"""
|
||||
from services.email_service import EmailService
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.smtp_host or not settings.smtp_from_email:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="SMTP not fully configured. Please set SMTP host and from email."
|
||||
)
|
||||
|
||||
email_service = EmailService(settings)
|
||||
success, message = email_service.test_connection()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"status": "success", "message": message}
|
||||
|
||||
|
||||
# ============ Kitchen Details Endpoints ============
|
||||
|
||||
class KitchenDetailsResponse(BaseModel):
|
||||
kitchen_display_name: str | None = None
|
||||
kitchen_address_line1: str | None = None
|
||||
kitchen_address_line2: str | None = None
|
||||
kitchen_city: str | None = None
|
||||
kitchen_postcode: str | None = None
|
||||
kitchen_phone: str | None = None
|
||||
kitchen_email: str | None = None
|
||||
|
||||
|
||||
class KitchenDetailsUpdate(BaseModel):
|
||||
kitchen_display_name: str | None = None
|
||||
kitchen_address_line1: str | None = None
|
||||
kitchen_address_line2: str | None = None
|
||||
kitchen_city: str | None = None
|
||||
kitchen_postcode: str | None = None
|
||||
kitchen_phone: str | None = None
|
||||
kitchen_email: str | None = None
|
||||
|
||||
|
||||
@router.get("/kitchen-details", response_model=KitchenDetailsResponse)
|
||||
async def get_kitchen_details(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get kitchen details for PO letterhead"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return KitchenDetailsResponse()
|
||||
|
||||
return KitchenDetailsResponse(
|
||||
kitchen_display_name=settings.kitchen_display_name,
|
||||
kitchen_address_line1=settings.kitchen_address_line1,
|
||||
kitchen_address_line2=settings.kitchen_address_line2,
|
||||
kitchen_city=settings.kitchen_city,
|
||||
kitchen_postcode=settings.kitchen_postcode,
|
||||
kitchen_phone=settings.kitchen_phone,
|
||||
kitchen_email=settings.kitchen_email,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/kitchen-details", response_model=KitchenDetailsResponse)
|
||||
async def update_kitchen_details(
|
||||
update: KitchenDetailsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update kitchen details for PO letterhead"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return KitchenDetailsResponse(
|
||||
kitchen_display_name=settings.kitchen_display_name,
|
||||
kitchen_address_line1=settings.kitchen_address_line1,
|
||||
kitchen_address_line2=settings.kitchen_address_line2,
|
||||
kitchen_city=settings.kitchen_city,
|
||||
kitchen_postcode=settings.kitchen_postcode,
|
||||
kitchen_phone=settings.kitchen_phone,
|
||||
kitchen_email=settings.kitchen_email,
|
||||
)
|
||||
|
||||
|
||||
# ============ Page Restrictions Endpoints ============
|
||||
|
||||
class PageRestrictionsResponse(BaseModel):
|
||||
restricted_pages: list[str]
|
||||
|
||||
|
||||
class PageRestrictionsUpdate(BaseModel):
|
||||
restricted_pages: list[str]
|
||||
|
||||
|
||||
@router.get("/page-restrictions", response_model=PageRestrictionsResponse)
|
||||
async def get_page_restrictions(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get list of pages restricted to admin users only"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return PageRestrictionsResponse(restricted_pages=[])
|
||||
|
||||
# Parse comma-separated list
|
||||
restricted = []
|
||||
if settings.admin_restricted_pages:
|
||||
restricted = [p.strip() for p in settings.admin_restricted_pages.split(',') if p.strip()]
|
||||
|
||||
return PageRestrictionsResponse(restricted_pages=restricted)
|
||||
|
||||
|
||||
@router.patch("/page-restrictions", response_model=PageRestrictionsResponse)
|
||||
async def update_page_restrictions(
|
||||
update: PageRestrictionsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update list of pages restricted to admin users only (admin only)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only admins can modify page restrictions"
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
# Store as comma-separated string
|
||||
settings.admin_restricted_pages = ','.join(update.restricted_pages) if update.restricted_pages else None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return PageRestrictionsResponse(restricted_pages=update.restricted_pages)
|
||||
|
||||
|
||||
# ============ Nextcloud Settings Endpoints ============
|
||||
|
||||
class NextcloudSettingsResponse(BaseModel):
|
||||
nextcloud_host: str | None
|
||||
nextcloud_username: str | None
|
||||
nextcloud_password_set: bool
|
||||
nextcloud_base_path: str | None
|
||||
nextcloud_enabled: bool
|
||||
nextcloud_delete_local: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NextcloudSettingsUpdate(BaseModel):
|
||||
nextcloud_host: str | None = None
|
||||
nextcloud_username: str | None = None
|
||||
nextcloud_password: str | None = None
|
||||
nextcloud_base_path: str | None = None
|
||||
nextcloud_enabled: bool | None = None
|
||||
nextcloud_delete_local: bool | None = None
|
||||
|
||||
|
||||
class NextcloudStatsResponse(BaseModel):
|
||||
pending_count: int
|
||||
archived_count: int
|
||||
local_count: int
|
||||
nextcloud_enabled: bool
|
||||
nextcloud_configured: bool
|
||||
|
||||
|
||||
class NextcloudArchiveResponse(BaseModel):
|
||||
success_count: int
|
||||
failed_count: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("/nextcloud", response_model=NextcloudSettingsResponse)
|
||||
async def get_nextcloud_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get Nextcloud settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return NextcloudSettingsResponse(
|
||||
nextcloud_host=None,
|
||||
nextcloud_username=None,
|
||||
nextcloud_password_set=False,
|
||||
nextcloud_base_path="/Kitchen Invoices",
|
||||
nextcloud_enabled=False,
|
||||
nextcloud_delete_local=False
|
||||
)
|
||||
|
||||
return NextcloudSettingsResponse(
|
||||
nextcloud_host=settings.nextcloud_host,
|
||||
nextcloud_username=settings.nextcloud_username,
|
||||
nextcloud_password_set=bool(settings.nextcloud_password),
|
||||
nextcloud_base_path=settings.nextcloud_base_path,
|
||||
nextcloud_enabled=settings.nextcloud_enabled,
|
||||
nextcloud_delete_local=settings.nextcloud_delete_local
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/nextcloud", response_model=NextcloudSettingsResponse)
|
||||
async def update_nextcloud_settings(
|
||||
update: NextcloudSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update Nextcloud settings"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
# Map 'nextcloud_password' to the model field
|
||||
if field == 'nextcloud_password' and value:
|
||||
setattr(settings, field, value)
|
||||
elif value is not None:
|
||||
setattr(settings, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return NextcloudSettingsResponse(
|
||||
nextcloud_host=settings.nextcloud_host,
|
||||
nextcloud_username=settings.nextcloud_username,
|
||||
nextcloud_password_set=bool(settings.nextcloud_password),
|
||||
nextcloud_base_path=settings.nextcloud_base_path,
|
||||
nextcloud_enabled=settings.nextcloud_enabled,
|
||||
nextcloud_delete_local=settings.nextcloud_delete_local
|
||||
)
|
||||
|
||||
|
||||
@router.post("/nextcloud/test")
|
||||
async def test_nextcloud_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Nextcloud WebDAV connection"""
|
||||
from services.nextcloud_service import NextcloudService
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
|
||||
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
||||
|
||||
nc = NextcloudService(
|
||||
settings.nextcloud_host,
|
||||
settings.nextcloud_username,
|
||||
settings.nextcloud_password,
|
||||
settings.nextcloud_base_path
|
||||
)
|
||||
|
||||
success, message = await nc.test_connection()
|
||||
await nc.close()
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {"status": "success", "message": message}
|
||||
|
||||
|
||||
@router.get("/nextcloud/stats", response_model=NextcloudStatsResponse)
|
||||
async def get_nextcloud_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get Nextcloud archive statistics"""
|
||||
from services.file_archival_service import FileArchivalService
|
||||
|
||||
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
||||
stats = await archival_service.get_archive_stats()
|
||||
|
||||
return NextcloudStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.post("/nextcloud/archive-all", response_model=NextcloudArchiveResponse)
|
||||
async def archive_all_pending(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Manually archive all pending invoices to Nextcloud"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
from services.file_archival_service import FileArchivalService
|
||||
|
||||
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
||||
success_count, failed_count, errors = await archival_service.archive_all_pending()
|
||||
|
||||
return NextcloudArchiveResponse(
|
||||
success_count=success_count,
|
||||
failed_count=failed_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.post("/nextcloud/archive/{invoice_id}")
|
||||
async def archive_single_invoice(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Archive a single invoice to Nextcloud (for testing)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
from models.invoice import Invoice
|
||||
from services.file_archival_service import FileArchivalService
|
||||
|
||||
# Get the invoice
|
||||
result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
invoice = result.scalar_one_or_none()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||||
|
||||
if invoice.file_storage_location == "nextcloud":
|
||||
return {"status": "skipped", "message": "Invoice already archived to Nextcloud"}
|
||||
|
||||
# Check Nextcloud config
|
||||
settings_result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings_result.scalar_one_or_none()
|
||||
|
||||
if not settings or not all([settings.nextcloud_host, settings.nextcloud_username, settings.nextcloud_password]):
|
||||
raise HTTPException(status_code=400, detail="Nextcloud not fully configured")
|
||||
|
||||
archival_service = FileArchivalService(db, current_user.kitchen_id)
|
||||
success, message = await archival_service.archive_invoice_file(invoice)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
return {"status": "success", "message": message, "nextcloud_path": invoice.nextcloud_path}
|
||||
|
||||
|
||||
# ============ API Access Endpoints ============
|
||||
|
||||
class ApiAccessResponse(BaseModel):
|
||||
api_key: str | None
|
||||
api_key_enabled: bool
|
||||
|
||||
|
||||
class ApiAccessUpdate(BaseModel):
|
||||
api_key_enabled: bool
|
||||
|
||||
|
||||
@router.get("/api-access", response_model=ApiAccessResponse)
|
||||
async def get_api_access(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get API access settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
return ApiAccessResponse(api_key=None, api_key_enabled=False)
|
||||
|
||||
return ApiAccessResponse(
|
||||
api_key=settings.api_key,
|
||||
api_key_enabled=settings.api_key_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/api-access", response_model=ApiAccessResponse)
|
||||
async def update_api_access(
|
||||
update: ApiAccessUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update API access settings (enable/disable)"""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
settings.api_key_enabled = update.api_key_enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return ApiAccessResponse(
|
||||
api_key=settings.api_key,
|
||||
api_key_enabled=settings.api_key_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api-access/regenerate")
|
||||
async def regenerate_api_key(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Generate or regenerate the API key"""
|
||||
import secrets
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin only")
|
||||
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
settings = KitchenSettings(kitchen_id=current_user.kitchen_id)
|
||||
db.add(settings)
|
||||
|
||||
new_key = secrets.token_urlsafe(32)
|
||||
settings.api_key = new_key
|
||||
settings.api_key_enabled = True
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
|
||||
return {"api_key": new_key, "api_key_enabled": True}
|
||||
|
||||
|
||||
# ============ LLM Usage Stats Endpoints ============
|
||||
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
||||
|
||||
|
||||
class LlmUsageStatsResponse(BaseModel):
|
||||
total_calls: int = 0
|
||||
successful_calls: int = 0
|
||||
failed_calls: int = 0
|
||||
total_input_tokens: int = 0
|
||||
total_output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
estimated_cost_usd: float = 0.0
|
||||
cache_entries_this_month: int = 0
|
||||
|
||||
|
||||
@router.get("/llm-usage", response_model=LlmUsageStatsResponse)
|
||||
async def get_llm_usage(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get aggregated LLM usage stats for the current month"""
|
||||
from services.llm_service import get_usage_stats
|
||||
|
||||
stats = await get_usage_stats(db, current_user.kitchen_id)
|
||||
return LlmUsageStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.post("/test-llm")
|
||||
async def test_llm_connection(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Test Anthropic API connection with current settings"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.anthropic_api_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Anthropic API key not configured"
|
||||
)
|
||||
|
||||
if not settings.llm_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="LLM features are disabled. Enable them in Settings first."
|
||||
)
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
from services.llm_service import DEFAULT_LLM_MODEL
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model=settings.llm_model or DEFAULT_LLM_MODEL,
|
||||
max_tokens=10,
|
||||
messages=[{"role": "user", "content": "Say 'ok'"}],
|
||||
)
|
||||
return {"status": "success", "message": f"Connection successful. Model: {response.model}"}
|
||||
except anthropic.AuthenticationError:
|
||||
raise HTTPException(status_code=400, detail="Authentication failed — check your API key")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Connection failed: {str(e)}")
|
||||
|
||||
|
||||
# LLM FEATURE — see LLM-MANIFEST.md for removal instructions
|
||||
@router.get("/llm-models")
|
||||
async def get_llm_models(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fetch available models from Anthropic API."""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.anthropic_api_key:
|
||||
return {"models": [], "default": None, "error": "No API key configured"}
|
||||
|
||||
from services.llm_service import list_available_models, DEFAULT_LLM_MODEL
|
||||
models = await list_available_models(settings.anthropic_api_key)
|
||||
|
||||
return {
|
||||
"models": models,
|
||||
"default": DEFAULT_LLM_MODEL,
|
||||
"current": settings.llm_model or DEFAULT_LLM_MODEL,
|
||||
}
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db, AsyncSessionLocal
|
||||
from models.user import User
|
||||
from models.supplier import Supplier
|
||||
from models.invoice import Invoice
|
||||
from auth import get_current_user, require_cap
|
||||
from ocr.parser import identify_supplier
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def rematch_unmatched_invoices(kitchen_id: int):
|
||||
"""
|
||||
Re-run supplier matching for all invoices without a supplier.
|
||||
Called after supplier create/update to match previously unmatched invoices.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get all invoices without a supplier that have vendor_name from OCR
|
||||
result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.kitchen_id == kitchen_id,
|
||||
Invoice.supplier_id == None,
|
||||
Invoice.vendor_name != None
|
||||
)
|
||||
)
|
||||
invoices = result.scalars().all()
|
||||
|
||||
for invoice in invoices:
|
||||
if invoice.vendor_name:
|
||||
supplier_id, match_type = await identify_supplier(
|
||||
invoice.vendor_name, kitchen_id, db
|
||||
)
|
||||
if supplier_id:
|
||||
invoice.supplier_id = supplier_id
|
||||
invoice.supplier_match_type = match_type
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
class SupplierCreate(BaseModel):
|
||||
name: str
|
||||
aliases: list[str] = []
|
||||
template_config: dict = {}
|
||||
identifier_config: dict = {}
|
||||
skip_dext: bool = False
|
||||
order_email: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
|
||||
|
||||
class SupplierUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
aliases: Optional[list[str]] = None
|
||||
template_config: Optional[dict] = None
|
||||
identifier_config: Optional[dict] = None
|
||||
skip_dext: Optional[bool] = None
|
||||
order_email: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
|
||||
|
||||
class SupplierResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
aliases: list[str]
|
||||
template_config: dict
|
||||
identifier_config: dict
|
||||
skip_dext: bool
|
||||
order_email: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/", response_model=SupplierResponse)
|
||||
async def create_supplier(
|
||||
request: SupplierCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new supplier with extraction templates"""
|
||||
supplier = Supplier(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
name=request.name,
|
||||
aliases=request.aliases,
|
||||
template_config=request.template_config,
|
||||
identifier_config=request.identifier_config,
|
||||
skip_dext=request.skip_dext,
|
||||
order_email=request.order_email,
|
||||
account_number=request.account_number,
|
||||
)
|
||||
db.add(supplier)
|
||||
await db.commit()
|
||||
await db.refresh(supplier)
|
||||
|
||||
# Rematch unmatched invoices in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SupplierResponse])
|
||||
async def list_suppliers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""List all suppliers for the current kitchen"""
|
||||
result = await db.execute(
|
||||
select(Supplier)
|
||||
.where(Supplier.kitchen_id == current_user.kitchen_id)
|
||||
.order_by(Supplier.name)
|
||||
)
|
||||
suppliers = result.scalars().all()
|
||||
|
||||
return [
|
||||
SupplierResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
aliases=s.aliases or [],
|
||||
template_config=s.template_config,
|
||||
identifier_config=s.identifier_config,
|
||||
skip_dext=s.skip_dext,
|
||||
order_email=s.order_email,
|
||||
account_number=s.account_number,
|
||||
created_at=s.created_at.isoformat()
|
||||
)
|
||||
for s in suppliers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{supplier_id}", response_model=SupplierResponse)
|
||||
async def get_supplier(
|
||||
supplier_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get a supplier by ID"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{supplier_id}", response_model=SupplierResponse)
|
||||
async def update_supplier(
|
||||
supplier_id: int,
|
||||
update: SupplierUpdate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a supplier's template configuration"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(supplier, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(supplier)
|
||||
|
||||
# Rematch unmatched invoices in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
async def delete_supplier(
|
||||
supplier_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete a supplier"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
await db.delete(supplier)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Supplier deleted"}
|
||||
|
||||
|
||||
class AddAliasRequest(BaseModel):
|
||||
alias: str
|
||||
invoice_id: Optional[int] = None # If provided, update this invoice's match type to 'exact'
|
||||
|
||||
|
||||
@router.post("/{supplier_id}/aliases", response_model=SupplierResponse)
|
||||
async def add_supplier_alias(
|
||||
supplier_id: int,
|
||||
request: AddAliasRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Add an alias to a supplier for better matching"""
|
||||
result = await db.execute(
|
||||
select(Supplier).where(
|
||||
Supplier.id == supplier_id,
|
||||
Supplier.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
supplier = result.scalar_one_or_none()
|
||||
|
||||
if not supplier:
|
||||
raise HTTPException(status_code=404, detail="Supplier not found")
|
||||
|
||||
alias = request.alias.strip()
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Alias cannot be empty")
|
||||
|
||||
# Add alias if not already present
|
||||
# Create a new list to ensure SQLAlchemy detects the change (JSON columns don't detect in-place mutations)
|
||||
current_aliases = list(supplier.aliases or [])
|
||||
if alias not in current_aliases:
|
||||
current_aliases.append(alias)
|
||||
supplier.aliases = current_aliases
|
||||
|
||||
# If invoice_id provided, update that invoice's match type to 'exact'
|
||||
if request.invoice_id:
|
||||
inv_result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.id == request.invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id
|
||||
)
|
||||
)
|
||||
invoice = inv_result.scalar_one_or_none()
|
||||
if invoice and invoice.supplier_match_type == 'fuzzy':
|
||||
invoice.supplier_match_type = 'exact'
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(supplier)
|
||||
|
||||
# Rematch unmatched invoices in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return SupplierResponse(
|
||||
id=supplier.id,
|
||||
name=supplier.name,
|
||||
aliases=supplier.aliases or [],
|
||||
template_config=supplier.template_config,
|
||||
identifier_config=supplier.identifier_config,
|
||||
skip_dext=supplier.skip_dext,
|
||||
order_email=supplier.order_email,
|
||||
account_number=supplier.account_number,
|
||||
created_at=supplier.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/rematch-fuzzy")
|
||||
async def rematch_fuzzy_invoices(
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Clear all fuzzy-matched invoices and re-run supplier matching.
|
||||
Use this after updating matching logic to fix incorrect fuzzy matches.
|
||||
"""
|
||||
# Count fuzzy matches before clearing
|
||||
count_result = await db.execute(
|
||||
select(Invoice).where(
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
Invoice.supplier_match_type == "fuzzy"
|
||||
)
|
||||
)
|
||||
fuzzy_invoices = count_result.scalars().all()
|
||||
count = len(fuzzy_invoices)
|
||||
|
||||
# Clear supplier assignment for all fuzzy matches
|
||||
for invoice in fuzzy_invoices:
|
||||
invoice.supplier_id = None
|
||||
invoice.supplier_match_type = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-run matching in background
|
||||
background_tasks.add_task(rematch_unmatched_invoices, current_user.kitchen_id)
|
||||
|
||||
return {"message": f"Cleared {count} fuzzy matches. Re-matching in background."}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
"""
|
||||
Support Request API
|
||||
|
||||
Handles user support requests with page screenshots.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user, require_cap
|
||||
from models.user import User, Kitchen
|
||||
from models.settings import KitchenSettings
|
||||
from services.email_service import EmailService
|
||||
from sqlalchemy import select
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupportRequest(BaseModel):
|
||||
"""Support request payload"""
|
||||
description: str
|
||||
screenshot: str # Base64 encoded PNG
|
||||
page_url: str
|
||||
browser_info: str | None = None
|
||||
|
||||
|
||||
class SupportResponse(BaseModel):
|
||||
"""Support request response"""
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
def generate_support_email_html(
|
||||
user_name: str,
|
||||
user_email: str,
|
||||
kitchen_name: str,
|
||||
description: str,
|
||||
page_url: str,
|
||||
browser_info: str | None,
|
||||
timestamp: datetime
|
||||
) -> str:
|
||||
"""Generate HTML email body for support request"""
|
||||
return f"""
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; color: #333; line-height: 1.6; }}
|
||||
.header {{ background-color: #e94560; color: white; padding: 20px; }}
|
||||
.content {{ padding: 20px; }}
|
||||
.meta {{ background: #f5f5f5; padding: 15px; border-radius: 6px; margin-bottom: 20px; }}
|
||||
.meta p {{ margin: 5px 0; }}
|
||||
.label {{ font-weight: bold; color: #666; }}
|
||||
.description {{ background: #fffbcc; padding: 15px; border-left: 4px solid #ffc107; margin: 20px 0; }}
|
||||
.screenshot-note {{ color: #666; font-style: italic; margin-top: 20px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h2>Support Request</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<p><span class="label">From:</span> {user_name} ({user_email})</p>
|
||||
<p><span class="label">Kitchen:</span> {kitchen_name}</p>
|
||||
<p><span class="label">Page URL:</span> {page_url}</p>
|
||||
<p><span class="label">Timestamp:</span> {timestamp.strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
|
||||
{f'<p><span class="label">Browser:</span> {browser_info}</p>' if browser_info else ''}
|
||||
</div>
|
||||
|
||||
<h3>Issue Description</h3>
|
||||
<div class="description">
|
||||
<p>{description.replace(chr(10), '<br>')}</p>
|
||||
</div>
|
||||
|
||||
<p class="screenshot-note">A screenshot of the page is attached to this email.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@router.post("/support/request", response_model=SupportResponse)
|
||||
async def submit_support_request(
|
||||
request: SupportRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Submit a support request with page screenshot.
|
||||
|
||||
The screenshot is sent as an email attachment to the configured support email.
|
||||
"""
|
||||
# Get kitchen settings
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings:
|
||||
raise HTTPException(status_code=404, detail="Kitchen settings not found")
|
||||
|
||||
# Check if support email is configured
|
||||
if not settings.support_email:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Support email not configured. Please contact your administrator."
|
||||
)
|
||||
|
||||
# Check if SMTP is configured
|
||||
if not settings.smtp_host or not settings.smtp_from_email:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Email settings not configured. Please contact your administrator."
|
||||
)
|
||||
|
||||
# Fetch kitchen name explicitly to avoid lazy loading issues
|
||||
kitchen_result = await db.execute(
|
||||
select(Kitchen).where(Kitchen.id == current_user.kitchen_id)
|
||||
)
|
||||
kitchen = kitchen_result.scalar_one_or_none()
|
||||
kitchen_name = kitchen.name if kitchen else "Unknown Kitchen"
|
||||
|
||||
try:
|
||||
# Decode screenshot from base64
|
||||
# Remove data URL prefix if present
|
||||
screenshot_data = request.screenshot
|
||||
if screenshot_data.startswith('data:'):
|
||||
screenshot_data = screenshot_data.split(',', 1)[1]
|
||||
|
||||
screenshot_bytes = base64.b64decode(screenshot_data)
|
||||
|
||||
# Generate email
|
||||
timestamp = datetime.utcnow()
|
||||
html_body = generate_support_email_html(
|
||||
user_name=current_user.name,
|
||||
user_email=current_user.email,
|
||||
kitchen_name=kitchen_name,
|
||||
description=request.description,
|
||||
page_url=request.page_url,
|
||||
browser_info=request.browser_info,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
# Create email subject
|
||||
subject = f"Support Request from {current_user.name} - {kitchen_name}"
|
||||
|
||||
# Send email with screenshot attachment
|
||||
email_service = EmailService(settings)
|
||||
filename = f"screenshot_{timestamp.strftime('%Y%m%d_%H%M%S')}.png"
|
||||
|
||||
success = email_service.send_email(
|
||||
to_email=settings.support_email,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
attachments=[(filename, screenshot_bytes)]
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Support request sent from {current_user.name} to {settings.support_email}")
|
||||
return SupportResponse(
|
||||
success=True,
|
||||
message="Support request sent successfully. We'll get back to you soon."
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to send support request email")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to send support request. Please try again later."
|
||||
)
|
||||
|
||||
except base64.binascii.Error:
|
||||
raise HTTPException(status_code=400, detail="Invalid screenshot data")
|
||||
except Exception as e:
|
||||
logger.error(f"Support request error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/support/enabled")
|
||||
async def check_support_enabled(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Check if support requests are enabled (support email configured).
|
||||
Returns whether the support button should be shown.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
enabled = bool(
|
||||
settings and
|
||||
settings.support_email and
|
||||
settings.smtp_host and
|
||||
settings.smtp_from_email
|
||||
)
|
||||
|
||||
return {"enabled": enabled}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue