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
|
|
@ -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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue