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>
208 lines
10 KiB
Python
208 lines
10 KiB
Python
"""
|
|
Pickup snapshot job - captures daily on-the-books values
|
|
Used by the pickup model for pace comparison
|
|
|
|
Prior year comparison uses 364 days (52 weeks) for day-of-week alignment:
|
|
- Monday compares to Monday
|
|
- Saturday compares to Saturday
|
|
"""
|
|
import logging
|
|
from datetime import date, timedelta
|
|
|
|
from sqlalchemy import text
|
|
from database import SyncSessionLocal
|
|
from utils.time_alignment import get_prior_year_daily, SQL_PRIOR_YEAR_OFFSET
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def run_pickup_snapshot():
|
|
"""
|
|
Capture daily on-the-books snapshot for future dates.
|
|
Stores OTB values at various lead times for pickup model.
|
|
"""
|
|
logger.info("Starting pickup snapshot capture")
|
|
|
|
db = next(iter([SyncSessionLocal()]))
|
|
snapshot_date = date.today()
|
|
|
|
# Overflow room category (category_id=5) is used for chargeable no-shows/cancellations
|
|
# and should be excluded from room night counts
|
|
overflow_category_id = '5'
|
|
|
|
try:
|
|
# Capture OTB for next 365 days (extended from 60 for longer-term forecasting)
|
|
for days_out in range(1, 366):
|
|
stay_date = snapshot_date + timedelta(days=days_out)
|
|
|
|
# Get total available rooms for this date from occupancy report
|
|
rooms_result = db.execute(
|
|
text("""
|
|
SELECT COALESCE(SUM(available), 25) as total_rooms
|
|
FROM newbook_occupancy_report
|
|
WHERE date = :stay_date
|
|
"""),
|
|
{"stay_date": stay_date}
|
|
).fetchone()
|
|
total_rooms = rooms_result.total_rooms if rooms_result and rooms_result.total_rooms else 25
|
|
|
|
# Get hotel occupancy OTB (count rooms on the books for this stay date)
|
|
# EXCLUDES overflow category (chargeable no-shows)
|
|
hotel_result = db.execute(
|
|
text("""
|
|
SELECT COUNT(DISTINCT newbook_id) as bookings,
|
|
SUM(CASE WHEN LOWER(status) IN ('confirmed', 'provisional', 'unconfirmed', 'arrived') THEN 1 ELSE 0 END) as active_bookings
|
|
FROM newbook_bookings
|
|
WHERE arrival_date <= :stay_date AND departure_date > :stay_date
|
|
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist')
|
|
AND (category_id IS NULL OR category_id != :overflow_cat)
|
|
"""),
|
|
{"stay_date": stay_date, "overflow_cat": overflow_category_id}
|
|
)
|
|
hotel_row = hotel_result.fetchone()
|
|
hotel_otb = hotel_row.active_bookings or 0
|
|
|
|
# Get dinner covers OTB
|
|
dinner_result = db.execute(
|
|
text("""
|
|
SELECT COUNT(*) as bookings, COALESCE(SUM(covers), 0) as total_covers
|
|
FROM resos_bookings
|
|
WHERE booking_date = :stay_date
|
|
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show')
|
|
AND booking_time >= '15:00'
|
|
"""),
|
|
{"stay_date": stay_date}
|
|
)
|
|
dinner_row = dinner_result.fetchone()
|
|
dinner_otb = dinner_row.total_covers or 0
|
|
|
|
# Get lunch covers OTB
|
|
lunch_result = db.execute(
|
|
text("""
|
|
SELECT COUNT(*) as bookings, COALESCE(SUM(covers), 0) as total_covers
|
|
FROM resos_bookings
|
|
WHERE booking_date = :stay_date
|
|
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show')
|
|
AND booking_time < '15:00'
|
|
"""),
|
|
{"stay_date": stay_date}
|
|
)
|
|
lunch_row = lunch_result.fetchone()
|
|
lunch_otb = lunch_row.total_covers or 0
|
|
|
|
# Get prior year comparison data (same day of week, exactly 52 weeks ago)
|
|
# Uses SQL_PRIOR_YEAR_OFFSET (364 days = 52 weeks) for Mon→Mon, Sat→Sat alignment
|
|
prior_year_stay_date = get_prior_year_daily(stay_date)
|
|
prior_year_snapshot_date = get_prior_year_daily(snapshot_date) # Same lead time last year
|
|
|
|
# Calculate prior year hotel OTB at same lead time using booking_placed
|
|
# EXCLUDES overflow category
|
|
prior_hotel_otb_result = db.execute(
|
|
text("""
|
|
SELECT COUNT(DISTINCT newbook_id) as otb_count
|
|
FROM newbook_bookings
|
|
WHERE arrival_date <= :prior_stay_date
|
|
AND departure_date > :prior_stay_date
|
|
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist')
|
|
AND (raw_json->>'booking_placed')::timestamp <= :prior_snapshot_date
|
|
AND (category_id IS NULL OR category_id != :overflow_cat)
|
|
"""),
|
|
{"prior_stay_date": prior_year_stay_date, "prior_snapshot_date": prior_year_snapshot_date, "overflow_cat": overflow_category_id}
|
|
).fetchone()
|
|
prior_hotel_otb = prior_hotel_otb_result.otb_count if prior_hotel_otb_result else None
|
|
|
|
# Store snapshots for each metric
|
|
# For hotel_occupancy_pct: store room count, convert to % for otb_value
|
|
# For hotel_room_nights: store raw room count (no conversion)
|
|
# For restaurant: store cover counts directly
|
|
for metric_type, otb_raw, otb_bookings_count in [
|
|
('hotel_occupancy_pct', hotel_otb, hotel_row.bookings),
|
|
('hotel_room_nights', hotel_otb, hotel_row.bookings), # Same count, stored as-is
|
|
('resos_dinner_covers', dinner_otb, dinner_row.bookings),
|
|
('resos_lunch_covers', lunch_otb, lunch_row.bookings)
|
|
]:
|
|
# Convert to percentage for occupancy metric, keep raw counts for others
|
|
if metric_type == 'hotel_occupancy_pct':
|
|
otb_value = (otb_raw / total_rooms) * 100 if total_rooms > 0 else 0
|
|
# Use 'is not None' check - 0 is valid data (no bookings at that lead time)
|
|
prior_otb_value = (prior_hotel_otb / total_rooms) * 100 if prior_hotel_otb is not None and total_rooms > 0 else None
|
|
elif metric_type == 'hotel_room_nights':
|
|
otb_value = otb_raw # Raw room count
|
|
prior_otb_value = prior_hotel_otb # Raw room count from prior year
|
|
else:
|
|
otb_value = otb_raw
|
|
prior_otb_value = None # Will try historical snapshots below
|
|
|
|
# Get prior year ACTUAL from daily_metrics (the final outcome)
|
|
prior_final_result = db.execute(
|
|
text("""
|
|
SELECT actual_value
|
|
FROM daily_metrics
|
|
WHERE date = :prior_date AND metric_code = :metric
|
|
"""),
|
|
{"prior_date": prior_year_stay_date, "metric": metric_type}
|
|
).fetchone()
|
|
prior_final = float(prior_final_result.actual_value) if prior_final_result and prior_final_result.actual_value else None
|
|
|
|
# Use reconstructed prior year OTB for hotel metrics, or try historical snapshots
|
|
if metric_type in ('hotel_occupancy_pct', 'hotel_room_nights') and prior_otb_value is not None:
|
|
prior_otb = prior_otb_value
|
|
else:
|
|
# For restaurant metrics, fall back to historical snapshots
|
|
prior_otb_result = db.execute(
|
|
text("""
|
|
SELECT otb_value
|
|
FROM pickup_snapshots
|
|
WHERE stay_date = :prior_stay_date
|
|
AND metric_type = :metric
|
|
AND days_out = :days_out
|
|
ORDER BY snapshot_date DESC LIMIT 1
|
|
"""),
|
|
{"prior_stay_date": prior_year_stay_date, "metric": metric_type, "days_out": days_out}
|
|
).fetchone()
|
|
# Use 'is not None' - 0 is valid OTB data
|
|
prior_otb = float(prior_otb_result.otb_value) if prior_otb_result and prior_otb_result.otb_value is not None else None
|
|
|
|
# Calculate pace vs prior year if we have comparison data
|
|
pace_pct = None
|
|
if prior_otb and prior_otb > 0:
|
|
pace_pct = ((otb_value - prior_otb) / prior_otb) * 100
|
|
|
|
db.execute(
|
|
text("""
|
|
INSERT INTO pickup_snapshots (
|
|
snapshot_date, stay_date, days_out, metric_type,
|
|
otb_value, otb_bookings, prior_year_otb, prior_year_final,
|
|
pace_vs_prior_pct, created_at
|
|
) VALUES (
|
|
:snapshot_date, :stay_date, :days_out, :metric_type,
|
|
:otb_value, :otb_bookings, :prior_year_otb, :prior_year_final,
|
|
:pace_pct, NOW()
|
|
)
|
|
ON CONFLICT (snapshot_date, stay_date, metric_type) DO UPDATE SET
|
|
otb_value = :otb_value,
|
|
prior_year_otb = :prior_year_otb,
|
|
pace_vs_prior_pct = :pace_pct
|
|
"""),
|
|
{
|
|
"snapshot_date": snapshot_date,
|
|
"stay_date": stay_date,
|
|
"days_out": days_out,
|
|
"metric_type": metric_type,
|
|
"otb_value": round(otb_value, 2), # % for hotel, covers for restaurant
|
|
"otb_bookings": otb_raw, # Raw count (rooms or covers)
|
|
"prior_year_otb": round(prior_otb, 2) if prior_otb is not None else None,
|
|
"prior_year_final": prior_final,
|
|
"pace_pct": round(pace_pct, 2) if pace_pct else None
|
|
}
|
|
)
|
|
|
|
db.commit()
|
|
logger.info(f"Pickup snapshot completed for {snapshot_date}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Pickup snapshot failed: {e}")
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|