FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
809 lines
30 KiB
Python
809 lines
30 KiB
Python
"""
|
|
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}
|