Add .gitignore, remove __pycache__ from tracking

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

View file

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