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>
864 lines
38 KiB
Python
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
|
|
}
|