kitchen/backend/services/resos_sync.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

864 lines
38 KiB
Python

"""
Resos Data Sync Service
Handles synchronization of booking data from Resos API to local database.
READ-ONLY integration - all API calls are GET requests only.
"""
import logging
from datetime import date, datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, and_, func, case
from sqlalchemy.dialects.postgresql import insert
from models.settings import KitchenSettings
from models.resos import ResosBooking, ResosDailyStats, ResosOpeningHour, ResosSyncLog
from services.resos_api import ResosAPIClient, ResosAPIError
logger = logging.getLogger(__name__)
class ResosSyncService:
"""Service for syncing Resos booking data"""
FORECAST_DAYS = 60
HISTORICAL_BACKFILL_DAYS = 30
def __init__(self, kitchen_id: int, db: AsyncSession):
self.kitchen_id = kitchen_id
self.db = db
self._settings: KitchenSettings = None
async def _get_settings(self) -> KitchenSettings:
"""Fetch and cache kitchen settings"""
if self._settings is None:
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
self._settings = result.scalar_one_or_none()
if not self._settings:
raise ValueError("Kitchen settings not found")
return self._settings
async def _get_client(self) -> ResosAPIClient:
"""Create authenticated Resos API client"""
settings = await self._get_settings()
if not settings.resos_api_key:
raise ValueError("Resos API key not configured")
return ResosAPIClient(settings.resos_api_key)
async def _log_sync(
self,
sync_type: str,
date_from: Optional[date] = None,
date_to: Optional[date] = None
) -> ResosSyncLog:
"""Create sync log entry"""
log = ResosSyncLog(
kitchen_id=self.kitchen_id,
sync_type=sync_type,
status="running",
date_from=date_from,
date_to=date_to
)
self.db.add(log)
await self.db.commit()
await self.db.refresh(log)
return log
async def _complete_sync(
self,
log: ResosSyncLog,
bookings_fetched: int,
bookings_flagged: int,
error: Optional[str] = None
):
"""Mark sync log as complete"""
log.status = "failed" if error else "success"
log.bookings_fetched = bookings_fetched
log.bookings_flagged = bookings_flagged
log.error_message = error
log.completed_at = datetime.utcnow()
await self.db.commit()
def _parse_custom_fields(self, custom_fields: list[dict], field_mapping: dict) -> dict:
"""
Extract custom fields from Resos booking using configured mapping
Args:
custom_fields: Raw custom fields array from Resos API
field_mapping: Mapping dict from settings (field_name -> resos_field_id)
Returns dict with extracted field values
"""
result = {}
# Build lookup by field ID if mapping exists
if field_mapping:
field_lookup = {f['_id']: f for f in custom_fields}
# Use mapped field IDs
if 'booking_number' in field_mapping:
field_id = field_mapping['booking_number']
if field_id in field_lookup:
result['hotel_booking_number'] = field_lookup[field_id].get('value')
if 'hotel_guest' in field_mapping:
field_id = field_mapping['hotel_guest']
if field_id in field_lookup:
choice_name = field_lookup[field_id].get('multipleChoiceValueName', '').lower()
result['is_hotel_guest'] = 'yes' in choice_name
if 'dbb' in field_mapping:
field_id = field_mapping['dbb']
if field_id in field_lookup:
choice_name = field_lookup[field_id].get('multipleChoiceValueName', '').lower()
result['is_dbb'] = 'yes' in choice_name
if 'package' in field_mapping:
field_id = field_mapping['package']
if field_id in field_lookup:
choice_name = field_lookup[field_id].get('multipleChoiceValueName', '').lower()
result['is_package'] = 'yes' in choice_name
if 'exclude' in field_mapping:
field_id = field_mapping['exclude']
if field_id in field_lookup:
result['exclude_flag'] = field_lookup[field_id].get('value')
# Handle allergies - combine predefined and other fields
allergies_parts = []
if 'allergies' in field_mapping:
field_id = field_mapping['allergies']
if field_id in field_lookup:
field = field_lookup[field_id]
# First, handle multipleChoiceValueName (predefined checkbox options)
if 'multipleChoiceValueName' in field:
choice_val = field.get('multipleChoiceValueName', '')
# Handle both list and string values
if isinstance(choice_val, list):
# Extract 'name' from each dict in the list
for item in choice_val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item: # If it's just a string
allergies_parts.append(str(item).strip())
elif isinstance(choice_val, str) and choice_val.startswith('['):
# Parse Python string representation (uses single quotes)
import ast
try:
parsed = ast.literal_eval(choice_val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
except (ValueError, SyntaxError):
# If parsing fails, just use the raw value
allergies_parts.append(str(choice_val))
elif choice_val:
allergies_parts.append(str(choice_val).strip())
# Then, ALSO check 'value' field
# This can contain either:
# 1. Actual Python list of checkbox selections: [{'_id': '...', 'name': 'Gluten Free', ...}]
# 2. Python list string of checkbox selections: "[{'_id': '...', 'name': 'Gluten Free', ...}]"
# 3. Free-text input: "No beef"
val = field.get('value', '')
if val:
# Check if already a list (API returns it as actual list, not string)
if isinstance(val, list):
for item in val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
elif isinstance(val, str) and val.startswith('['):
# Parse Python list string (if API returns string representation)
import ast
try:
parsed = ast.literal_eval(val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
except (ValueError, SyntaxError):
# If parsing fails, use as plain text
allergies_parts.append(str(val).strip())
else:
# Plain text value
allergies_parts.append(str(val).strip())
if 'allergies_other' in field_mapping:
field_id = field_mapping['allergies_other']
if field_id in field_lookup:
val = field_lookup[field_id].get('value', '')
if val:
allergies_parts.append(str(val).strip())
if allergies_parts:
result['allergies'] = ', '.join(filter(None, allergies_parts))
else:
# Fallback: Use name-based matching (case-insensitive substring)
allergies_parts = []
for field in custom_fields:
name = field.get('name', '').lower()
if 'booking #' in name or 'booking number' in name:
result['hotel_booking_number'] = field.get('value')
elif 'hotel guest' in name:
choice_name = field.get('multipleChoiceValueName', '').lower()
result['is_hotel_guest'] = 'yes' in choice_name
elif 'dbb' in name:
choice_name = field.get('multipleChoiceValueName', '').lower()
result['is_dbb'] = 'yes' in choice_name
elif 'package' in name:
choice_name = field.get('multipleChoiceValueName', '').lower()
result['is_package'] = 'yes' in choice_name
elif 'group' in name and 'exclude' in name:
result['exclude_flag'] = field.get('value')
elif 'allerg' in name or 'dietary' in name:
# Collect both predefined checkbox options and free-text "Other" input
# First, handle multipleChoiceValueName (predefined options)
if 'multipleChoiceValueName' in field:
val = field.get('multipleChoiceValueName', '')
# Handle both list and string values
if isinstance(val, list):
# Extract 'name' from each dict in the list
for item in val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item: # If it's just a string
allergies_parts.append(str(item).strip())
elif isinstance(val, str) and val.startswith('['):
# Parse Python string representation (uses single quotes)
import ast
try:
parsed = ast.literal_eval(val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
except (ValueError, SyntaxError):
# If parsing fails, just use the raw value
allergies_parts.append(str(val))
elif val:
allergies_parts.append(str(val).strip())
# Then, ALSO check 'value' field (free-text "Other" input OR checkbox selections)
# This can exist alongside multipleChoiceValueName
val = field.get('value', '')
if val:
# Check if already a list (API returns it as actual list, not string)
if isinstance(val, list):
for item in val:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
elif isinstance(val, str) and val.startswith('['):
# Parse Python list string (if API returns string representation)
import ast
try:
parsed = ast.literal_eval(val)
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and 'name' in item:
allergies_parts.append(item['name'].strip())
elif item:
allergies_parts.append(str(item).strip())
else:
allergies_parts.append(str(val).strip())
except (ValueError, SyntaxError):
allergies_parts.append(str(val).strip())
else:
allergies_parts.append(str(val).strip())
if allergies_parts:
result['allergies'] = ', '.join(filter(None, allergies_parts))
return result
def _check_flags(
self,
booking: dict,
people: int,
notes: str,
allergies: str,
settings: KitchenSettings
) -> tuple[bool, list[str]]:
"""
Check if booking should be flagged
Returns: (is_flagged, flag_reasons list)
"""
flags = []
# Large group check
if people >= settings.resos_large_group_threshold:
flags.append("large_group")
# Allergy check
if allergies:
flags.append("allergies")
# Note keyword check
if notes and settings.resos_note_keywords:
keywords = [k.strip().lower() for k in settings.resos_note_keywords.split('|') if k.strip()]
notes_lower = notes.lower()
for keyword in keywords:
if keyword in notes_lower:
flags.append(f"note_keyword_{keyword}")
return (len(flags) > 0, flags)
async def sync_opening_hours(self) -> int:
"""
Sync opening hours/service periods from Resos
Returns number of periods synced
"""
logger.info(f"Syncing opening hours for kitchen {self.kitchen_id}")
async with await self._get_client() as client:
hours = await client.get_opening_hours()
# Filter out special/one-off periods - only keep regular recurring service periods
# Special periods include things like "closed", "no power", one-time events, etc.
regular_hours = [h for h in hours if h.get('special') == False]
special_hours = [h for h in hours if h.get('special') == True]
logger.info(f"Fetched {len(hours)} total periods, filtered to {len(regular_hours)} regular service periods")
# Log special periods to understand what's being filtered out
breakfast_related = [h for h in special_hours if 'breakfast' in h.get('name', '').lower() or h.get('open', 0) < 1200]
if breakfast_related:
logger.info(f"Found {len(breakfast_related)} breakfast/morning periods that are marked as special:")
for h in breakfast_related[:5]: # Log first 5
open_time = f"{h.get('open', 0) // 100:02d}:{h.get('open', 0) % 100:02d}" if 'open' in h else 'N/A'
close_time = f"{h.get('close', 0) // 100:02d}:{h.get('close', 0) % 100:02d}" if 'close' in h else 'N/A'
logger.info(f" - {h.get('name', 'Unknown')}: {open_time} - {close_time} (special={h.get('special')})")
# Delete existing hours
await self.db.execute(
delete(ResosOpeningHour).where(ResosOpeningHour.kitchen_id == self.kitchen_id)
)
# Insert fresh data (only regular periods)
for hour in regular_hours:
# Transform time format: Resos API uses 'open' and 'close' as HHMM integers (e.g., 1200 = 12:00)
# Convert to time objects for database
start_time = None
end_time = None
if 'open' in hour:
open_val = hour['open']
hours_part = open_val // 100
mins_part = open_val % 100
start_time = datetime.strptime(f"{hours_part:02d}:{mins_part:02d}", "%H:%M").time()
if 'close' in hour:
close_val = hour['close']
hours_part = close_val // 100
mins_part = close_val % 100
end_time = datetime.strptime(f"{hours_part:02d}:{mins_part:02d}", "%H:%M").time()
opening_hour = ResosOpeningHour(
kitchen_id=self.kitchen_id,
resos_opening_hour_id=hour['_id'],
name=hour.get('name', 'Unknown'),
start_time=start_time,
end_time=end_time,
days_of_week=','.join(hour.get('days', [])),
is_special=hour.get('type') == 'special',
fetched_at=datetime.utcnow()
)
self.db.add(opening_hour)
await self.db.commit()
logger.info(f"Synced {len(regular_hours)} regular opening hours (excluded {len(hours) - len(regular_hours)} special periods)")
return len(regular_hours)
async def sync_bookings(
self,
date_from: date,
date_to: date,
is_forecast: bool = False
) -> dict:
"""
Sync bookings for date range
Returns summary dict with counts
"""
logger.info(f"Starting Resos sync: kitchen_id={self.kitchen_id}, from={date_from}, to={date_to}, forecast={is_forecast}")
log = await self._log_sync('forecast' if is_forecast else 'historical', date_from, date_to)
try:
settings = await self._get_settings()
logger.info(f"Retrieved settings, API key configured: {bool(settings.resos_api_key)}")
logger.info(f"Creating Resos API client...")
async with await self._get_client() as client:
logger.info(f"Fetching bookings from Resos API...")
bookings = await client.get_bookings(date_from, date_to)
total_fetched = len(bookings)
total_processed = 0
total_skipped = 0
total_flagged = 0
total_orphaned = 0
logger.info(f"Fetched {total_fetched} bookings from Resos API")
# Track all resos IDs from API response for orphan detection
api_booking_ids = set()
# Get custom field mapping from settings
field_mapping = settings.resos_custom_field_mapping or {}
# Statuses to exclude from sync (cancelled, waitlist, deleted bookings shouldn't be counted)
excluded_statuses = {'canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'}
# Process each booking
for booking_data in bookings:
resos_id = booking_data.get('_id')
if resos_id:
api_booking_ids.add(resos_id)
# Handle bookings with excluded statuses - remove from DB if they exist
status = booking_data.get('status', '').lower()
if status in excluded_statuses:
if resos_id:
await self.db.execute(
delete(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.resos_booking_id == resos_id
)
)
)
logger.debug(f"Removed/skipped booking {resos_id} with excluded status: {status}")
total_skipped += 1
continue
total_processed += 1
custom_fields = self._parse_custom_fields(
booking_data.get('customFields', []),
field_mapping
)
# Extract notes
notes_list = booking_data.get('restaurantNotes', [])
notes = '\n'.join([n.get('restaurantNote', '') for n in notes_list if n.get('restaurantNote')])
# Check flags
is_flagged, flag_reasons = self._check_flags(
booking_data,
booking_data.get('people', 0),
notes,
custom_fields.get('allergies', ''),
settings
)
if is_flagged:
total_flagged += 1
# Parse date and time
# Resos API returns date as "YYYY-MM-DD" and time as "HH:MM"
from datetime import time as time_class
booking_date = date.fromisoformat(booking_data['date'])
# Parse time string (format: "HH:MM" or "HH:MM:SS")
time_str = booking_data['time']
if ':' in time_str:
time_parts = time_str.split(':')
booking_time = time_class(int(time_parts[0]), int(time_parts[1]))
else:
# Fallback if no colon
booking_time = time_class(0, 0)
# Parse booked_at timestamp if available
# Convert to timezone-naive datetime for database (TIMESTAMP WITHOUT TIME ZONE)
booked_at = None
if booking_data.get('createdAt'):
try:
dt = datetime.fromisoformat(booking_data['createdAt'].replace('Z', '+00:00'))
# Remove timezone info to match database column type
booked_at = dt.replace(tzinfo=None)
except:
pass
# Extract table name from tables array (Phase 8.1)
# Format: [{'_id': '...', 'name': 'Table 8', 'area': {...}}]
table_name = None
tables = booking_data.get('tables', [])
if tables and len(tables) > 0:
table_name = tables[0].get('name')
# Upsert booking using INSERT ... ON CONFLICT
stmt = insert(ResosBooking).values(
kitchen_id=self.kitchen_id,
resos_booking_id=booking_data['_id'],
booking_date=booking_date,
booking_time=booking_time,
people=booking_data.get('people', 0),
status=booking_data.get('status', 'unknown').lower(),
seating_area=booking_data.get('area'),
table_name=table_name,
hotel_booking_number=custom_fields.get('hotel_booking_number'),
is_hotel_guest=custom_fields.get('is_hotel_guest'),
is_dbb=custom_fields.get('is_dbb'),
is_package=custom_fields.get('is_package'),
exclude_flag=custom_fields.get('exclude_flag'),
allergies=custom_fields.get('allergies'),
notes=notes,
booked_at=booked_at,
opening_hour_id=booking_data.get('openingHourId'),
opening_hour_name=booking_data.get('openingHourName'),
is_flagged=is_flagged,
flag_reasons=','.join(flag_reasons) if flag_reasons else None,
fetched_at=datetime.utcnow(),
is_forecast=is_forecast
)
stmt = stmt.on_conflict_do_update(
index_elements=['kitchen_id', 'resos_booking_id'],
set_={
'booking_date': stmt.excluded.booking_date,
'booking_time': stmt.excluded.booking_time,
'people': stmt.excluded.people,
'status': stmt.excluded.status,
'seating_area': stmt.excluded.seating_area,
'table_name': stmt.excluded.table_name,
'hotel_booking_number': stmt.excluded.hotel_booking_number,
'is_hotel_guest': stmt.excluded.is_hotel_guest,
'is_dbb': stmt.excluded.is_dbb,
'is_package': stmt.excluded.is_package,
'exclude_flag': stmt.excluded.exclude_flag,
'allergies': stmt.excluded.allergies,
'notes': stmt.excluded.notes,
'booked_at': stmt.excluded.booked_at,
'opening_hour_id': stmt.excluded.opening_hour_id,
'opening_hour_name': stmt.excluded.opening_hour_name,
'is_flagged': stmt.excluded.is_flagged,
'flag_reasons': stmt.excluded.flag_reasons,
'fetched_at': stmt.excluded.fetched_at,
'is_forecast': stmt.excluded.is_forecast,
}
)
await self.db.execute(stmt)
await self.db.commit()
logger.info(f"Committed {total_processed} bookings to database ({total_skipped} skipped with excluded statuses)")
# Remove orphaned bookings (in DB for this date range but not in API response)
if api_booking_ids:
orphan_result = await self.db.execute(
delete(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date >= date_from,
ResosBooking.booking_date <= date_to,
~ResosBooking.resos_booking_id.in_(api_booking_ids)
)
)
)
total_orphaned = orphan_result.rowcount
if total_orphaned > 0:
await self.db.commit()
logger.info(f"Removed {total_orphaned} orphaned bookings no longer in Resos API")
# Aggregate into daily stats
logger.info(f"Aggregating daily stats for {date_from} to {date_to}...")
await self._aggregate_daily_stats(date_from, date_to, is_forecast)
logger.info(f"Daily stats aggregation complete")
await self._complete_sync(log, total_processed, total_flagged)
logger.info(f"Resos sync completed: {total_processed} processed, {total_skipped} excluded, {total_orphaned} orphaned removed, {total_flagged} flagged")
return {
'bookings_fetched': total_fetched,
'bookings_processed': total_processed,
'bookings_skipped': total_skipped,
'bookings_orphaned': total_orphaned,
'bookings_flagged': total_flagged,
'date_from': date_from,
'date_to': date_to
}
except Exception as e:
logger.error(f"Resos sync failed: {e}", exc_info=True)
await self._complete_sync(log, 0, 0, str(e))
raise
async def _aggregate_daily_stats(
self,
date_from: date,
date_to: date,
is_forecast: bool
):
"""
Aggregate bookings into daily stats
"""
logger.info(f"_aggregate_daily_stats: Querying bookings for aggregation...")
# Query bookings grouped by date and service period
result = await self.db.execute(
select(
ResosBooking.booking_date,
ResosBooking.opening_hour_id,
ResosBooking.opening_hour_name,
func.count(ResosBooking.id).label('booking_count'),
func.sum(ResosBooking.people).label('cover_count'),
func.sum(case((ResosBooking.is_flagged == True, 1), else_=0)).label('flagged_count')
).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date >= date_from,
ResosBooking.booking_date <= date_to,
~func.lower(ResosBooking.status).in_(['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'])
)
).group_by(
ResosBooking.booking_date,
ResosBooking.opening_hour_id,
ResosBooking.opening_hour_name
)
)
# Get kitchen settings for service type mapping
settings_result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
settings = settings_result.scalar_one_or_none()
opening_hours_mapping = settings.resos_opening_hours_mapping if settings else None
# Create a map from opening_hour_id (resos_id) to service_type
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 daily stats
daily_data = {}
logger.info(f"_aggregate_daily_stats: Building daily stats from query results...")
for row in result:
booking_date = row.booking_date
if booking_date not in daily_data:
daily_data[booking_date] = {
'total_bookings': 0,
'total_covers': 0,
'flagged_count': 0,
'service_breakdown_raw': {} # Store by service type
}
daily_data[booking_date]['total_bookings'] += row.booking_count
daily_data[booking_date]['total_covers'] += row.cover_count
daily_data[booking_date]['flagged_count'] += row.flagged_count
# Map opening_hour_id to service_type
opening_hour_id = row.opening_hour_id
opening_hour_name = row.opening_hour_name or 'Unknown'
# Look up service type by opening_hour_id, fallback to opening_hour_name
service_type = service_type_map.get(opening_hour_id, opening_hour_name) if opening_hour_id else opening_hour_name
# Capitalize service type for display
service_type_display = service_type.capitalize() if service_type else 'Unknown'
# Aggregate by service type
if service_type_display not in daily_data[booking_date]['service_breakdown_raw']:
daily_data[booking_date]['service_breakdown_raw'][service_type_display] = {
'bookings': 0,
'covers': 0
}
daily_data[booking_date]['service_breakdown_raw'][service_type_display]['bookings'] += row.booking_count
daily_data[booking_date]['service_breakdown_raw'][service_type_display]['covers'] += row.cover_count
# Convert service_breakdown_raw dict to list format
for booking_date in daily_data.keys():
daily_data[booking_date]['service_breakdown'] = [
{
'period': service_type,
'bookings': stats['bookings'],
'covers': stats['covers']
}
for service_type, stats in daily_data[booking_date]['service_breakdown_raw'].items()
]
del daily_data[booking_date]['service_breakdown_raw'] # Remove temp field
# Build consolidated bookings summary for each day
for booking_date in daily_data.keys():
# Fetch all bookings for this date
bookings_result = await self.db.execute(
select(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date == booking_date,
~func.lower(ResosBooking.status).in_(['canceled', 'cancelled', 'waitlist', 'deleted', 'declined', 'rejected'])
)
).order_by(ResosBooking.booking_time)
)
bookings_for_date = bookings_result.scalars().all()
# Build consolidated summary (stripped data for quick access)
bookings_summary = []
for b in bookings_for_date:
# Map opening_hour_id to service_type for display
service_type = service_type_map.get(b.opening_hour_id, b.opening_hour_name) if b.opening_hour_id else b.opening_hour_name
service_type_display = service_type.capitalize() if service_type else (b.opening_hour_name or 'Unknown')
bookings_summary.append({
'time': b.booking_time.strftime('%H:%M'),
'people': b.people,
'period': service_type_display,
'booked_at': b.booked_at.isoformat() if b.booked_at else None,
'is_flagged': b.is_flagged,
'status': b.status
})
# Collect unique flag types for this day
unique_flags = set()
for b in bookings_for_date:
if b.is_flagged and b.flag_reasons:
# Split flag_reasons and add to set
for flag in b.flag_reasons.split(','):
flag = flag.strip()
if flag:
unique_flags.add(flag)
daily_data[booking_date]['bookings_summary'] = bookings_summary
daily_data[booking_date]['unique_flag_types'] = list(unique_flags)
# Upsert daily stats
for booking_date, data in daily_data.items():
stmt = insert(ResosDailyStats).values(
kitchen_id=self.kitchen_id,
date=booking_date,
total_bookings=data['total_bookings'],
total_covers=data['total_covers'],
service_breakdown=data['service_breakdown'],
flagged_booking_count=data['flagged_count'],
unique_flag_types=data['unique_flag_types'],
bookings_summary=data['bookings_summary'],
fetched_at=datetime.utcnow(),
is_forecast=is_forecast
)
stmt = stmt.on_conflict_do_update(
index_elements=['kitchen_id', 'date'],
set_={
'total_bookings': stmt.excluded.total_bookings,
'total_covers': stmt.excluded.total_covers,
'service_breakdown': stmt.excluded.service_breakdown,
'flagged_booking_count': stmt.excluded.flagged_booking_count,
'unique_flag_types': stmt.excluded.unique_flag_types,
'bookings_summary': stmt.excluded.bookings_summary,
'fetched_at': stmt.excluded.fetched_at,
'is_forecast': stmt.excluded.is_forecast,
}
)
await self.db.execute(stmt)
# Clean up stale daily stats for dates in range that no longer have valid bookings
# (e.g. all bookings for a date were cancelled - daily_data won't have an entry,
# so the old stats row with inflated counts would persist)
dates_with_bookings = set(daily_data.keys())
existing_stats = await self.db.execute(
select(ResosDailyStats.date).where(
and_(
ResosDailyStats.kitchen_id == self.kitchen_id,
ResosDailyStats.date >= date_from,
ResosDailyStats.date <= date_to
)
)
)
for row in existing_stats:
if row.date not in dates_with_bookings:
await self.db.execute(
delete(ResosDailyStats).where(
and_(
ResosDailyStats.kitchen_id == self.kitchen_id,
ResosDailyStats.date == row.date
)
)
)
logger.info(f"Removed stale daily stats for {row.date} (no valid bookings remaining)")
await self.db.commit()
async def run_daily_sync(self) -> dict:
"""
Run daily sync:
- Historical: Yesterday - 30 days
- Forecast: Today + 60 days
"""
today = date.today()
yesterday = today - timedelta(days=1)
historical_from = yesterday - timedelta(days=self.HISTORICAL_BACKFILL_DAYS)
forecast_to = today + timedelta(days=self.FORECAST_DAYS)
# Sync opening hours first
await self.sync_opening_hours()
# Sync historical
hist_result = await self.sync_bookings(historical_from, yesterday, is_forecast=False)
# Sync forecast
forecast_result = await self.sync_bookings(today, forecast_to, is_forecast=True)
# Update last sync timestamp
settings = await self._get_settings()
settings.resos_last_sync = datetime.utcnow()
await self.db.commit()
return {
'historical': hist_result,
'forecast': forecast_result
}
async def run_upcoming_sync(self) -> dict:
"""
Run upcoming sync for next 7 days only.
This is designed to run more frequently (e.g., every 15 minutes) to keep
the most important upcoming bookings fresh.
"""
today = date.today()
next_week = today + timedelta(days=7)
# Sync opening hours first
await self.sync_opening_hours()
# Sync next 7 days
result = await self.sync_bookings(today, next_week, is_forecast=True)
# Update last upcoming sync timestamp
settings = await self._get_settings()
settings.resos_last_upcoming_sync = datetime.utcnow()
await self.db.commit()
return {
'upcoming': result
}