forecasting/backend/jobs/resos_aggregation.py
jtricerolph 75d2c1fa9d Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 18:49:34 +00:00

476 lines
18 KiB
Python

"""
Resos Bookings Aggregation Job
Aggregates resos_bookings_data into:
- resos_bookings_stats: daily aggregated stats with period/source breakdowns
- resos_booking_pace: lead-time snapshots for pickup forecasting (3 types)
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Set, Dict, Any, Optional, Tuple, List
from collections import defaultdict
from sqlalchemy import text
from database import SyncSessionLocal
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')
# All tracked pace intervals (same as Newbook)
PACE_INTERVALS = [
# Monthly (months 7-12)
365, 330, 300, 270, 240, 210,
# Weekly (weeks 5-25)
177, 170, 163, 156, 149, 142, 135, 128, 121, 114,
107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37,
# Daily (days 0-30)
30, 29, 28, 27, 26, 25, 24, 23, 22, 21,
20, 19, 18, 17, 16, 15, 14, 13, 12, 11,
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
]
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.
Returns: (all_booking_numbers, exclude_numbers)
"""
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
async def aggregate_resos_bookings(triggered_by: str = "manual"):
"""
Aggregate Resos bookings into resos_bookings_stats.
Flow:
1. Find bookings changed since last_resos_aggregation_at
2. Calculate affected dates
3. Reaggregate each affected date
4. Update booking pace table (3 types)
5. Update last_resos_aggregation_at
"""
logger.info(f"Starting Resos bookings aggregation (triggered_by={triggered_by})")
db = SyncSessionLocal()
try:
# Get last aggregation timestamp
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'last_resos_aggregation_at'")
)
row = result.fetchone()
if row and row.config_value:
try:
last_ts = datetime.fromisoformat(row.config_value)
except ValueError:
last_ts = datetime.min
else:
last_ts = datetime.min
logger.info(f"Last aggregation: {last_ts}")
# Find bookings changed since last aggregation
result = db.execute(
text("""
SELECT resos_id, booking_date
FROM resos_bookings_data
WHERE fetched_at > :last_ts
"""),
{"last_ts": last_ts}
)
changed_bookings = result.fetchall()
if not changed_bookings:
logger.info("No changed bookings to aggregate")
# Still update pace table
await update_resos_booking_pace(db)
db.commit()
# Update timestamp
db.execute(
text("""
INSERT INTO system_config (config_key, config_value, updated_at)
VALUES ('last_resos_aggregation_at', :now, NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_value = :now,
updated_at = NOW()
"""),
{"now": datetime.now().isoformat()}
)
db.commit()
return
logger.info(f"Found {len(changed_bookings)} changed bookings")
# Calculate affected dates
affected_dates: Set[date] = set()
for booking in changed_bookings:
if booking.booking_date:
affected_dates.add(booking.booking_date)
logger.info(f"Reaggregating {len(affected_dates)} affected dates")
# Aggregate each affected date
for target_date in sorted(affected_dates):
await aggregate_date(db, target_date)
# Update booking pace table (3 types)
await update_resos_booking_pace(db)
# Update last aggregation timestamp
db.execute(
text("""
INSERT INTO system_config (config_key, config_value, updated_at)
VALUES ('last_resos_aggregation_at', :now, NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_value = :now,
updated_at = NOW()
"""),
{"now": datetime.now().isoformat()}
)
db.commit()
logger.info(f"Resos bookings aggregation completed: {len(affected_dates)} dates processed")
except Exception as e:
logger.error(f"Resos bookings aggregation failed: {e}", exc_info=True)
db.rollback()
raise
finally:
db.close()
async def aggregate_date(db, target_date: date):
"""
Aggregate all Resos bookings for a specific date into resos_bookings_stats.
"""
# Get all valid bookings for this date
result = db.execute(
text("""
SELECT
resos_id,
period_type,
covers,
source,
opening_hour_id,
is_hotel_guest,
is_dbb,
is_package,
total_guests,
hotel_booking_number,
group_exclude_field
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN :valid_statuses
"""),
{"target_date": target_date, "valid_statuses": VALID_STATUSES}
)
bookings = result.fetchall()
# Initialize counters
breakfast_covers = 0
lunch_covers = 0
afternoon_covers = 0
dinner_covers = 0
other_covers = 0
breakfast_bookings = 0
lunch_bookings = 0
afternoon_bookings = 0
dinner_bookings = 0
other_bookings = 0
hotel_guest_covers = 0
non_hotel_guest_covers = 0
dbb_covers = 0
package_covers = 0
covers_by_source: Dict[str, int] = defaultdict(int)
covers_by_period: Dict[str, Dict[str, Any]] = {}
total_party_sizes = []
party_sizes_by_period: Dict[str, list] = defaultdict(list)
# Build hotel_booking_numbers mapping
hotel_booking_numbers: Dict[str, str] = {} # hotel_booking_number -> resos_id
bookings_with_hotel_link = 0
for booking in bookings:
period = booking.period_type or 'other'
covers = booking.covers or 0
source = booking.source or 'unknown'
opening_hour_id = booking.opening_hour_id
resos_id = booking.resos_id
# Count by period
if period == 'breakfast':
breakfast_covers += covers
breakfast_bookings += 1
elif period == 'lunch':
lunch_covers += covers
lunch_bookings += 1
elif period == 'afternoon':
afternoon_covers += covers
afternoon_bookings += 1
elif period == 'dinner':
dinner_covers += covers
dinner_bookings += 1
else:
other_covers += covers
other_bookings += 1
# Count by source
covers_by_source[source] += covers
# Count by period (detailed)
if opening_hour_id:
if opening_hour_id not in covers_by_period:
covers_by_period[opening_hour_id] = {
"period_type": period,
"covers": 0,
"bookings": 0
}
covers_by_period[opening_hour_id]["covers"] += covers
covers_by_period[opening_hour_id]["bookings"] += 1
# Count business segments
if booking.is_hotel_guest:
hotel_guest_covers += covers
else:
non_hotel_guest_covers += covers
if booking.is_dbb:
dbb_covers += covers
if booking.is_package:
package_covers += covers
# Track party sizes
if covers > 0:
total_party_sizes.append(covers)
party_sizes_by_period[period].append(covers)
# Build hotel booking numbers mapping
all_booking_numbers, _ = parse_group_exclude_field(
booking.group_exclude_field,
booking.hotel_booking_number
)
if all_booking_numbers:
bookings_with_hotel_link += 1
for hotel_number in all_booking_numbers:
hotel_booking_numbers[hotel_number] = resos_id
# Calculate averages
avg_party_size = sum(total_party_sizes) / len(total_party_sizes) if total_party_sizes else None
avg_by_period = {}
for period, sizes in party_sizes_by_period.items():
avg_by_period[period] = sum(sizes) / len(sizes) if sizes else None
total_covers = breakfast_covers + lunch_covers + afternoon_covers + dinner_covers + other_covers
total_bookings = breakfast_bookings + lunch_bookings + afternoon_bookings + dinner_bookings + other_bookings
distinct_hotel_bookings = len(hotel_booking_numbers)
# Upsert stats
db.execute(
text("""
INSERT INTO resos_bookings_stats (
date,
breakfast_covers, lunch_covers, afternoon_covers, dinner_covers, other_covers, total_covers,
breakfast_bookings, lunch_bookings, afternoon_bookings, dinner_bookings, other_bookings, total_bookings,
covers_by_source, covers_by_period,
hotel_guest_covers, non_hotel_guest_covers, dbb_covers, package_covers,
hotel_booking_numbers, distinct_hotel_bookings, bookings_with_hotel_link,
avg_party_size, avg_party_size_by_period,
aggregated_at
) VALUES (
:date,
:breakfast_covers, :lunch_covers, :afternoon_covers, :dinner_covers, :other_covers, :total_covers,
:breakfast_bookings, :lunch_bookings, :afternoon_bookings, :dinner_bookings, :other_bookings, :total_bookings,
:covers_by_source, :covers_by_period,
:hotel_guest_covers, :non_hotel_guest_covers, :dbb_covers, :package_covers,
:hotel_booking_numbers, :distinct_hotel_bookings, :bookings_with_hotel_link,
:avg_party_size, :avg_party_size_by_period,
NOW()
)
ON CONFLICT (date) DO UPDATE SET
breakfast_covers = :breakfast_covers,
lunch_covers = :lunch_covers,
afternoon_covers = :afternoon_covers,
dinner_covers = :dinner_covers,
other_covers = :other_covers,
total_covers = :total_covers,
breakfast_bookings = :breakfast_bookings,
lunch_bookings = :lunch_bookings,
afternoon_bookings = :afternoon_bookings,
dinner_bookings = :dinner_bookings,
other_bookings = :other_bookings,
total_bookings = :total_bookings,
covers_by_source = :covers_by_source,
covers_by_period = :covers_by_period,
hotel_guest_covers = :hotel_guest_covers,
non_hotel_guest_covers = :non_hotel_guest_covers,
dbb_covers = :dbb_covers,
package_covers = :package_covers,
hotel_booking_numbers = :hotel_booking_numbers,
distinct_hotel_bookings = :distinct_hotel_bookings,
bookings_with_hotel_link = :bookings_with_hotel_link,
avg_party_size = :avg_party_size,
avg_party_size_by_period = :avg_party_size_by_period,
aggregated_at = NOW()
"""),
{
"date": target_date,
"breakfast_covers": breakfast_covers,
"lunch_covers": lunch_covers,
"afternoon_covers": afternoon_covers,
"dinner_covers": dinner_covers,
"other_covers": other_covers,
"total_covers": total_covers,
"breakfast_bookings": breakfast_bookings,
"lunch_bookings": lunch_bookings,
"afternoon_bookings": afternoon_bookings,
"dinner_bookings": dinner_bookings,
"other_bookings": other_bookings,
"total_bookings": total_bookings,
"covers_by_source": json.dumps(dict(covers_by_source)),
"covers_by_period": json.dumps(covers_by_period),
"hotel_guest_covers": hotel_guest_covers,
"non_hotel_guest_covers": non_hotel_guest_covers,
"dbb_covers": dbb_covers,
"package_covers": package_covers,
"hotel_booking_numbers": json.dumps(hotel_booking_numbers),
"distinct_hotel_bookings": distinct_hotel_bookings,
"bookings_with_hotel_link": bookings_with_hotel_link,
"avg_party_size": avg_party_size,
"avg_party_size_by_period": json.dumps(avg_by_period)
}
)
db.commit()
async def update_resos_booking_pace(db):
"""
Update resos_booking_pace table with lead-time snapshots.
Creates 3 rows per date: total, resident, non_resident
"""
logger.info("Updating Resos booking pace table...")
today = date.today()
# Process dates from -30 to +365 (historical + forecast window)
from_date = today - timedelta(days=30)
to_date = today + timedelta(days=365)
current = from_date
while current <= to_date:
# Calculate pace for each type
for pace_type in ['total', 'resident', 'non_resident']:
pace_values = {}
for days_out in PACE_INTERVALS:
snapshot_date = current - timedelta(days=days_out)
# Build query based on pace_type
if pace_type == 'total':
# All valid bookings
result = db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN :valid_statuses
AND booking_placed <= :snapshot_date
"""),
{
"target_date": current,
"valid_statuses": VALID_STATUSES,
"snapshot_date": snapshot_date
}
)
elif pace_type == 'resident':
# Hotel guests only
result = db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN :valid_statuses
AND booking_placed <= :snapshot_date
AND is_hotel_guest = true
"""),
{
"target_date": current,
"valid_statuses": VALID_STATUSES,
"snapshot_date": snapshot_date
}
)
else: # non_resident
# Non-hotel guests
result = db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN :valid_statuses
AND booking_placed <= :snapshot_date
AND (is_hotel_guest = false OR is_hotel_guest IS NULL)
"""),
{
"target_date": current,
"valid_statuses": VALID_STATUSES,
"snapshot_date": snapshot_date
}
)
row = result.fetchone()
pace_values[f"d{days_out}"] = row.total_covers if row else 0
# Upsert pace record
columns = ", ".join(pace_values.keys())
placeholders = ", ".join([f":{k}" for k in pace_values.keys()])
updates = ", ".join([f"{k} = :{k}" for k in pace_values.keys()])
db.execute(
text(f"""
INSERT INTO resos_booking_pace (booking_date, pace_type, {columns}, updated_at)
VALUES (:booking_date, :pace_type, {placeholders}, NOW())
ON CONFLICT (booking_date, pace_type) DO UPDATE SET
{updates},
updated_at = NOW()
"""),
{"booking_date": current, "pace_type": pace_type, **pace_values}
)
current += timedelta(days=1)
db.commit()
logger.info("Resos booking pace table updated (3 types: total, resident, non_resident)")