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>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
476
backend/jobs/resos_aggregation.py
Normal file
476
backend/jobs/resos_aggregation.py
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
"""
|
||||
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)")
|
||||
Loading…
Add table
Add a link
Reference in a new issue