forecasting/backend/jobs/resos_bookings_sync.py
jtricerolph 3650c92175 Move Resos API key to central Settings service; add sidebar scrollbar styling
Removes the standalone resos_api_key from the forecasting app's own
system_config table. All credential fetches now go through
central_settings.get_resos_credentials() / get_resos_credentials_sync()
which pull from the Settings app (LXC 116) via the internal integration
endpoint — the same pattern already used for NewBook. The Resos API
Config section is removed from the forecasting Settings page; users
manage the key in the central Settings app instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 14:35:35 +00:00

377 lines
15 KiB
Python

"""
Resos Bookings Data Sync Job
Syncs restaurant bookings to resos_bookings_data table
Pattern: Replicates newbook bookings sync but adapted for Resos covers/stats
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Optional, Tuple, List, Dict, Any
from sqlalchemy import text
from database import SyncSessionLocal
from services.resos_client import ResosClient
logger = logging.getLogger(__name__)
# Valid booking statuses for aggregation
# Note: Resos uses 'approved' for confirmed future reservations, 'left' for completed meals
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
def load_resos_custom_field_mappings(db) -> Dict[str, Dict[str, Any]]:
"""
Load custom field mappings from resos_custom_field_mapping table.
Returns dict: {field_id: {"maps_to": "hotel_guest", "value_for_true": "Yes"}}
"""
result = db.execute(text("""
SELECT field_id, maps_to, value_for_true
FROM resos_custom_field_mapping
WHERE maps_to != 'ignore'
"""))
mappings = {}
for row in result.fetchall():
mappings[row.field_id] = {
"maps_to": row.maps_to,
"value_for_true": row.value_for_true
}
logger.info(f"Loaded {len(mappings)} custom field mappings")
return mappings
def load_resos_opening_hours_mappings(db) -> Dict[str, Dict[str, str]]:
"""
Load opening hours mappings from resos_opening_hours_mapping table.
Returns dict: {opening_hour_id: {"period_type": "dinner", "display_name": "..."}}
"""
result = db.execute(text("""
SELECT opening_hour_id, period_type, display_name
FROM resos_opening_hours_mapping
WHERE period_type != 'ignore'
"""))
mappings = {}
for row in result.fetchall():
mappings[row.opening_hour_id] = {
"period_type": row.period_type,
"display_name": row.display_name
}
logger.info(f"Loaded {len(mappings)} opening hours mappings")
return mappings
def parse_group_exclude_field(group_exclude_field: Optional[str], primary_booking_number: Optional[str]) -> Tuple[List[str], List[str]]:
"""
Parse group_exclude_field to extract linked bookings and exclude markers.
Args:
group_exclude_field: Raw field like "#12346,#12347,NOT-#56748"
primary_booking_number: Primary booking from hotel_booking_number field
Returns:
(all_booking_numbers, exclude_numbers)
Example:
Input: "#12346,#12347,NOT-#56748", "NB12345"
Returns: (["NB12345", "NB12346", "NB12347"], ["NB56748"])
"""
all_booking_numbers = []
exclude_numbers = []
# Always include primary booking number
if primary_booking_number:
all_booking_numbers.append(primary_booking_number)
if not group_exclude_field:
return all_booking_numbers, exclude_numbers
# Parse comma-separated entries
parts = group_exclude_field.split(',')
for part in parts:
part = part.strip()
if part.upper().startswith('NOT-#'):
# Exclude marker: NOT-#56748 → NB56748
booking_num = part[5:] # Remove "NOT-#"
exclude_numbers.append(f"NB{booking_num}")
elif part.startswith('#'):
# Additional booking: #12346 → NB12346
booking_num = part[1:] # Remove "#"
all_booking_numbers.append(f"NB{booking_num}")
return all_booking_numbers, exclude_numbers
def extract_custom_field_value(custom_fields: List[Dict[str, Any]], field_id: str, cf_mappings: Dict[str, Dict[str, Any]]) -> Optional[Any]:
"""
Extract value from Resos custom fields array.
Args:
custom_fields: Array of custom field objects from Resos API
field_id: The field ID to look for
cf_mappings: Mapping configuration
Returns:
Extracted value (boolean for hotel_guest/dbb/package, string for booking_number, etc.)
"""
if field_id not in cf_mappings:
return None
mapping = cf_mappings[field_id]
maps_to = mapping["maps_to"]
value_for_true = mapping.get("value_for_true")
# Find the field in custom_fields array
field_value = None
field_value_label = None
for cf in custom_fields:
cf_id = cf.get("id") or cf.get("_id") or cf.get("fieldId")
if cf_id == field_id:
field_value = cf.get("value")
field_value_label = cf.get("multipleChoiceValueName") or cf.get("value")
break
if field_value is None and field_value_label is None:
return None
# For boolean mappings (hotel_guest, dbb, package)
if maps_to in ("hotel_guest", "dbb", "package"):
if value_for_true:
return str(field_value_label) == str(value_for_true)
else:
# Auto-detect: check for "yes", "true", "1"
return str(field_value_label).lower() in ("yes", "true", "1")
# For string mappings (booking_number, group_exclude, allergies)
return str(field_value) if field_value else str(field_value_label) if field_value_label else None
async def sync_resos_bookings_data(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""
Sync Resos bookings to resos_bookings_data.
Date range: Historical -365 days + Forecast +365 days (daily sync)
PII handling: Remove guest details, store only aggregate covers
"""
logger.info(f"Starting Resos bookings sync from {from_date} to {to_date} (triggered by {triggered_by})")
db = SyncSessionLocal()
try:
# Log sync start
db.execute(
text("""
INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by)
VALUES ('bookings_data', 'resos', NOW(), 'running', :from_date, :to_date, :triggered_by)
"""),
{"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by}
)
db.commit()
# Load Resos API key from central Settings service
from services.central_settings import get_resos_credentials_sync
_resos_creds = get_resos_credentials_sync()
api_key = _resos_creds["api_key"] if _resos_creds else None
if not api_key:
raise Exception("Resos API key not configured in central Settings")
# Load mappings
cf_mappings = load_resos_custom_field_mappings(db)
oh_mappings = load_resos_opening_hours_mappings(db)
async with ResosClient(api_key=api_key) as client:
# Test connection
if not await client.test_connection():
raise Exception("Resos connection failed")
# Fetch bookings
logger.info(f"Fetching bookings from Resos API for {from_date} to {to_date}")
bookings = await client.get_bookings(from_date, to_date)
logger.info(f"Fetched {len(bookings)} bookings from Resos")
records_created = 0
records_updated = 0
for booking in bookings:
resos_id = booking.get("_id")
if not resos_id:
continue
booking_date_str = booking.get("date")
booking_date_obj = date.fromisoformat(booking_date_str) if booking_date_str else None
if not booking_date_obj:
continue
# Extract opening hour ID and map to period type
opening_hour_id = booking.get("openingHourId")
period_type = None
if opening_hour_id and opening_hour_id in oh_mappings:
period_type = oh_mappings[opening_hour_id]["period_type"]
# Extract custom fields using mappings
custom_fields = booking.get("customFields", [])
is_hotel_guest = None
is_dbb = None
is_package = None
hotel_booking_number = None
group_exclude_field = None
for field_id, mapping in cf_mappings.items():
maps_to = mapping["maps_to"]
if maps_to == "hotel_guest":
is_hotel_guest = extract_custom_field_value(custom_fields, field_id, cf_mappings)
elif maps_to == "dbb":
is_dbb = extract_custom_field_value(custom_fields, field_id, cf_mappings)
elif maps_to == "package":
is_package = extract_custom_field_value(custom_fields, field_id, cf_mappings)
elif maps_to == "booking_number":
hotel_booking_number = extract_custom_field_value(custom_fields, field_id, cf_mappings)
elif maps_to == "group_exclude":
group_exclude_field = extract_custom_field_value(custom_fields, field_id, cf_mappings)
# Remove PII from raw JSON (remove guest object)
raw_booking = {k: v for k, v in booking.items() if k != "guest"}
raw_json_str = json.dumps(raw_booking)
# Extract other booking details
covers = booking.get("people", 0)
status = booking.get("status")
source = booking.get("source")
booking_time_str = booking.get("time")
# Parse table information
tables = booking.get("tables", [])
table_name = tables[0].get("name") if tables else None
table_area = None
if tables and tables[0].get("area"):
table_area = tables[0]["area"].get("name")
# Parse booking placed timestamp
booking_placed_str = booking.get("createdAt")
booking_placed = None
if booking_placed_str:
try:
booking_placed = datetime.fromisoformat(booking_placed_str.replace('Z', '+00:00'))
except:
pass
# Parse notes (sanitized - no PII)
notes_array = booking.get("restaurantNotes", [])
notes = ', '.join([str(note) for note in notes_array]) if notes_array else None
# Check if record exists
existing = db.execute(
text("SELECT id FROM resos_bookings_data WHERE resos_id = :rid"),
{"rid": resos_id}
).fetchone()
# Upsert booking
db.execute(
text("""
INSERT INTO resos_bookings_data (
resos_id, booking_date, booking_time, opening_hour_id, period_type,
covers, status, source, table_name, table_area,
is_hotel_guest, is_dbb, is_package, hotel_booking_number, group_exclude_field,
total_guests, booking_placed, notes, raw_json, fetched_at
) VALUES (
:resos_id, :booking_date, :booking_time, :opening_hour_id, :period_type,
:covers, :status, :source, :table_name, :table_area,
:is_hotel_guest, :is_dbb, :is_package, :hotel_booking_number, :group_exclude_field,
:total_guests, :booking_placed, :notes, :raw_json, NOW()
)
ON CONFLICT (resos_id) DO UPDATE SET
status = :status,
covers = :covers,
period_type = :period_type,
is_hotel_guest = COALESCE(:is_hotel_guest, resos_bookings_data.is_hotel_guest),
is_dbb = COALESCE(:is_dbb, resos_bookings_data.is_dbb),
is_package = COALESCE(:is_package, resos_bookings_data.is_package),
hotel_booking_number = COALESCE(:hotel_booking_number, resos_bookings_data.hotel_booking_number),
group_exclude_field = COALESCE(:group_exclude_field, resos_bookings_data.group_exclude_field),
raw_json = :raw_json,
fetched_at = NOW()
"""),
{
"resos_id": resos_id,
"booking_date": booking_date_obj,
"booking_time": booking_time_str,
"opening_hour_id": opening_hour_id,
"period_type": period_type,
"covers": covers,
"status": status,
"source": source,
"table_name": table_name,
"table_area": table_area,
"is_hotel_guest": is_hotel_guest,
"is_dbb": is_dbb,
"is_package": is_package,
"hotel_booking_number": hotel_booking_number,
"group_exclude_field": group_exclude_field,
"total_guests": covers, # Same as covers for restaurants
"booking_placed": booking_placed,
"notes": notes,
"raw_json": raw_json_str
}
)
if existing:
records_updated += 1
else:
records_created += 1
db.commit()
# Update sync log
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :fetched, records_created = :created, records_updated = :updated
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'resos' AND sync_type = 'bookings_data' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"fetched": len(bookings), "created": records_created, "updated": records_updated}
)
db.commit()
logger.info(f"Resos bookings sync completed: {records_created} created, {records_updated} updated")
# Trigger aggregation
logger.info("Triggering Resos bookings aggregation...")
try:
import asyncio
from jobs.resos_aggregation import aggregate_resos_bookings
await aggregate_resos_bookings(triggered_by=triggered_by)
logger.info("Resos bookings aggregation completed")
except Exception as agg_error:
logger.warning(f"Resos aggregation failed (non-fatal): {agg_error}")
except Exception as e:
logger.error(f"Resos bookings sync failed: {e}", exc_info=True)
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'resos' AND sync_type = 'bookings_data' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"error": str(e)[:500]}
)
db.commit()
raise
finally:
db.close()