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>
3727 lines
153 KiB
Python
3727 lines
153 KiB
Python
"""
|
||
Forecast API endpoints
|
||
"""
|
||
import asyncio
|
||
import math
|
||
from datetime import date, timedelta
|
||
from typing import Optional, List
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import text
|
||
from pydantic import BaseModel
|
||
|
||
from database import get_db
|
||
from auth import get_current_user
|
||
from api.special_dates import resolve_special_date
|
||
from utils.capacity import get_bookable_cap
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# Metric column mapping - defines how to get historical data for each metric
|
||
# Each entry: (column_expression, needs_revenue_join, is_percentage)
|
||
METRIC_COLUMN_MAP = {
|
||
'occupancy': ('s.total_occupancy_pct', False, True),
|
||
'rooms': ('s.booking_count', False, False),
|
||
'guests': ('s.guests_count', False, False),
|
||
'ave_guest_rate': ('s.guest_rate_total / NULLIF(s.booking_count, 0)', False, False),
|
||
'arr': ('r.accommodation / NULLIF(s.booking_count, 0)', True, False),
|
||
'net_accom': ('r.accommodation', True, False),
|
||
'net_dry': ('r.dry', True, False),
|
||
'net_wet': ('r.wet', True, False),
|
||
'total_rev': ('COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0)', True, False),
|
||
}
|
||
|
||
|
||
def get_metric_query_parts(metric: str) -> tuple:
|
||
"""
|
||
Get SQL query parts for a metric.
|
||
Returns: (column_expr, from_clause, is_percentage)
|
||
"""
|
||
if metric not in METRIC_COLUMN_MAP:
|
||
# Default to rooms if unknown metric
|
||
metric = 'rooms'
|
||
|
||
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
|
||
|
||
if needs_revenue:
|
||
from_clause = """
|
||
FROM newbook_bookings_stats s
|
||
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
|
||
"""
|
||
else:
|
||
from_clause = "FROM newbook_bookings_stats s"
|
||
|
||
return col_expr, from_clause, is_pct
|
||
|
||
|
||
def round_towards_reference(value: float, reference: Optional[float]) -> int:
|
||
"""
|
||
Round a forecast value towards a reference value (prior year actual).
|
||
|
||
- If forecast < reference: round up (ceil) towards reference
|
||
- If forecast > reference: round down (floor) towards reference
|
||
- If no reference: use standard rounding
|
||
|
||
Examples:
|
||
- 24.2 with prior year 25 → 25 (ceil towards reference)
|
||
- 22.8 with prior year 20 → 22 (floor towards reference)
|
||
"""
|
||
if reference is None:
|
||
return round(value)
|
||
|
||
if value < reference:
|
||
return math.ceil(value)
|
||
else:
|
||
return math.floor(value)
|
||
|
||
|
||
class ForecastResponse(BaseModel):
|
||
date: date
|
||
metric_code: str
|
||
metric_name: str
|
||
prophet_value: Optional[float]
|
||
prophet_lower: Optional[float]
|
||
prophet_upper: Optional[float]
|
||
xgboost_value: Optional[float]
|
||
pickup_value: Optional[float]
|
||
current_otb: Optional[float]
|
||
budget_value: Optional[float]
|
||
|
||
|
||
class DailyForecastSummary(BaseModel):
|
||
date: date
|
||
day_of_week: str
|
||
hotel_occupancy_pct: Optional[float]
|
||
hotel_guests: Optional[float]
|
||
hotel_arrivals: Optional[float]
|
||
hotel_adr: Optional[float]
|
||
resos_lunch_covers: Optional[float]
|
||
resos_dinner_covers: Optional[float]
|
||
model_used: str
|
||
|
||
|
||
@router.get("/daily")
|
||
async def get_daily_forecasts(
|
||
from_date: Optional[date] = Query(None, description="Start date (default: today)"),
|
||
to_date: Optional[date] = Query(None, description="End date (default: +14 days)"),
|
||
metric: Optional[str] = Query(None, description="Filter by metric code"),
|
||
model: Optional[str] = Query(None, description="Filter by model type: prophet, xgboost, pickup, all"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get daily forecasts with all models side-by-side.
|
||
Returns forecasts for each date with Prophet, XGBoost, Pickup values and confidence intervals.
|
||
"""
|
||
if from_date is None:
|
||
from_date = date.today()
|
||
if to_date is None:
|
||
to_date = from_date + timedelta(days=14)
|
||
|
||
query = """
|
||
SELECT
|
||
f.forecast_date,
|
||
f.forecast_type as metric_code,
|
||
fm.metric_name,
|
||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as prophet_value,
|
||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.lower_bound END) as prophet_lower,
|
||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.upper_bound END) as prophet_upper,
|
||
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as xgboost_value,
|
||
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as pickup_value,
|
||
ps.otb_value as current_otb,
|
||
db.budget_value
|
||
FROM forecasts f
|
||
LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code
|
||
LEFT JOIN pickup_snapshots ps ON f.forecast_date = ps.stay_date
|
||
AND f.forecast_type = ps.metric_type
|
||
AND ps.snapshot_date = CURRENT_DATE
|
||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||
"""
|
||
|
||
params = {"from_date": from_date, "to_date": to_date}
|
||
|
||
if metric:
|
||
query += " AND f.forecast_type = :metric"
|
||
params["metric"] = metric
|
||
|
||
query += """
|
||
GROUP BY f.forecast_date, f.forecast_type, fm.metric_name, ps.otb_value, db.budget_value
|
||
ORDER BY f.forecast_date, fm.display_order
|
||
"""
|
||
|
||
result = await db.execute(text(query), params)
|
||
rows = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"date": row.forecast_date,
|
||
"metric_code": row.metric_code,
|
||
"metric_name": row.metric_name,
|
||
"prophet_value": row.prophet_value,
|
||
"prophet_lower": row.prophet_lower,
|
||
"prophet_upper": row.prophet_upper,
|
||
"xgboost_value": row.xgboost_value,
|
||
"pickup_value": row.pickup_value,
|
||
"current_otb": row.current_otb,
|
||
"budget_value": row.budget_value
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@router.get("/weekly")
|
||
async def get_weekly_summary(
|
||
weeks: int = Query(8, description="Number of weeks to forecast"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get weekly summary forecast for the next N weeks.
|
||
Aggregates daily forecasts into weekly totals/averages.
|
||
"""
|
||
from_date = date.today()
|
||
to_date = from_date + timedelta(weeks=weeks)
|
||
|
||
query = """
|
||
WITH weekly_data AS (
|
||
SELECT
|
||
DATE_TRUNC('week', f.forecast_date) as week_start,
|
||
f.forecast_type,
|
||
fm.metric_name,
|
||
fm.unit,
|
||
AVG(f.predicted_value) as avg_value,
|
||
SUM(f.predicted_value) as sum_value,
|
||
AVG(db.budget_value) as avg_budget,
|
||
SUM(db.budget_value) as sum_budget
|
||
FROM forecasts f
|
||
LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code
|
||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||
AND f.model_type = 'prophet'
|
||
GROUP BY DATE_TRUNC('week', f.forecast_date), f.forecast_type, fm.metric_name, fm.unit
|
||
)
|
||
SELECT
|
||
week_start,
|
||
forecast_type,
|
||
metric_name,
|
||
unit,
|
||
CASE
|
||
WHEN unit = 'percent' THEN avg_value
|
||
WHEN unit = 'decimal' THEN avg_value
|
||
ELSE sum_value
|
||
END as forecast_value,
|
||
CASE
|
||
WHEN unit = 'percent' THEN avg_budget
|
||
WHEN unit = 'decimal' THEN avg_budget
|
||
ELSE sum_budget
|
||
END as budget_value
|
||
FROM weekly_data
|
||
ORDER BY week_start, forecast_type
|
||
"""
|
||
|
||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||
rows = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"week_start": row.week_start,
|
||
"metric_code": row.forecast_type,
|
||
"metric_name": row.metric_name,
|
||
"unit": row.unit,
|
||
"forecast_value": row.forecast_value,
|
||
"budget_value": row.budget_value,
|
||
"variance": (row.forecast_value - row.budget_value) if row.budget_value else None,
|
||
"variance_pct": ((row.forecast_value - row.budget_value) / row.budget_value * 100) if row.budget_value and row.budget_value != 0 else None
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@router.get("/comparison")
|
||
async def get_model_comparison(
|
||
from_date: Optional[date] = Query(None),
|
||
to_date: Optional[date] = Query(None),
|
||
metric: str = Query(..., description="Metric code to compare"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get side-by-side comparison of all forecasting models for a specific metric.
|
||
Includes prior year actual for the full date range (both actuals and forecasts).
|
||
Prior year uses 364-day offset (52 weeks) for day-of-week alignment.
|
||
"""
|
||
if from_date is None:
|
||
from_date = date.today()
|
||
if to_date is None:
|
||
to_date = from_date + timedelta(days=28)
|
||
|
||
# Build comparison dict with all dates in range
|
||
# Generate dates in Python to avoid asyncpg parameter issues with generate_series
|
||
comparison = {}
|
||
current_date = from_date
|
||
while current_date <= to_date:
|
||
comparison[str(current_date)] = {
|
||
"date": current_date,
|
||
"actual": None,
|
||
"current_otb": None,
|
||
"budget": None,
|
||
"prior_year_actual": None,
|
||
"prior_year_otb": None,
|
||
"models": {}
|
||
}
|
||
current_date += timedelta(days=1)
|
||
|
||
# Get actuals, OTB, and budget for dates with data
|
||
dates_query = """
|
||
SELECT
|
||
dm.date as forecast_date,
|
||
dm.actual_value,
|
||
ps.otb_value as current_otb,
|
||
db.budget_value
|
||
FROM daily_metrics dm
|
||
LEFT JOIN pickup_snapshots ps ON dm.date = ps.stay_date
|
||
AND ps.metric_type = dm.metric_code
|
||
AND ps.snapshot_date = CURRENT_DATE
|
||
LEFT JOIN daily_budgets db ON dm.date = db.date AND db.budget_type = dm.metric_code
|
||
WHERE dm.date BETWEEN :from_date AND :to_date
|
||
AND dm.metric_code = :metric
|
||
ORDER BY dm.date
|
||
"""
|
||
|
||
dates_result = await db.execute(text(dates_query), {
|
||
"from_date": from_date,
|
||
"to_date": to_date,
|
||
"metric": metric
|
||
})
|
||
date_rows = dates_result.fetchall()
|
||
|
||
# Update comparison dict with actual data
|
||
for row in date_rows:
|
||
date_str = str(row.forecast_date)
|
||
if date_str in comparison:
|
||
# Use 'is not None' - 0 is valid data
|
||
comparison[date_str]["actual"] = float(row.actual_value) if row.actual_value is not None else None
|
||
comparison[date_str]["current_otb"] = float(row.current_otb) if row.current_otb is not None else None
|
||
comparison[date_str]["budget"] = float(row.budget_value) if row.budget_value is not None else None
|
||
|
||
# Get prior year actuals for ALL dates in the range
|
||
# Calculate prior year date range in Python (364 days = 52 weeks for DOW alignment)
|
||
prior_from = from_date - timedelta(days=364)
|
||
prior_to = to_date - timedelta(days=364)
|
||
|
||
prior_year_query = """
|
||
SELECT
|
||
dm.date as prior_date,
|
||
dm.actual_value as prior_year_actual
|
||
FROM daily_metrics dm
|
||
WHERE dm.date BETWEEN :prior_from AND :prior_to
|
||
AND dm.metric_code = :metric
|
||
"""
|
||
|
||
prior_result = await db.execute(text(prior_year_query), {
|
||
"prior_from": prior_from,
|
||
"prior_to": prior_to,
|
||
"metric": metric
|
||
})
|
||
prior_rows = prior_result.fetchall()
|
||
|
||
# Map prior year dates to current year dates (+364 days)
|
||
for row in prior_rows:
|
||
target_date = row.prior_date + timedelta(days=364)
|
||
date_str = str(target_date)
|
||
if date_str in comparison:
|
||
comparison[date_str]["prior_year_actual"] = float(row.prior_year_actual) if row.prior_year_actual is not None else None
|
||
|
||
# Get OTB, prior year OTB, and budget for future dates
|
||
future_data_query = """
|
||
SELECT
|
||
ps.stay_date as forecast_date,
|
||
ps.otb_value as current_otb,
|
||
ps.prior_year_otb,
|
||
ps.prior_year_final,
|
||
db.budget_value
|
||
FROM pickup_snapshots ps
|
||
LEFT JOIN daily_budgets db ON ps.stay_date = db.date AND db.budget_type = ps.metric_type
|
||
WHERE ps.stay_date BETWEEN :from_date AND :to_date
|
||
AND ps.metric_type = :metric
|
||
AND ps.snapshot_date = CURRENT_DATE
|
||
"""
|
||
|
||
future_result = await db.execute(text(future_data_query), {
|
||
"from_date": from_date,
|
||
"to_date": to_date,
|
||
"metric": metric
|
||
})
|
||
future_rows = future_result.fetchall()
|
||
|
||
for row in future_rows:
|
||
date_str = str(row.forecast_date)
|
||
if date_str in comparison:
|
||
if comparison[date_str]["current_otb"] is None:
|
||
comparison[date_str]["current_otb"] = float(row.current_otb) if row.current_otb is not None else None
|
||
if comparison[date_str]["budget"] is None:
|
||
comparison[date_str]["budget"] = float(row.budget_value) if row.budget_value is not None else None
|
||
# Add prior year OTB for pace comparison (0 is valid - means no bookings at that lead time)
|
||
comparison[date_str]["prior_year_otb"] = float(row.prior_year_otb) if row.prior_year_otb is not None else None
|
||
# Prior year final is the actual from 52 weeks ago
|
||
if comparison[date_str]["prior_year_actual"] is None and row.prior_year_final is not None:
|
||
comparison[date_str]["prior_year_actual"] = float(row.prior_year_final)
|
||
|
||
# Now get forecasts to overlay
|
||
forecasts_query = """
|
||
SELECT
|
||
f.forecast_date,
|
||
f.model_type,
|
||
f.predicted_value,
|
||
f.lower_bound,
|
||
f.upper_bound
|
||
FROM forecasts f
|
||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||
AND f.forecast_type = :metric
|
||
ORDER BY f.forecast_date, f.model_type
|
||
"""
|
||
|
||
forecasts_result = await db.execute(text(forecasts_query), {
|
||
"from_date": from_date,
|
||
"to_date": to_date,
|
||
"metric": metric
|
||
})
|
||
forecast_rows = forecasts_result.fetchall()
|
||
|
||
for row in forecast_rows:
|
||
date_str = str(row.forecast_date)
|
||
if date_str in comparison:
|
||
comparison[date_str]["models"][row.model_type] = {
|
||
"value": float(row.predicted_value) if row.predicted_value else None,
|
||
"lower": float(row.lower_bound) if row.lower_bound else None,
|
||
"upper": float(row.upper_bound) if row.upper_bound else None
|
||
}
|
||
|
||
return list(comparison.values())
|
||
|
||
|
||
async def _run_forecast_in_background(
|
||
horizon_days: int,
|
||
start_days: int,
|
||
models: List[str],
|
||
triggered_by: str
|
||
):
|
||
"""Background task to run forecast generation"""
|
||
from jobs.forecast_daily import run_daily_forecast
|
||
await run_daily_forecast(
|
||
horizon_days=horizon_days,
|
||
start_days=start_days,
|
||
models=models,
|
||
triggered_by=triggered_by
|
||
)
|
||
|
||
|
||
@router.post("/regenerate")
|
||
async def regenerate_forecasts(
|
||
background_tasks: BackgroundTasks,
|
||
from_date: Optional[date] = Query(None),
|
||
to_date: Optional[date] = Query(None),
|
||
models: Optional[List[str]] = Query(None, description="Models to run: prophet, xgboost, pickup, catboost"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Force regenerate forecasts for a date range.
|
||
Triggers an immediate forecast run outside the schedule.
|
||
"""
|
||
if from_date is None:
|
||
from_date = date.today()
|
||
if to_date is None:
|
||
to_date = from_date + timedelta(days=14)
|
||
|
||
start_days = (from_date - date.today()).days
|
||
horizon_days = (to_date - date.today()).days
|
||
models_to_run = models or ['prophet', 'xgboost', 'pickup', 'catboost']
|
||
|
||
# Run forecast in background
|
||
background_tasks.add_task(
|
||
_run_forecast_in_background,
|
||
horizon_days=horizon_days,
|
||
start_days=start_days,
|
||
models=models_to_run,
|
||
triggered_by=f"api:manual:{current_user.get('username', 'unknown')}"
|
||
)
|
||
|
||
return {
|
||
"status": "triggered",
|
||
"from_date": from_date,
|
||
"to_date": to_date,
|
||
"models": models_to_run,
|
||
"message": "Forecast regeneration started in background"
|
||
}
|
||
|
||
|
||
@router.get("/metrics")
|
||
async def get_forecast_metrics(
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get list of all available forecast metrics with their configuration.
|
||
"""
|
||
query = """
|
||
SELECT
|
||
metric_code,
|
||
metric_name,
|
||
category,
|
||
unit,
|
||
use_prophet,
|
||
use_xgboost,
|
||
use_pickup,
|
||
is_derived,
|
||
display_order,
|
||
show_in_dashboard,
|
||
decimal_places
|
||
FROM forecast_metrics
|
||
WHERE is_active = TRUE
|
||
ORDER BY display_order
|
||
"""
|
||
|
||
result = await db.execute(text(query))
|
||
rows = result.fetchall()
|
||
|
||
return [
|
||
{
|
||
"metric_code": row.metric_code,
|
||
"metric_name": row.metric_name,
|
||
"category": row.category,
|
||
"unit": row.unit,
|
||
"models": {
|
||
"prophet": row.use_prophet,
|
||
"xgboost": row.use_xgboost,
|
||
"pickup": row.use_pickup
|
||
},
|
||
"is_derived": row.is_derived,
|
||
"display_order": row.display_order,
|
||
"show_in_dashboard": row.show_in_dashboard,
|
||
"decimal_places": row.decimal_places
|
||
}
|
||
for row in rows
|
||
]
|
||
|
||
|
||
# ============================================
|
||
# LIVE PREVIEW ENDPOINTS (No Logging)
|
||
# ============================================
|
||
|
||
# ============================================
|
||
# LIVE PROPHET ENDPOINT
|
||
# ============================================
|
||
|
||
class ProphetDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
current_otb: Optional[float]
|
||
prior_year_otb: Optional[float]
|
||
forecast: Optional[float]
|
||
forecast_lower: Optional[float]
|
||
forecast_upper: Optional[float]
|
||
prior_year_final: Optional[float]
|
||
|
||
|
||
class ProphetSummary(BaseModel):
|
||
otb_total: float
|
||
prior_otb_total: float
|
||
forecast_total: float
|
||
prior_final_total: float
|
||
days_count: int
|
||
days_forecasting_more: int
|
||
days_forecasting_less: int
|
||
|
||
|
||
class ProphetResponse(BaseModel):
|
||
data: List[ProphetDataPoint]
|
||
summary: ProphetSummary
|
||
|
||
|
||
@router.get("/prophet-preview", response_model=ProphetResponse)
|
||
async def get_prophet_preview(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("occupancy", description="Metric: occupancy or rooms"),
|
||
perception_date: Optional[str] = Query(None, description="Optional: Generate forecast as if it was this date (YYYY-MM-DD) for backtesting"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Live forecast using Prophet model.
|
||
Trains on historical d0 (final) values and forecasts future dates.
|
||
No logging or persistence - pure read-only preview.
|
||
|
||
If perception_date is provided, generates forecast as if it was that date,
|
||
training only on data available at that time (for backtesting).
|
||
"""
|
||
from datetime import datetime
|
||
from prophet import Prophet
|
||
import pandas as pd
|
||
import warnings
|
||
warnings.filterwarnings('ignore')
|
||
|
||
# Validate dates
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
# Use perception_date if provided, otherwise use actual today
|
||
actual_today = date.today()
|
||
if perception_date:
|
||
try:
|
||
today = datetime.strptime(perception_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid perception_date format. Use YYYY-MM-DD")
|
||
else:
|
||
today = actual_today
|
||
|
||
is_backtest = perception_date is not None
|
||
|
||
# Get default bookable cap (used as fallback for dates without specific data)
|
||
default_bookable_cap = await get_bookable_cap(db)
|
||
|
||
# Get metric column and query parts
|
||
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
|
||
|
||
# Get historical data for Prophet training (past 2 years)
|
||
history_start = today - timedelta(days=730)
|
||
history_query = f"""
|
||
SELECT s.date as ds, {col_expr} as y
|
||
{from_clause}
|
||
WHERE s.date >= :history_start
|
||
AND s.date < :today
|
||
AND {col_expr} IS NOT NULL
|
||
ORDER BY s.date
|
||
"""
|
||
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
|
||
history_rows = history_result.fetchall()
|
||
|
||
if len(history_rows) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient historical data for Prophet model")
|
||
|
||
# Build training dataframe
|
||
df = pd.DataFrame([{"ds": row.ds, "y": float(row.y) if row.y is not None else 0} for row in history_rows])
|
||
|
||
# Set floor/cap based on metric type
|
||
if is_pct_metric:
|
||
# Percentage metrics (occupancy)
|
||
training_cap = 100
|
||
elif metric == 'rooms':
|
||
# Room counts - cap at bookable rooms
|
||
training_cap = default_bookable_cap
|
||
elif metric == 'guests':
|
||
# Guests can exceed rooms (multiple per room) - use historical max * 1.5
|
||
training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else default_bookable_cap * 3
|
||
else:
|
||
# Revenue/rate metrics - use percentile-based cap
|
||
training_cap = df["y"].quantile(0.99) * 1.5 if len(df) > 0 and df["y"].quantile(0.99) > 0 else 10000
|
||
|
||
df["floor"] = 0
|
||
df["cap"] = training_cap
|
||
|
||
# Train Prophet model with logistic growth (respects floor/cap)
|
||
model = Prophet(
|
||
growth='logistic',
|
||
yearly_seasonality=True,
|
||
weekly_seasonality=True,
|
||
daily_seasonality=False,
|
||
interval_width=0.8,
|
||
changepoint_prior_scale=0.05
|
||
)
|
||
|
||
# Add UK holidays
|
||
model.add_country_holidays(country_name='UK')
|
||
|
||
# Add custom special dates from settings
|
||
try:
|
||
from api.special_dates import get_special_dates_for_prophet
|
||
# Get special dates for training period + forecast period
|
||
min_year = history_start.year
|
||
max_year = end.year + 1
|
||
custom_holidays = await get_special_dates_for_prophet(db, min_year, max_year)
|
||
|
||
if custom_holidays:
|
||
# Create holidays dataframe for Prophet
|
||
holidays_df = pd.DataFrame(custom_holidays)
|
||
# Group by holiday name and add lower/upper windows
|
||
for holiday_name in holidays_df['holiday'].unique():
|
||
holiday_dates = holidays_df[holidays_df['holiday'] == holiday_name][['ds', 'holiday']]
|
||
holiday_dates = holiday_dates.copy()
|
||
holiday_dates['lower_window'] = 0
|
||
holiday_dates['upper_window'] = 0
|
||
model.holidays = pd.concat([model.holidays, holiday_dates]) if model.holidays is not None else holiday_dates
|
||
except Exception as e:
|
||
# Log but don't fail if special dates can't be loaded
|
||
import logging
|
||
logging.warning(f"Could not load special dates for Prophet: {e}")
|
||
|
||
model.fit(df)
|
||
|
||
# Create future dataframe for forecast period
|
||
future_dates = []
|
||
current_date = start
|
||
while current_date <= end:
|
||
if (current_date - today).days >= 0:
|
||
future_dates.append({"ds": current_date})
|
||
current_date += timedelta(days=1)
|
||
|
||
if not future_dates:
|
||
return ProphetResponse(
|
||
data=[],
|
||
summary=ProphetSummary(
|
||
otb_total=0,
|
||
forecast_total=0,
|
||
prior_final_total=0,
|
||
days_count=0
|
||
)
|
||
)
|
||
|
||
future_df = pd.DataFrame(future_dates)
|
||
|
||
# Add floor/cap for logistic growth predictions (must match training cap)
|
||
future_df["floor"] = 0
|
||
future_df["cap"] = training_cap
|
||
|
||
forecast = model.predict(future_df)
|
||
|
||
# Get current OTB and prior year data for each date
|
||
data_points = []
|
||
otb_total = 0.0
|
||
prior_otb_total = 0.0
|
||
forecast_total = 0.0
|
||
prior_final_total = 0.0
|
||
days_forecasting_more = 0
|
||
days_forecasting_less = 0
|
||
|
||
for _, row in forecast.iterrows():
|
||
forecast_date = row["ds"].date()
|
||
lead_days = (forecast_date - today).days
|
||
lead_col = get_lead_time_column(lead_days)
|
||
prior_year_date = forecast_date - timedelta(days=364)
|
||
day_of_week = forecast_date.strftime("%a")
|
||
|
||
# OTB only applies to room-based metrics (occupancy, rooms, guests)
|
||
is_room_based = metric in ('occupancy', 'rooms')
|
||
|
||
# Get current OTB (only for room-based metrics)
|
||
current_otb = None
|
||
prior_otb = None
|
||
if is_room_based:
|
||
if is_backtest:
|
||
# In backtest mode, get "current" OTB from booking_pace at that lead time
|
||
current_otb_query = text(f"""
|
||
SELECT {lead_col} as current_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_otb_query, {"arrival_date": forecast_date})
|
||
current_row = current_result.fetchone()
|
||
else:
|
||
# Normal mode: get current OTB from bookings_stats
|
||
current_query = text("""
|
||
SELECT booking_count as current_otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
|
||
current_row = current_result.fetchone()
|
||
|
||
# Get prior year OTB from booking_pace
|
||
prior_year_for_otb = forecast_date - timedelta(days=364)
|
||
prior_otb_query = text(f"""
|
||
SELECT {lead_col} as prior_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :prior_date
|
||
""")
|
||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||
prior_otb_row = prior_otb_result.fetchone()
|
||
|
||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None
|
||
|
||
# Get prior year final using metric mapping
|
||
prior_query = f"""
|
||
SELECT {col_expr} as prior_final
|
||
{from_clause}
|
||
WHERE s.date = :prior_date
|
||
"""
|
||
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
|
||
prior_row = prior_result.fetchone()
|
||
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
|
||
|
||
# Get per-date bookable cap for room-based metrics
|
||
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
|
||
|
||
# Convert to occupancy if needed
|
||
if metric == "occupancy" and date_bookable_cap > 0:
|
||
if current_otb is not None:
|
||
current_otb = (current_otb / date_bookable_cap) * 100
|
||
if prior_otb is not None:
|
||
prior_otb = (prior_otb / date_bookable_cap) * 100
|
||
|
||
# Get Prophet forecast values
|
||
yhat = row["yhat"]
|
||
yhat_lower = row["yhat_lower"]
|
||
yhat_upper = row["yhat_upper"]
|
||
|
||
# Cap at max capacity based on metric type (uses per-date bookable cap)
|
||
if is_pct_metric:
|
||
yhat = min(yhat, 100.0)
|
||
yhat_upper = min(yhat_upper, 100.0)
|
||
elif metric == 'rooms':
|
||
yhat = min(yhat, float(date_bookable_cap))
|
||
yhat_upper = min(yhat_upper, float(date_bookable_cap))
|
||
# Guests and revenue/rate metrics don't have a hard cap
|
||
|
||
# Floor forecast to current OTB if we have it (room-based metrics only)
|
||
# But never exceed the bookable capacity (e.g., closed/maintenance periods)
|
||
if is_room_based and current_otb is not None and yhat < current_otb:
|
||
yhat = min(current_otb, float(date_bookable_cap))
|
||
yhat_lower = min(current_otb, float(date_bookable_cap))
|
||
|
||
if current_otb is not None:
|
||
otb_total += current_otb
|
||
if prior_otb is not None:
|
||
prior_otb_total += prior_otb
|
||
forecast_total += yhat
|
||
if prior_final is not None:
|
||
prior_final_total += prior_final
|
||
# Count days forecasting more/less vs prior year final
|
||
if yhat > prior_final:
|
||
days_forecasting_more += 1
|
||
elif yhat < prior_final:
|
||
days_forecasting_less += 1
|
||
|
||
data_points.append(ProphetDataPoint(
|
||
date=str(forecast_date),
|
||
day_of_week=day_of_week,
|
||
current_otb=round(current_otb, 1) if current_otb is not None else None,
|
||
prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None,
|
||
forecast=round(yhat, 1),
|
||
forecast_lower=round(yhat_lower, 1),
|
||
forecast_upper=round(yhat_upper, 1),
|
||
prior_year_final=round(prior_final, 1) if prior_final is not None else None
|
||
))
|
||
|
||
# For occupancy (percentage), show averages; for rooms/guests (counts), show sums
|
||
days_count = len(data_points)
|
||
if metric == "occupancy" and days_count > 0:
|
||
return ProphetResponse(
|
||
data=data_points,
|
||
summary=ProphetSummary(
|
||
otb_total=round(otb_total / days_count, 1),
|
||
prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0,
|
||
forecast_total=round(forecast_total / days_count, 1),
|
||
prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0,
|
||
days_count=days_count,
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less
|
||
)
|
||
)
|
||
else:
|
||
return ProphetResponse(
|
||
data=data_points,
|
||
summary=ProphetSummary(
|
||
otb_total=round(otb_total, 1),
|
||
prior_otb_total=round(prior_otb_total, 1),
|
||
forecast_total=round(forecast_total, 1),
|
||
prior_final_total=round(prior_final_total, 1),
|
||
days_count=days_count,
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less
|
||
)
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# LIVE XGBOOST ENDPOINT
|
||
# ============================================
|
||
|
||
class XGBoostDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
current_otb: Optional[float]
|
||
prior_year_otb: Optional[float]
|
||
forecast: Optional[float]
|
||
prior_year_final: Optional[float]
|
||
|
||
|
||
class XGBoostSummary(BaseModel):
|
||
otb_total: float
|
||
prior_otb_total: float
|
||
forecast_total: float
|
||
prior_final_total: float
|
||
days_count: int
|
||
days_forecasting_more: int
|
||
days_forecasting_less: int
|
||
|
||
|
||
class XGBoostResponse(BaseModel):
|
||
data: List[XGBoostDataPoint]
|
||
summary: XGBoostSummary
|
||
|
||
|
||
@router.get("/xgboost-preview", response_model=XGBoostResponse)
|
||
async def get_xgboost_preview(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("occupancy", description="Metric: occupancy or rooms"),
|
||
perception_date: Optional[str] = Query(None, description="Optional: Generate forecast as if it was this date (YYYY-MM-DD) for backtesting"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Live forecast using XGBoost model.
|
||
Trains on historical d0 (final) values and forecasts future dates.
|
||
Uses lag features from prior year same DOW.
|
||
No logging or persistence - pure read-only preview.
|
||
|
||
If perception_date is provided, generates forecast as if it was that date,
|
||
training only on data available at that time (for backtesting).
|
||
"""
|
||
from datetime import datetime
|
||
import pandas as pd
|
||
import numpy as np
|
||
from xgboost import XGBRegressor
|
||
import warnings
|
||
warnings.filterwarnings('ignore')
|
||
|
||
# Validate dates
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
# Use perception_date if provided, otherwise use actual today
|
||
actual_today = date.today()
|
||
if perception_date:
|
||
try:
|
||
today = datetime.strptime(perception_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid perception_date format. Use YYYY-MM-DD")
|
||
else:
|
||
today = actual_today
|
||
|
||
is_backtest = perception_date is not None
|
||
|
||
# Get default bookable cap (used as fallback for dates without specific data)
|
||
default_bookable_cap = await get_bookable_cap(db)
|
||
|
||
# Get metric column and query parts
|
||
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
|
||
is_room_based = metric in ('occupancy', 'rooms')
|
||
|
||
# Get historical data for XGBoost training (past 2 years)
|
||
history_start = today - timedelta(days=730)
|
||
|
||
# Lead times to train on (key intervals) - only used for room-based metrics
|
||
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
|
||
|
||
# Get final values (and pace data for room-based metrics)
|
||
if is_room_based:
|
||
history_result = await db.execute(text("""
|
||
SELECT s.date as ds, s.booking_count as final,
|
||
p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30
|
||
FROM newbook_bookings_stats s
|
||
LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date
|
||
WHERE s.date >= :history_start
|
||
AND s.date < :today
|
||
AND s.booking_count IS NOT NULL
|
||
ORDER BY s.date
|
||
"""), {"history_start": history_start, "today": today})
|
||
else:
|
||
# Non-room metrics: get values without pace join
|
||
history_query = f"""
|
||
SELECT s.date as ds, {col_expr} as final
|
||
{from_clause}
|
||
WHERE s.date >= :history_start
|
||
AND s.date < :today
|
||
AND {col_expr} IS NOT NULL
|
||
ORDER BY s.date
|
||
"""
|
||
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
|
||
history_rows = history_result.fetchall()
|
||
|
||
if len(history_rows) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient historical data for XGBoost model")
|
||
|
||
# Load special dates for feature
|
||
special_date_set = set()
|
||
try:
|
||
special_dates_result = await db.execute(text(
|
||
"SELECT * FROM special_dates WHERE is_active = TRUE"
|
||
))
|
||
special_dates_rows = special_dates_result.fetchall()
|
||
years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1}
|
||
for row in special_dates_rows:
|
||
sd = {
|
||
'pattern_type': row.pattern_type,
|
||
'fixed_month': row.fixed_month,
|
||
'fixed_day': row.fixed_day,
|
||
'nth_week': row.nth_week,
|
||
'weekday': row.weekday,
|
||
'month': row.month,
|
||
'relative_to_month': row.relative_to_month,
|
||
'relative_to_day': row.relative_to_day,
|
||
'relative_weekday': row.relative_weekday,
|
||
'relative_direction': row.relative_direction,
|
||
'duration_days': row.duration_days,
|
||
'is_recurring': row.is_recurring,
|
||
'one_off_year': row.one_off_year
|
||
}
|
||
for year in years_needed:
|
||
resolved_dates = resolve_special_date(sd, year)
|
||
for d in resolved_dates:
|
||
special_date_set.add(d)
|
||
except Exception:
|
||
pass
|
||
|
||
# Build lookup dicts
|
||
final_by_date = {}
|
||
pace_by_date = {}
|
||
for row in history_rows:
|
||
final_by_date[row.ds] = row.final
|
||
if is_room_based and hasattr(row, 'd0'):
|
||
pace_by_date[row.ds] = {
|
||
0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7,
|
||
14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30
|
||
}
|
||
|
||
# Build training examples
|
||
training_rows = []
|
||
|
||
if is_room_based:
|
||
# Room-based metrics: use pace features (one per date,lead_time combo)
|
||
for row in history_rows:
|
||
ds = row.ds
|
||
final = float(row.final) if row.final else 0
|
||
prior_ds = ds - timedelta(days=364)
|
||
|
||
prior_final = final_by_date.get(prior_ds)
|
||
if prior_final is None:
|
||
continue
|
||
|
||
for lead_time in train_lead_times:
|
||
current_otb = pace_by_date.get(ds, {}).get(lead_time)
|
||
if current_otb is None:
|
||
continue
|
||
|
||
prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time)
|
||
if prior_otb is None:
|
||
prior_otb = 0
|
||
|
||
otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0
|
||
|
||
training_rows.append({
|
||
'ds': ds,
|
||
'y': final,
|
||
'days_out': lead_time,
|
||
'current_otb': float(current_otb),
|
||
'prior_otb_same_lead': float(prior_otb),
|
||
'lag_364': float(prior_final),
|
||
'otb_pct_of_prior_final': otb_pct_of_prior_final
|
||
})
|
||
else:
|
||
# Non-room metrics: use time features only (one per date)
|
||
for row in history_rows:
|
||
ds = row.ds
|
||
final = float(row.final) if row.final else 0
|
||
prior_ds = ds - timedelta(days=364)
|
||
|
||
prior_final = final_by_date.get(prior_ds)
|
||
if prior_final is None:
|
||
prior_final = 0 # Allow training even without prior year for revenue metrics
|
||
|
||
training_rows.append({
|
||
'ds': ds,
|
||
'y': final,
|
||
'lag_364': float(prior_final) if prior_final else 0
|
||
})
|
||
|
||
if len(training_rows) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient data for XGBoost training")
|
||
|
||
df = pd.DataFrame(training_rows)
|
||
df['ds'] = pd.to_datetime(df['ds'])
|
||
|
||
# Convert to occupancy if needed
|
||
if metric == "occupancy" and default_bookable_cap > 0:
|
||
df["y"] = (df["y"] / default_bookable_cap) * 100
|
||
if "current_otb" in df.columns:
|
||
df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100
|
||
if "prior_otb_same_lead" in df.columns:
|
||
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100
|
||
df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100
|
||
|
||
# Create time-based features
|
||
df['day_of_week'] = df['ds'].dt.dayofweek
|
||
df['month'] = df['ds'].dt.month
|
||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
|
||
df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0)
|
||
|
||
df_train = df.dropna()
|
||
|
||
if len(df_train) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient data after creating features")
|
||
|
||
# Define features based on metric type
|
||
if is_room_based:
|
||
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date',
|
||
'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final']
|
||
else:
|
||
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
|
||
|
||
X_train = df_train[feature_cols]
|
||
y_train = df_train['y']
|
||
|
||
# Train XGBoost model
|
||
model = XGBRegressor(
|
||
n_estimators=100,
|
||
max_depth=6,
|
||
learning_rate=0.1,
|
||
objective='reg:squarederror',
|
||
random_state=42,
|
||
n_jobs=-1
|
||
)
|
||
model.fit(X_train, y_train)
|
||
|
||
# Create future dataframe for forecast period
|
||
future_dates = []
|
||
current_date = start
|
||
while current_date <= end:
|
||
if (current_date - today).days >= 0:
|
||
future_dates.append(current_date)
|
||
current_date += timedelta(days=1)
|
||
|
||
if not future_dates:
|
||
return XGBoostResponse(
|
||
data=[],
|
||
summary=XGBoostSummary(
|
||
otb_total=0, prior_otb_total=0, forecast_total=0,
|
||
prior_final_total=0, days_count=0,
|
||
days_forecasting_more=0, days_forecasting_less=0
|
||
)
|
||
)
|
||
|
||
# Get current OTB and prior year data for each date
|
||
data_points = []
|
||
otb_total = 0.0
|
||
prior_otb_total = 0.0
|
||
forecast_total = 0.0
|
||
prior_final_total = 0.0
|
||
days_forecasting_more = 0
|
||
days_forecasting_less = 0
|
||
|
||
for forecast_date in future_dates:
|
||
lead_days = (forecast_date - today).days
|
||
lead_col = get_lead_time_column(lead_days)
|
||
prior_year_date = forecast_date - timedelta(days=364)
|
||
day_of_week = forecast_date.strftime("%a")
|
||
|
||
# Get OTB data only for room-based metrics
|
||
current_otb = None
|
||
prior_otb = None
|
||
|
||
if is_room_based:
|
||
if is_backtest:
|
||
current_otb_query = text(f"""
|
||
SELECT {lead_col} as current_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_otb_query, {"arrival_date": forecast_date})
|
||
current_row = current_result.fetchone()
|
||
else:
|
||
current_query = text("""
|
||
SELECT booking_count as current_otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
|
||
current_row = current_result.fetchone()
|
||
|
||
prior_year_for_otb = forecast_date - timedelta(days=364)
|
||
prior_otb_query = text(f"""
|
||
SELECT {lead_col} as prior_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :prior_date
|
||
""")
|
||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||
prior_otb_row = prior_otb_result.fetchone()
|
||
|
||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None
|
||
|
||
# Get prior year final using metric mapping
|
||
prior_query = f"""
|
||
SELECT {col_expr} as prior_final
|
||
{from_clause}
|
||
WHERE s.date = :prior_date
|
||
"""
|
||
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
|
||
prior_row = prior_result.fetchone()
|
||
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
|
||
|
||
# Get per-date bookable cap for this forecast date
|
||
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
|
||
|
||
# Build features for this date
|
||
forecast_dt = pd.Timestamp(forecast_date)
|
||
lag_364_val = prior_final if prior_final else 0
|
||
|
||
# Convert to occupancy if needed
|
||
if metric == "occupancy" and date_bookable_cap > 0:
|
||
if current_otb is not None:
|
||
current_otb = (current_otb / date_bookable_cap) * 100
|
||
if prior_otb is not None:
|
||
prior_otb = (prior_otb / date_bookable_cap) * 100
|
||
lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0
|
||
|
||
# Build features based on metric type
|
||
if is_room_based:
|
||
prior_otb_same_lead = prior_otb if prior_otb is not None else 0
|
||
current_otb_val = current_otb if current_otb is not None else 0
|
||
otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0
|
||
|
||
features = pd.DataFrame([{
|
||
'day_of_week': forecast_dt.dayofweek,
|
||
'month': forecast_dt.month,
|
||
'week_of_year': forecast_dt.isocalendar().week,
|
||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||
'days_out': lead_days,
|
||
'current_otb': current_otb_val,
|
||
'prior_otb_same_lead': prior_otb_same_lead,
|
||
'lag_364': lag_364_val,
|
||
'otb_pct_of_prior_final': otb_pct_of_prior_final,
|
||
}])
|
||
else:
|
||
features = pd.DataFrame([{
|
||
'day_of_week': forecast_dt.dayofweek,
|
||
'month': forecast_dt.month,
|
||
'week_of_year': forecast_dt.isocalendar().week,
|
||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||
'lag_364': lag_364_val,
|
||
}])
|
||
|
||
# Predict
|
||
yhat = float(model.predict(features)[0])
|
||
|
||
# Cap at max capacity based on metric type (uses per-date bookable cap)
|
||
if is_pct_metric:
|
||
yhat = min(max(yhat, 0), 100.0)
|
||
elif metric == 'rooms':
|
||
yhat = round(min(max(yhat, 0), float(date_bookable_cap)))
|
||
elif metric == 'guests':
|
||
yhat = round(max(yhat, 0))
|
||
else:
|
||
# Revenue/rate metrics: just ensure non-negative
|
||
yhat = max(yhat, 0)
|
||
|
||
# Floor forecast to current OTB (room-based only)
|
||
if is_room_based and current_otb is not None and yhat < current_otb:
|
||
yhat = current_otb
|
||
|
||
if current_otb is not None:
|
||
otb_total += current_otb
|
||
if prior_otb is not None:
|
||
prior_otb_total += prior_otb
|
||
forecast_total += yhat
|
||
if prior_final is not None:
|
||
prior_final_total += prior_final
|
||
if yhat > prior_final:
|
||
days_forecasting_more += 1
|
||
elif yhat < prior_final:
|
||
days_forecasting_less += 1
|
||
|
||
# Round to 1 decimal for occupancy %, whole numbers for room counts
|
||
if metric == "occupancy":
|
||
data_points.append(XGBoostDataPoint(
|
||
date=str(forecast_date),
|
||
day_of_week=day_of_week,
|
||
current_otb=round(current_otb, 1) if current_otb is not None else None,
|
||
prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None,
|
||
forecast=round(yhat, 1),
|
||
prior_year_final=round(prior_final, 1) if prior_final is not None else None
|
||
))
|
||
else:
|
||
data_points.append(XGBoostDataPoint(
|
||
date=str(forecast_date),
|
||
day_of_week=day_of_week,
|
||
current_otb=round(current_otb) if current_otb is not None else None,
|
||
prior_year_otb=round(prior_otb) if prior_otb is not None else None,
|
||
forecast=round_towards_reference(yhat, prior_final),
|
||
prior_year_final=round(prior_final) if prior_final is not None else None
|
||
))
|
||
|
||
# For occupancy (percentage), show averages; for rooms/guests (counts), show sums
|
||
days_count = len(data_points)
|
||
if metric == "occupancy" and days_count > 0:
|
||
return XGBoostResponse(
|
||
data=data_points,
|
||
summary=XGBoostSummary(
|
||
otb_total=round(otb_total / days_count, 1),
|
||
prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0,
|
||
forecast_total=round(forecast_total / days_count, 1),
|
||
prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0,
|
||
days_count=days_count,
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less
|
||
)
|
||
)
|
||
else:
|
||
return XGBoostResponse(
|
||
data=data_points,
|
||
summary=XGBoostSummary(
|
||
otb_total=round(otb_total),
|
||
prior_otb_total=round(prior_otb_total),
|
||
forecast_total=round(forecast_total),
|
||
prior_final_total=round(prior_final_total),
|
||
days_count=days_count,
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less
|
||
)
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# LIVE CHRONOS ENDPOINT
|
||
# ============================================
|
||
# LIVE CATBOOST ENDPOINT
|
||
# ============================================
|
||
|
||
|
||
class CatBoostDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
current_otb: Optional[float] = None
|
||
prior_year_otb: Optional[float] = None
|
||
forecast: Optional[float] = None
|
||
prior_year_final: Optional[float] = None
|
||
|
||
|
||
class CatBoostSummary(BaseModel):
|
||
otb_total: float
|
||
prior_otb_total: float
|
||
forecast_total: float
|
||
prior_final_total: float
|
||
days_count: int
|
||
days_forecasting_more: int
|
||
days_forecasting_less: int
|
||
|
||
|
||
class CatBoostResponse(BaseModel):
|
||
data: List[CatBoostDataPoint]
|
||
summary: CatBoostSummary
|
||
|
||
|
||
@router.get("/catboost-preview", response_model=CatBoostResponse)
|
||
async def get_catboost_preview(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("occupancy", description="Metric: occupancy or room-nights"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Live forecast using CatBoost model.
|
||
Gradient boosting with native categorical feature support.
|
||
Similar to XGBoost but handles categories natively without encoding.
|
||
Uses same features: OTB, prior year, holidays, day-of-week.
|
||
"""
|
||
from datetime import datetime
|
||
import pandas as pd
|
||
import numpy as np
|
||
from catboost import CatBoostRegressor
|
||
import warnings
|
||
warnings.filterwarnings('ignore')
|
||
|
||
# Validate dates
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
today = date.today()
|
||
|
||
# Get default bookable cap
|
||
default_bookable_cap = await get_bookable_cap(db)
|
||
|
||
# Get metric column and query parts
|
||
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
|
||
is_room_based = metric in ('occupancy', 'rooms')
|
||
|
||
# Get historical data (2+ years for YoY features)
|
||
history_start = today - timedelta(days=730)
|
||
|
||
# Lead times to train on (only used for room-based metrics)
|
||
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
|
||
|
||
# Get final values (and pace data for room-based metrics)
|
||
if is_room_based:
|
||
history_result = await db.execute(text("""
|
||
SELECT s.date as ds, s.booking_count as final,
|
||
p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30
|
||
FROM newbook_bookings_stats s
|
||
LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date
|
||
WHERE s.date >= :history_start
|
||
AND s.date < :today
|
||
AND s.booking_count IS NOT NULL
|
||
ORDER BY s.date
|
||
"""), {"history_start": history_start, "today": today})
|
||
else:
|
||
# Non-room metrics: get values without pace join
|
||
history_query = f"""
|
||
SELECT s.date as ds, {col_expr} as final
|
||
{from_clause}
|
||
WHERE s.date >= :history_start
|
||
AND s.date < :today
|
||
AND {col_expr} IS NOT NULL
|
||
ORDER BY s.date
|
||
"""
|
||
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
|
||
history_rows = history_result.fetchall()
|
||
|
||
if len(history_rows) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient historical data for CatBoost model")
|
||
|
||
# Load special dates for feature
|
||
special_date_set = set()
|
||
try:
|
||
special_dates_result = await db.execute(text(
|
||
"SELECT * FROM special_dates WHERE is_active = TRUE"
|
||
))
|
||
special_dates_rows = special_dates_result.fetchall()
|
||
years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1}
|
||
for row in special_dates_rows:
|
||
sd = {
|
||
'pattern_type': row.pattern_type,
|
||
'fixed_month': row.fixed_month,
|
||
'fixed_day': row.fixed_day,
|
||
'nth_week': row.nth_week,
|
||
'weekday': row.weekday,
|
||
'month': row.month,
|
||
'relative_to_month': row.relative_to_month,
|
||
'relative_to_day': row.relative_to_day,
|
||
'relative_weekday': row.relative_weekday,
|
||
'relative_direction': row.relative_direction,
|
||
'duration_days': row.duration_days,
|
||
'is_recurring': row.is_recurring,
|
||
'one_off_year': row.one_off_year
|
||
}
|
||
for year in years_needed:
|
||
resolved_dates = resolve_special_date(sd, year)
|
||
for d in resolved_dates:
|
||
special_date_set.add(d)
|
||
except Exception:
|
||
pass
|
||
|
||
# Build lookup dicts
|
||
final_by_date = {}
|
||
pace_by_date = {}
|
||
for row in history_rows:
|
||
final_by_date[row.ds] = row.final
|
||
if is_room_based and hasattr(row, 'd0'):
|
||
pace_by_date[row.ds] = {
|
||
0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7,
|
||
14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30
|
||
}
|
||
|
||
# Build training examples
|
||
training_rows = []
|
||
|
||
if is_room_based:
|
||
# Room-based metrics: use pace features (one per date,lead_time combo)
|
||
for row in history_rows:
|
||
ds = row.ds
|
||
final = float(row.final) if row.final else 0
|
||
prior_ds = ds - timedelta(days=364)
|
||
|
||
prior_final = final_by_date.get(prior_ds)
|
||
if prior_final is None:
|
||
continue
|
||
|
||
for lead_time in train_lead_times:
|
||
current_otb = pace_by_date.get(ds, {}).get(lead_time)
|
||
if current_otb is None:
|
||
continue
|
||
|
||
prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time)
|
||
if prior_otb is None:
|
||
prior_otb = 0
|
||
|
||
otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0
|
||
|
||
training_rows.append({
|
||
'ds': ds,
|
||
'y': final,
|
||
'days_out': lead_time,
|
||
'current_otb': float(current_otb),
|
||
'prior_otb_same_lead': float(prior_otb),
|
||
'lag_364': float(prior_final),
|
||
'otb_pct_of_prior_final': otb_pct_of_prior_final
|
||
})
|
||
else:
|
||
# Non-room metrics: use time features only (one per date)
|
||
for row in history_rows:
|
||
ds = row.ds
|
||
final = float(row.final) if row.final else 0
|
||
prior_ds = ds - timedelta(days=364)
|
||
|
||
prior_final = final_by_date.get(prior_ds)
|
||
if prior_final is None:
|
||
prior_final = 0 # Allow training even without prior year for revenue metrics
|
||
|
||
training_rows.append({
|
||
'ds': ds,
|
||
'y': final,
|
||
'lag_364': float(prior_final) if prior_final else 0
|
||
})
|
||
|
||
if len(training_rows) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient data for CatBoost training")
|
||
|
||
df = pd.DataFrame(training_rows)
|
||
df['ds'] = pd.to_datetime(df['ds'])
|
||
|
||
# Convert to occupancy if needed
|
||
if metric == "occupancy" and default_bookable_cap > 0:
|
||
df["y"] = (df["y"] / default_bookable_cap) * 100
|
||
if "current_otb" in df.columns:
|
||
df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100
|
||
if "prior_otb_same_lead" in df.columns:
|
||
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100
|
||
df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100
|
||
|
||
# Create features - CatBoost handles categoricals natively
|
||
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical
|
||
df['month'] = df['ds'].dt.month.astype(str) # Categorical
|
||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
|
||
df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0)
|
||
|
||
df_train = df.dropna()
|
||
|
||
if len(df_train) < 30:
|
||
raise HTTPException(status_code=400, detail="Insufficient data after creating features")
|
||
|
||
# Define features based on metric type - categoricals handled natively by CatBoost
|
||
categorical_features = ['day_of_week', 'month']
|
||
if is_room_based:
|
||
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date',
|
||
'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final']
|
||
else:
|
||
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
|
||
feature_cols = categorical_features + numerical_features
|
||
|
||
X_train = df_train[feature_cols]
|
||
y_train = df_train['y']
|
||
|
||
# Train CatBoost model
|
||
model = CatBoostRegressor(
|
||
iterations=150,
|
||
depth=6,
|
||
learning_rate=0.1,
|
||
loss_function='RMSE',
|
||
cat_features=categorical_features,
|
||
verbose=False,
|
||
random_seed=42
|
||
)
|
||
model.fit(X_train, y_train)
|
||
|
||
# Create future dataframe for forecast period
|
||
future_dates = []
|
||
current_date = start
|
||
while current_date <= end:
|
||
if (current_date - today).days >= 0:
|
||
future_dates.append(current_date)
|
||
current_date += timedelta(days=1)
|
||
|
||
if not future_dates:
|
||
return CatBoostResponse(
|
||
data=[],
|
||
summary=CatBoostSummary(
|
||
otb_total=0, prior_otb_total=0, forecast_total=0,
|
||
prior_final_total=0, days_count=0,
|
||
days_forecasting_more=0, days_forecasting_less=0
|
||
)
|
||
)
|
||
|
||
# Generate predictions
|
||
data_points = []
|
||
otb_total = 0.0
|
||
prior_otb_total = 0.0
|
||
forecast_total = 0.0
|
||
prior_final_total = 0.0
|
||
days_forecasting_more = 0
|
||
days_forecasting_less = 0
|
||
|
||
for forecast_date in future_dates:
|
||
lead_days = (forecast_date - today).days
|
||
lead_col = get_lead_time_column(lead_days)
|
||
prior_year_date = forecast_date - timedelta(days=364)
|
||
day_of_week = forecast_date.strftime("%a")
|
||
|
||
# Get OTB data only for room-based metrics
|
||
current_otb = None
|
||
prior_otb = None
|
||
|
||
if is_room_based:
|
||
# Get current OTB
|
||
current_query = text("""
|
||
SELECT booking_count as current_otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
|
||
current_row = current_result.fetchone()
|
||
|
||
# Get prior year OTB
|
||
prior_year_for_otb = forecast_date - timedelta(days=364)
|
||
prior_otb_query = text(f"""
|
||
SELECT {lead_col} as prior_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :prior_date
|
||
""")
|
||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||
prior_otb_row = prior_otb_result.fetchone()
|
||
|
||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None
|
||
|
||
# Get prior year final using metric mapping
|
||
prior_query = f"""
|
||
SELECT {col_expr} as prior_final
|
||
{from_clause}
|
||
WHERE s.date = :prior_date
|
||
"""
|
||
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
|
||
prior_row = prior_result.fetchone()
|
||
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
|
||
|
||
# Get per-date bookable cap
|
||
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
|
||
|
||
forecast_dt = pd.Timestamp(forecast_date)
|
||
lag_364_val = prior_final if prior_final else 0
|
||
|
||
# Convert to occupancy if needed
|
||
if metric == "occupancy" and date_bookable_cap > 0:
|
||
if current_otb is not None:
|
||
current_otb = (current_otb / date_bookable_cap) * 100
|
||
if prior_otb is not None:
|
||
prior_otb = (prior_otb / date_bookable_cap) * 100
|
||
lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0
|
||
|
||
# Build features based on metric type
|
||
if is_room_based:
|
||
prior_otb_same_lead = prior_otb if prior_otb is not None else 0
|
||
current_otb_val = current_otb if current_otb is not None else 0
|
||
otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0
|
||
|
||
features = pd.DataFrame([{
|
||
'day_of_week': str(forecast_dt.dayofweek), # Categorical
|
||
'month': str(forecast_dt.month), # Categorical
|
||
'week_of_year': forecast_dt.isocalendar().week,
|
||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||
'days_out': lead_days,
|
||
'current_otb': current_otb_val,
|
||
'prior_otb_same_lead': prior_otb_same_lead,
|
||
'lag_364': lag_364_val,
|
||
'otb_pct_of_prior_final': otb_pct_of_prior_final,
|
||
}])
|
||
else:
|
||
features = pd.DataFrame([{
|
||
'day_of_week': str(forecast_dt.dayofweek), # Categorical
|
||
'month': str(forecast_dt.month), # Categorical
|
||
'week_of_year': forecast_dt.isocalendar().week,
|
||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||
'lag_364': lag_364_val,
|
||
}])
|
||
|
||
# Predict
|
||
yhat = float(model.predict(features)[0])
|
||
|
||
# Cap at max capacity based on metric type (uses per-date bookable cap)
|
||
if is_pct_metric:
|
||
yhat = min(max(yhat, 0), 100.0)
|
||
elif metric == 'rooms':
|
||
yhat = round(min(max(yhat, 0), float(date_bookable_cap)))
|
||
elif metric == 'guests':
|
||
yhat = round(max(yhat, 0))
|
||
else:
|
||
# Revenue/rate metrics: just ensure non-negative
|
||
yhat = max(yhat, 0)
|
||
|
||
# Floor forecast to current OTB (room-based only)
|
||
if is_room_based and current_otb is not None and yhat < current_otb:
|
||
yhat = current_otb
|
||
|
||
if current_otb is not None:
|
||
otb_total += current_otb
|
||
if prior_otb is not None:
|
||
prior_otb_total += prior_otb
|
||
forecast_total += yhat
|
||
if prior_final is not None:
|
||
prior_final_total += prior_final
|
||
if yhat > prior_final:
|
||
days_forecasting_more += 1
|
||
elif yhat < prior_final:
|
||
days_forecasting_less += 1
|
||
|
||
if metric == "occupancy":
|
||
data_points.append(CatBoostDataPoint(
|
||
date=str(forecast_date),
|
||
day_of_week=day_of_week,
|
||
current_otb=round(current_otb, 1) if current_otb is not None else None,
|
||
prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None,
|
||
forecast=round(yhat, 1),
|
||
prior_year_final=round(prior_final, 1) if prior_final is not None else None
|
||
))
|
||
else:
|
||
data_points.append(CatBoostDataPoint(
|
||
date=str(forecast_date),
|
||
day_of_week=day_of_week,
|
||
current_otb=round(current_otb) if current_otb is not None else None,
|
||
prior_year_otb=round(prior_otb) if prior_otb is not None else None,
|
||
forecast=round_towards_reference(yhat, prior_final),
|
||
prior_year_final=round(prior_final) if prior_final is not None else None
|
||
))
|
||
|
||
days_count = len(data_points)
|
||
if metric == "occupancy" and days_count > 0:
|
||
return CatBoostResponse(
|
||
data=data_points,
|
||
summary=CatBoostSummary(
|
||
otb_total=round(otb_total / days_count, 1),
|
||
prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0,
|
||
forecast_total=round(forecast_total / days_count, 1),
|
||
prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0,
|
||
days_count=days_count,
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less
|
||
)
|
||
)
|
||
else:
|
||
return CatBoostResponse(
|
||
data=data_points,
|
||
summary=CatBoostSummary(
|
||
otb_total=round(otb_total),
|
||
prior_otb_total=round(prior_otb_total),
|
||
forecast_total=round(forecast_total),
|
||
prior_final_total=round(prior_final_total),
|
||
days_count=days_count,
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less
|
||
)
|
||
)
|
||
|
||
|
||
class PreviewDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
lead_days: int
|
||
current_otb: Optional[float]
|
||
prior_year_date: str
|
||
prior_year_dow: str
|
||
prior_year_otb: Optional[float]
|
||
prior_year_final: Optional[float]
|
||
expected_pickup: Optional[float]
|
||
forecast: Optional[float]
|
||
pace_vs_prior_pct: Optional[float]
|
||
|
||
|
||
class PreviewSummary(BaseModel):
|
||
otb_total: float
|
||
forecast_total: float
|
||
prior_otb_total: float
|
||
prior_final_total: float
|
||
pace_pct: Optional[float]
|
||
days_count: int
|
||
|
||
|
||
class PreviewResponse(BaseModel):
|
||
data: List[PreviewDataPoint]
|
||
summary: PreviewSummary
|
||
|
||
|
||
|
||
def get_lead_time_column(lead_days: int) -> str:
|
||
"""
|
||
Map lead days to the appropriate column in newbook_booking_pace.
|
||
Columns: d365, d330, d300, d270, d240, d210 (monthly)
|
||
d177-d37 in 7-day intervals (weekly)
|
||
d30-d0 (daily)
|
||
"""
|
||
if lead_days <= 0:
|
||
return "d0"
|
||
elif lead_days <= 30:
|
||
return f"d{lead_days}"
|
||
elif lead_days <= 177:
|
||
# Weekly intervals - find nearest column
|
||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||
for col in weekly_cols:
|
||
if lead_days <= col:
|
||
return f"d{col}"
|
||
return "d177"
|
||
else:
|
||
# Monthly intervals
|
||
monthly_cols = [210, 240, 270, 300, 330, 365]
|
||
for col in monthly_cols:
|
||
if lead_days <= col:
|
||
return f"d{col}"
|
||
return "d365"
|
||
|
||
|
||
# ============================================
|
||
# LIVE BLENDED FORECAST ENDPOINT
|
||
# ============================================
|
||
|
||
REVENUE_METRICS = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
|
||
MODEL_WEIGHT = 0.6 # 60% from accuracy-weighted models
|
||
BUDGET_PRIOR_WEIGHT = 0.4 # 40% from budget (revenue) or prior year (other)
|
||
|
||
|
||
class BlendedDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
current_otb: Optional[float]
|
||
prior_year_otb: Optional[float]
|
||
blended_forecast: Optional[float]
|
||
prophet_forecast: Optional[float]
|
||
xgboost_forecast: Optional[float]
|
||
catboost_forecast: Optional[float]
|
||
budget_or_prior: Optional[float]
|
||
prior_year_final: Optional[float]
|
||
|
||
|
||
class BlendedSummary(BaseModel):
|
||
otb_total: float
|
||
prior_otb_total: float
|
||
forecast_total: float
|
||
prior_final_total: float
|
||
days_count: int
|
||
days_forecasting_more: int
|
||
days_forecasting_less: int
|
||
prophet_weight: float
|
||
xgboost_weight: float
|
||
catboost_weight: float
|
||
|
||
|
||
class BlendedResponse(BaseModel):
|
||
data: List[BlendedDataPoint]
|
||
summary: BlendedSummary
|
||
|
||
|
||
@router.get("/blended-preview", response_model=BlendedResponse)
|
||
async def get_blended_preview(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("occupancy", description="Metric: occupancy, rooms, net_accom, etc."),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Live blended forecast combining multiple models with accuracy-based weighting.
|
||
|
||
For revenue metrics (net_accom, net_dry, net_wet):
|
||
- 60% accuracy-weighted models (Prophet/XGBoost/CatBoost)
|
||
- 40% budget target
|
||
|
||
For other metrics (occupancy, rooms, guests, etc.):
|
||
- 60% accuracy-weighted models
|
||
- 40% prior year DOW-aligned actuals
|
||
|
||
Model weights are calculated from recent accuracy (inverse MAPE).
|
||
"""
|
||
from datetime import datetime
|
||
|
||
# Validate dates
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
today = date.today()
|
||
is_revenue_metric = metric in REVENUE_METRICS
|
||
|
||
# Get accuracy scores for model weighting (from last 90 days)
|
||
accuracy_query = """
|
||
SELECT
|
||
AVG(ABS(prophet_pct_error)) as prophet_mape,
|
||
AVG(ABS(xgboost_pct_error)) as xgboost_mape,
|
||
AVG(ABS(catboost_pct_error)) as catboost_mape
|
||
FROM actual_vs_forecast
|
||
WHERE date >= CURRENT_DATE - INTERVAL '90 days'
|
||
AND date < CURRENT_DATE
|
||
AND metric_type = :metric
|
||
AND actual_value IS NOT NULL
|
||
"""
|
||
accuracy_result = await db.execute(text(accuracy_query), {"metric": metric})
|
||
accuracy_row = accuracy_result.fetchone()
|
||
|
||
# Calculate inverse-MAPE weights (lower MAPE = higher weight)
|
||
# Use default equal weights if no accuracy data
|
||
if accuracy_row and accuracy_row.prophet_mape and accuracy_row.xgboost_mape and accuracy_row.catboost_mape:
|
||
prophet_mape = float(accuracy_row.prophet_mape) or 10
|
||
xgboost_mape = float(accuracy_row.xgboost_mape) or 10
|
||
catboost_mape = float(accuracy_row.catboost_mape) or 10
|
||
|
||
# Inverse weights (1/MAPE), normalized
|
||
inv_prophet = 1 / max(prophet_mape, 0.1)
|
||
inv_xgboost = 1 / max(xgboost_mape, 0.1)
|
||
inv_catboost = 1 / max(catboost_mape, 0.1)
|
||
total_inv = inv_prophet + inv_xgboost + inv_catboost
|
||
|
||
prophet_weight = inv_prophet / total_inv
|
||
xgboost_weight = inv_xgboost / total_inv
|
||
catboost_weight = inv_catboost / total_inv
|
||
else:
|
||
# Equal weights if no accuracy data
|
||
prophet_weight = 1/3
|
||
xgboost_weight = 1/3
|
||
catboost_weight = 1/3
|
||
|
||
# Get forecasts from stored forecasts table
|
||
forecasts_query = """
|
||
SELECT
|
||
forecast_date,
|
||
model_type,
|
||
predicted_value
|
||
FROM forecasts
|
||
WHERE forecast_date BETWEEN :start AND :end
|
||
AND forecast_type = :metric
|
||
ORDER BY forecast_date
|
||
"""
|
||
forecasts_result = await db.execute(text(forecasts_query), {
|
||
"start": start, "end": end, "metric": metric
|
||
})
|
||
forecasts_rows = forecasts_result.fetchall()
|
||
|
||
# Build forecasts dict by date and model
|
||
forecasts_by_date = {}
|
||
for row in forecasts_rows:
|
||
date_str = str(row.forecast_date)
|
||
if date_str not in forecasts_by_date:
|
||
forecasts_by_date[date_str] = {}
|
||
forecasts_by_date[date_str][row.model_type] = float(row.predicted_value) if row.predicted_value else None
|
||
|
||
# Try to get current OTB from pickup_snapshots (may not exist)
|
||
otb_by_date = {}
|
||
try:
|
||
otb_query = """
|
||
SELECT
|
||
stay_date,
|
||
otb_value,
|
||
prior_year_otb,
|
||
prior_year_final
|
||
FROM pickup_snapshots
|
||
WHERE stay_date BETWEEN :start AND :end
|
||
AND metric_type = :metric
|
||
AND snapshot_date = CURRENT_DATE
|
||
"""
|
||
otb_result = await db.execute(text(otb_query), {
|
||
"start": start, "end": end, "metric": metric
|
||
})
|
||
otb_rows = otb_result.fetchall()
|
||
otb_by_date = {str(row.stay_date): row for row in otb_rows}
|
||
except Exception:
|
||
# Table doesn't exist or query failed - continue without OTB data
|
||
pass
|
||
|
||
# Get budget OR prior year data depending on metric type
|
||
budget_prior_by_date = {}
|
||
if is_revenue_metric:
|
||
# Get daily budget values
|
||
if metric == 'total_rev':
|
||
# For total_rev, sum all three department budgets
|
||
budget_query = """
|
||
SELECT date, SUM(budget_value) as budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start AND :end
|
||
AND budget_type IN ('net_accom', 'net_dry', 'net_wet')
|
||
GROUP BY date
|
||
"""
|
||
budget_result = await db.execute(text(budget_query), {
|
||
"start": start, "end": end
|
||
})
|
||
else:
|
||
budget_query = """
|
||
SELECT date, budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start AND :end
|
||
AND budget_type = :metric
|
||
"""
|
||
budget_result = await db.execute(text(budget_query), {
|
||
"start": start, "end": end, "metric": metric
|
||
})
|
||
budget_rows = budget_result.fetchall()
|
||
budget_prior_by_date = {str(row.date): float(row.budget_value) for row in budget_rows}
|
||
else:
|
||
# Get prior year DOW-aligned actuals from newbook_bookings_stats
|
||
# Calculate prior dates (364 days back for DOW alignment)
|
||
col_expr, from_clause, _ = get_metric_query_parts(metric)
|
||
prior_query = f"""
|
||
SELECT s.date, {col_expr} as value
|
||
{from_clause}
|
||
WHERE s.date BETWEEN :prior_start AND :prior_end
|
||
AND {col_expr} IS NOT NULL
|
||
"""
|
||
prior_start = start - timedelta(days=364)
|
||
prior_end = end - timedelta(days=364)
|
||
try:
|
||
prior_result = await db.execute(text(prior_query), {
|
||
"prior_start": prior_start, "prior_end": prior_end
|
||
})
|
||
prior_rows = prior_result.fetchall()
|
||
# Map prior dates to target dates (+364 days)
|
||
for row in prior_rows:
|
||
target_date = row.date + timedelta(days=364)
|
||
if start <= target_date <= end:
|
||
budget_prior_by_date[str(target_date)] = float(row.value) if row.value else None
|
||
except Exception:
|
||
pass
|
||
|
||
# Build response data
|
||
data = []
|
||
otb_total = 0
|
||
prior_otb_total = 0
|
||
forecast_total = 0
|
||
prior_final_total = 0
|
||
days_forecasting_more = 0
|
||
days_forecasting_less = 0
|
||
|
||
current_date = start
|
||
while current_date <= end:
|
||
date_str = str(current_date)
|
||
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||
day_of_week = day_names[current_date.weekday()]
|
||
|
||
# Get OTB data (may be empty if pickup_snapshots doesn't exist)
|
||
otb_row = otb_by_date.get(date_str)
|
||
current_otb = float(otb_row.otb_value) if otb_row and otb_row.otb_value is not None else None
|
||
prior_year_otb = float(otb_row.prior_year_otb) if otb_row and otb_row.prior_year_otb is not None else None
|
||
# For prior_year_final, use OTB data if available, otherwise use budget_prior_by_date for non-revenue
|
||
prior_year_final = float(otb_row.prior_year_final) if otb_row and otb_row.prior_year_final is not None else (
|
||
budget_prior_by_date.get(date_str) if not is_revenue_metric else None
|
||
)
|
||
|
||
# Get model forecasts
|
||
date_forecasts = forecasts_by_date.get(date_str, {})
|
||
prophet_fc = date_forecasts.get('prophet')
|
||
xgboost_fc = date_forecasts.get('xgboost')
|
||
catboost_fc = date_forecasts.get('catboost')
|
||
saved_blended = date_forecasts.get('blended') # Check for pre-generated blended forecast
|
||
|
||
# Get budget/prior value
|
||
budget_prior = budget_prior_by_date.get(date_str)
|
||
|
||
# Use saved blended forecast if available, otherwise calculate on-the-fly
|
||
blended_forecast = None
|
||
if saved_blended is not None:
|
||
# Use pre-generated blended forecast from snapshot (already accuracy-weighted)
|
||
blended_forecast = saved_blended
|
||
elif any([prophet_fc, xgboost_fc, catboost_fc]):
|
||
# Calculate accuracy-weighted model forecast
|
||
model_sum = 0
|
||
weight_sum = 0
|
||
if prophet_fc is not None:
|
||
model_sum += prophet_fc * prophet_weight
|
||
weight_sum += prophet_weight
|
||
if xgboost_fc is not None:
|
||
model_sum += xgboost_fc * xgboost_weight
|
||
weight_sum += xgboost_weight
|
||
if catboost_fc is not None:
|
||
model_sum += catboost_fc * catboost_weight
|
||
weight_sum += catboost_weight
|
||
|
||
if weight_sum > 0:
|
||
accuracy_weighted = model_sum / weight_sum
|
||
|
||
# Blend with budget/prior
|
||
if budget_prior is not None:
|
||
blended_forecast = (MODEL_WEIGHT * accuracy_weighted) + (BUDGET_PRIOR_WEIGHT * budget_prior)
|
||
else:
|
||
# No budget/prior data - use just accuracy-weighted models
|
||
blended_forecast = accuracy_weighted
|
||
|
||
# Accumulate totals
|
||
if current_otb is not None:
|
||
otb_total += current_otb
|
||
if prior_year_otb is not None:
|
||
prior_otb_total += prior_year_otb
|
||
if blended_forecast is not None:
|
||
forecast_total += blended_forecast
|
||
if prior_year_final is not None:
|
||
prior_final_total += prior_year_final
|
||
if blended_forecast is not None:
|
||
if blended_forecast > prior_year_final:
|
||
days_forecasting_more += 1
|
||
elif blended_forecast < prior_year_final:
|
||
days_forecasting_less += 1
|
||
|
||
data.append(BlendedDataPoint(
|
||
date=date_str,
|
||
day_of_week=day_of_week,
|
||
current_otb=current_otb,
|
||
prior_year_otb=prior_year_otb,
|
||
blended_forecast=round(blended_forecast, 2) if blended_forecast else None,
|
||
prophet_forecast=round(prophet_fc, 2) if prophet_fc else None,
|
||
xgboost_forecast=round(xgboost_fc, 2) if xgboost_fc else None,
|
||
catboost_forecast=round(catboost_fc, 2) if catboost_fc else None,
|
||
budget_or_prior=round(budget_prior, 2) if budget_prior else None,
|
||
prior_year_final=prior_year_final
|
||
))
|
||
|
||
current_date += timedelta(days=1)
|
||
|
||
return BlendedResponse(
|
||
data=data,
|
||
summary=BlendedSummary(
|
||
otb_total=round(otb_total, 2),
|
||
prior_otb_total=round(prior_otb_total, 2),
|
||
forecast_total=round(forecast_total, 2),
|
||
prior_final_total=round(prior_final_total, 2),
|
||
days_count=len(data),
|
||
days_forecasting_more=days_forecasting_more,
|
||
days_forecasting_less=days_forecasting_less,
|
||
prophet_weight=round(prophet_weight, 3),
|
||
xgboost_weight=round(xgboost_weight, 3),
|
||
catboost_weight=round(catboost_weight, 3)
|
||
)
|
||
)
|
||
|
||
|
||
@router.get("/preview", response_model=PreviewResponse)
|
||
async def get_forecast_preview(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("occupancy", description="Metric: occupancy or rooms"),
|
||
perception_date: Optional[str] = Query(None, description="Optional: Generate forecast as if it was this date (YYYY-MM-DD) for backtesting"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Live forecast preview using transparent pickup model.
|
||
Uses newbook_booking_pace table to get current OTB and prior year comparison.
|
||
Calculates: Forecast = Current OTB + (Prior Year Final - Prior Year OTB)
|
||
|
||
No logging or persistence - pure read-only preview.
|
||
|
||
If perception_date is provided, generates forecast as if it was that date,
|
||
using only data that would have been available at that time (for backtesting).
|
||
|
||
Note: Pickup model only works for room-based metrics (occupancy, rooms, guests).
|
||
For revenue/rate metrics, returns empty data as OTB/pace concepts don't apply.
|
||
"""
|
||
from datetime import datetime
|
||
|
||
# Check if metric is room-based (pickup model only works for these)
|
||
is_room_based = metric in ('occupancy', 'rooms')
|
||
if not is_room_based:
|
||
# Pickup model doesn't apply to revenue/rate metrics
|
||
return PreviewResponse(
|
||
data=[],
|
||
summary=PreviewSummary(
|
||
otb_total=0,
|
||
forecast_total=0,
|
||
prior_otb_total=0,
|
||
prior_final_total=0,
|
||
pace_pct=None,
|
||
days_count=0
|
||
)
|
||
)
|
||
|
||
# Validate dates
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
# Use perception_date if provided, otherwise use actual today
|
||
actual_today = date.today()
|
||
if perception_date:
|
||
try:
|
||
today = datetime.strptime(perception_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid perception_date format. Use YYYY-MM-DD")
|
||
else:
|
||
today = actual_today
|
||
|
||
is_backtest = perception_date is not None
|
||
|
||
# Get default bookable cap
|
||
default_bookable_cap = await get_bookable_cap(db)
|
||
|
||
# Generate date range and calculate for each date
|
||
data_points = []
|
||
otb_total = 0.0
|
||
forecast_total = 0.0
|
||
prior_otb_total = 0.0
|
||
prior_final_total = 0.0
|
||
|
||
current_date = start
|
||
while current_date <= end:
|
||
lead_days = (current_date - today).days
|
||
if lead_days < 0:
|
||
current_date += timedelta(days=1)
|
||
continue
|
||
|
||
lead_col = get_lead_time_column(lead_days)
|
||
prior_year_date = current_date - timedelta(days=364) # 52 weeks for DOW alignment
|
||
day_of_week = current_date.strftime("%a")
|
||
|
||
# Get current OTB
|
||
if is_backtest:
|
||
# In backtest mode, get "current" OTB from booking_pace at that lead time
|
||
current_otb_query = text(f"""
|
||
SELECT {lead_col} as current_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_otb_query, {"arrival_date": current_date})
|
||
current_row = current_result.fetchone()
|
||
else:
|
||
# Normal mode: get current OTB from bookings_stats (today's actual booking count)
|
||
current_query = text("""
|
||
SELECT booking_count as current_otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date = :arrival_date
|
||
""")
|
||
current_result = await db.execute(current_query, {"arrival_date": current_date})
|
||
current_row = current_result.fetchone()
|
||
|
||
# Get prior year OTB from booking_pace (for lead time comparison - always uses 364-day offset)
|
||
prior_year_for_otb = current_date - timedelta(days=364)
|
||
prior_otb_query = text(f"""
|
||
SELECT {lead_col} as prior_otb
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :prior_date
|
||
""")
|
||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||
prior_otb_row = prior_otb_result.fetchone()
|
||
|
||
# Get prior year FINAL from bookings_stats (actual booking count for that date)
|
||
prior_final_query = text("""
|
||
SELECT booking_count as prior_final
|
||
FROM newbook_bookings_stats
|
||
WHERE date = :prior_date
|
||
""")
|
||
prior_final_result = await db.execute(prior_final_query, {"prior_date": prior_year_date})
|
||
prior_final_row = prior_final_result.fetchone()
|
||
|
||
# Extract values - default to 0 for stats (no row = no bookings), None for pace (no historical tracking)
|
||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None
|
||
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final is not None else 0
|
||
|
||
# Get per-date bookable cap
|
||
date_bookable_cap = await get_bookable_cap(db, current_date, default_bookable_cap)
|
||
|
||
# Convert to occupancy % if metric is occupancy
|
||
if metric == "occupancy" and date_bookable_cap > 0:
|
||
if current_otb is not None:
|
||
current_otb = (current_otb / date_bookable_cap) * 100
|
||
if prior_otb is not None:
|
||
prior_otb = (prior_otb / date_bookable_cap) * 100
|
||
if prior_final is not None:
|
||
prior_final = (prior_final / date_bookable_cap) * 100
|
||
|
||
# Calculate expected pickup and forecast
|
||
expected_pickup = None
|
||
forecast = None
|
||
pace_vs_prior_pct = None
|
||
|
||
if current_otb is not None:
|
||
if prior_final is not None and prior_otb is not None:
|
||
expected_pickup = prior_final - prior_otb
|
||
forecast = current_otb + expected_pickup
|
||
# Floor to current OTB if pickup is negative
|
||
if forecast < current_otb:
|
||
forecast = current_otb
|
||
expected_pickup = 0
|
||
# Cap at max capacity (uses per-date bookable cap)
|
||
if metric == "occupancy" and forecast > 100:
|
||
forecast = 100.0
|
||
elif metric == "rooms" and forecast > date_bookable_cap:
|
||
forecast = float(date_bookable_cap)
|
||
# Calculate pace vs prior
|
||
if prior_otb > 0:
|
||
pace_vs_prior_pct = ((current_otb - prior_otb) / prior_otb) * 100
|
||
else:
|
||
# No prior year data - use current OTB as forecast
|
||
forecast = current_otb
|
||
expected_pickup = 0
|
||
|
||
otb_total += current_otb
|
||
forecast_total += forecast if forecast else current_otb
|
||
|
||
if prior_otb is not None:
|
||
prior_otb_total += prior_otb
|
||
if prior_final is not None:
|
||
prior_final_total += prior_final
|
||
|
||
prior_year_dow = prior_year_date.strftime("%a")
|
||
|
||
data_points.append(PreviewDataPoint(
|
||
date=str(current_date),
|
||
day_of_week=day_of_week,
|
||
lead_days=lead_days,
|
||
current_otb=round(current_otb, 1) if current_otb is not None else None,
|
||
prior_year_date=str(prior_year_date),
|
||
prior_year_dow=prior_year_dow,
|
||
prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None,
|
||
prior_year_final=round(prior_final, 1) if prior_final is not None else None,
|
||
expected_pickup=round(expected_pickup, 1) if expected_pickup is not None else None,
|
||
forecast=round(forecast, 1) if forecast is not None else None,
|
||
pace_vs_prior_pct=round(pace_vs_prior_pct, 1) if pace_vs_prior_pct is not None else None
|
||
))
|
||
|
||
current_date += timedelta(days=1)
|
||
|
||
# Calculate overall pace percentage
|
||
pace_pct = None
|
||
if prior_otb_total > 0:
|
||
pace_pct = round(((otb_total - prior_otb_total) / prior_otb_total) * 100, 1)
|
||
|
||
# For occupancy (percentage), show averages; for rooms/guests (counts), show sums
|
||
days_count = len(data_points)
|
||
if metric == "occupancy" and days_count > 0:
|
||
return PreviewResponse(
|
||
data=data_points,
|
||
summary=PreviewSummary(
|
||
otb_total=round(otb_total / days_count, 1),
|
||
forecast_total=round(forecast_total / days_count, 1),
|
||
prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0,
|
||
prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0,
|
||
pace_pct=pace_pct,
|
||
days_count=days_count
|
||
)
|
||
)
|
||
else:
|
||
return PreviewResponse(
|
||
data=data_points,
|
||
summary=PreviewSummary(
|
||
otb_total=round(otb_total, 1),
|
||
forecast_total=round(forecast_total, 1),
|
||
prior_otb_total=round(prior_otb_total, 1),
|
||
prior_final_total=round(prior_final_total, 1),
|
||
pace_pct=pace_pct,
|
||
days_count=days_count
|
||
)
|
||
)
|
||
|
||
|
||
class PaceCurvePoint(BaseModel):
|
||
days_out: int
|
||
rooms: Optional[int]
|
||
|
||
|
||
class PaceCurveResponse(BaseModel):
|
||
arrival_date: str
|
||
day_of_week: str
|
||
current_year: List[PaceCurvePoint]
|
||
prior_year: List[PaceCurvePoint]
|
||
final_value: Optional[int]
|
||
prior_year_final: Optional[int]
|
||
|
||
|
||
@router.get("/pace-curve", response_model=PaceCurveResponse)
|
||
async def get_pace_curve(
|
||
arrival_date: str = Query(..., description="Arrival date (YYYY-MM-DD)"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get booking pace curve for a specific arrival date.
|
||
Shows how bookings built up over time from 365 days out to today.
|
||
Includes prior year same day-of-week comparison (364-day offset).
|
||
"""
|
||
from datetime import datetime
|
||
|
||
try:
|
||
target_date = datetime.strptime(arrival_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
# Prior year same DOW (364 days = 52 weeks exactly)
|
||
prior_year_date = target_date - timedelta(days=364)
|
||
|
||
# Column names in order (d365 to d0)
|
||
lead_time_columns = [
|
||
# Monthly intervals (6)
|
||
("d365", 365), ("d330", 330), ("d300", 300), ("d270", 270), ("d240", 240), ("d210", 210),
|
||
# Weekly intervals (21)
|
||
("d177", 177), ("d170", 170), ("d163", 163), ("d156", 156), ("d149", 149),
|
||
("d142", 142), ("d135", 135), ("d128", 128), ("d121", 121), ("d114", 114),
|
||
("d107", 107), ("d100", 100), ("d93", 93), ("d86", 86), ("d79", 79),
|
||
("d72", 72), ("d65", 65), ("d58", 58), ("d51", 51), ("d44", 44), ("d37", 37),
|
||
# Daily intervals (31)
|
||
("d30", 30), ("d29", 29), ("d28", 28), ("d27", 27), ("d26", 26),
|
||
("d25", 25), ("d24", 24), ("d23", 23), ("d22", 22), ("d21", 21),
|
||
("d20", 20), ("d19", 19), ("d18", 18), ("d17", 17), ("d16", 16),
|
||
("d15", 15), ("d14", 14), ("d13", 13), ("d12", 12), ("d11", 11),
|
||
("d10", 10), ("d9", 9), ("d8", 8), ("d7", 7), ("d6", 6),
|
||
("d5", 5), ("d4", 4), ("d3", 3), ("d2", 2), ("d1", 1), ("d0", 0)
|
||
]
|
||
|
||
# Build column select list
|
||
col_names = [col[0] for col in lead_time_columns]
|
||
col_select = ", ".join(col_names)
|
||
|
||
# Get current year pace data
|
||
current_query = text(f"""
|
||
SELECT {col_select}
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :arrival_date
|
||
""")
|
||
|
||
current_result = await db.execute(current_query, {"arrival_date": target_date})
|
||
current_row = current_result.fetchone()
|
||
|
||
# Get prior year pace data
|
||
prior_query = text(f"""
|
||
SELECT {col_select}
|
||
FROM newbook_booking_pace
|
||
WHERE arrival_date = :prior_date
|
||
""")
|
||
|
||
prior_result = await db.execute(prior_query, {"prior_date": prior_year_date})
|
||
prior_row = prior_result.fetchone()
|
||
|
||
# Build response
|
||
current_year_data = []
|
||
prior_year_data = []
|
||
|
||
for col_name, days_out in lead_time_columns:
|
||
# Current year
|
||
if current_row:
|
||
val = getattr(current_row, col_name, None)
|
||
current_year_data.append(PaceCurvePoint(days_out=days_out, rooms=val))
|
||
else:
|
||
current_year_data.append(PaceCurvePoint(days_out=days_out, rooms=None))
|
||
|
||
# Prior year
|
||
if prior_row:
|
||
val = getattr(prior_row, col_name, None)
|
||
prior_year_data.append(PaceCurvePoint(days_out=days_out, rooms=val))
|
||
else:
|
||
prior_year_data.append(PaceCurvePoint(days_out=days_out, rooms=None))
|
||
|
||
# Get final values from d0 column
|
||
final_value = getattr(current_row, 'd0', None) if current_row else None
|
||
prior_final = getattr(prior_row, 'd0', None) if prior_row else None
|
||
|
||
# Get day of week
|
||
day_of_week = target_date.strftime("%a")
|
||
|
||
return PaceCurveResponse(
|
||
arrival_date=arrival_date,
|
||
day_of_week=day_of_week,
|
||
current_year=current_year_data,
|
||
prior_year=prior_year_data,
|
||
final_value=final_value,
|
||
prior_year_final=prior_final
|
||
)
|
||
|
||
|
||
# ============================================
|
||
# ACTUALS DATA ENDPOINT
|
||
# ============================================
|
||
|
||
class ActualsDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
actual_value: Optional[float]
|
||
prior_year_value: Optional[float]
|
||
budget_value: Optional[float]
|
||
otb_value: Optional[float] = None # On-the-books revenue for future dates
|
||
|
||
|
||
class ActualsResponse(BaseModel):
|
||
data: List[ActualsDataPoint]
|
||
summary: dict
|
||
|
||
|
||
@router.get("/actuals", response_model=ActualsResponse)
|
||
async def get_actuals_data(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("rooms", description="Metric type"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get actual/final data for a date range with prior year and budget comparison.
|
||
Used for the main forecast page to show actuals for past dates.
|
||
|
||
Note: Today's actual is excluded (set to null) since the day isn't finished.
|
||
For net_accom metric, OTB (on-the-books) values are included for today and future dates.
|
||
"""
|
||
from datetime import datetime, date as date_type
|
||
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
today = date_type.today()
|
||
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||
|
||
# Get default bookable capacity for rooms/occupancy budget calculation
|
||
default_cap = await get_bookable_cap(db)
|
||
|
||
# Build query based on metric type
|
||
if metric == 'total_rev':
|
||
# Total revenue = sum of accommodation + dry + wet
|
||
query = text("""
|
||
WITH date_range AS (
|
||
SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date
|
||
),
|
||
actuals AS (
|
||
SELECT date, COALESCE(accommodation, 0) + COALESCE(dry, 0) + COALESCE(wet, 0) as value
|
||
FROM newbook_net_revenue_data
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
prior_year AS (
|
||
SELECT date + interval '364 days' as target_date,
|
||
COALESCE(accommodation, 0) + COALESCE(dry, 0) + COALESCE(wet, 0) as value
|
||
FROM newbook_net_revenue_data
|
||
WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days'
|
||
),
|
||
budgets AS (
|
||
SELECT date, SUM(budget_value) as budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND budget_type IN ('net_accom', 'net_dry', 'net_wet')
|
||
GROUP BY date
|
||
),
|
||
otb_data AS (
|
||
SELECT date, net_booking_rev_total as otb_gross
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
tax_rates_lookup AS (
|
||
SELECT DISTINCT ON (dr.date) dr.date, tr.rate
|
||
FROM date_range dr
|
||
LEFT JOIN tax_rates tr ON tr.tax_type = 'accommodation_vat' AND tr.effective_from <= dr.date
|
||
ORDER BY dr.date, tr.effective_from DESC
|
||
)
|
||
SELECT
|
||
dr.date,
|
||
EXTRACT(DOW FROM dr.date) as dow,
|
||
CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value,
|
||
py.value as prior_year_value,
|
||
b.budget_value,
|
||
CASE
|
||
WHEN dr.date >= :today AND o.otb_gross IS NOT NULL AND trl.rate IS NOT NULL
|
||
THEN ROUND(o.otb_gross / (1 + trl.rate), 2)
|
||
ELSE NULL
|
||
END as otb_value
|
||
FROM date_range dr
|
||
LEFT JOIN actuals a ON dr.date = a.date
|
||
LEFT JOIN prior_year py ON dr.date = py.target_date
|
||
LEFT JOIN budgets b ON dr.date = b.date
|
||
LEFT JOIN otb_data o ON dr.date = o.date
|
||
LEFT JOIN tax_rates_lookup trl ON dr.date = trl.date
|
||
ORDER BY dr.date
|
||
""")
|
||
elif metric in ['net_accom', 'net_dry', 'net_wet']:
|
||
# Revenue metrics from newbook_net_revenue_data
|
||
col_map = {'net_accom': 'accommodation', 'net_dry': 'dry', 'net_wet': 'wet'}
|
||
col_name = col_map[metric]
|
||
|
||
# For net_accom, also fetch OTB values from newbook_bookings_stats
|
||
# OTB = net_booking_rev_total / (1 + vat_rate) to get net of VAT
|
||
if metric == 'net_accom':
|
||
query = text(f"""
|
||
WITH date_range AS (
|
||
SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date
|
||
),
|
||
actuals AS (
|
||
SELECT date, {col_name} as value
|
||
FROM newbook_net_revenue_data
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
prior_year AS (
|
||
SELECT date + interval '364 days' as target_date, {col_name} as value
|
||
FROM newbook_net_revenue_data
|
||
WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days'
|
||
),
|
||
budgets AS (
|
||
SELECT date, budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND budget_type = :metric
|
||
),
|
||
otb_data AS (
|
||
SELECT date, net_booking_rev_total as otb_gross
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
tax_rates_lookup AS (
|
||
-- Get the effective tax rate for each date in range
|
||
SELECT DISTINCT ON (dr.date) dr.date, tr.rate
|
||
FROM date_range dr
|
||
LEFT JOIN tax_rates tr ON tr.tax_type = 'accommodation_vat' AND tr.effective_from <= dr.date
|
||
ORDER BY dr.date, tr.effective_from DESC
|
||
)
|
||
SELECT
|
||
dr.date,
|
||
EXTRACT(DOW FROM dr.date) as dow,
|
||
CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value,
|
||
py.value as prior_year_value,
|
||
b.budget_value,
|
||
CASE
|
||
WHEN dr.date >= :today AND o.otb_gross IS NOT NULL AND trl.rate IS NOT NULL
|
||
THEN ROUND(o.otb_gross / (1 + trl.rate), 2)
|
||
ELSE NULL
|
||
END as otb_value
|
||
FROM date_range dr
|
||
LEFT JOIN actuals a ON dr.date = a.date
|
||
LEFT JOIN prior_year py ON dr.date = py.target_date
|
||
LEFT JOIN budgets b ON dr.date = b.date
|
||
LEFT JOIN otb_data o ON dr.date = o.date
|
||
LEFT JOIN tax_rates_lookup trl ON dr.date = trl.date
|
||
ORDER BY dr.date
|
||
""")
|
||
else:
|
||
# For net_dry and net_wet, no OTB data available
|
||
query = text(f"""
|
||
WITH date_range AS (
|
||
SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date
|
||
),
|
||
actuals AS (
|
||
SELECT date, {col_name} as value
|
||
FROM newbook_net_revenue_data
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
prior_year AS (
|
||
SELECT date + interval '364 days' as target_date, {col_name} as value
|
||
FROM newbook_net_revenue_data
|
||
WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days'
|
||
),
|
||
budgets AS (
|
||
SELECT date, budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND budget_type = :metric
|
||
)
|
||
SELECT
|
||
dr.date,
|
||
EXTRACT(DOW FROM dr.date) as dow,
|
||
CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value,
|
||
py.value as prior_year_value,
|
||
b.budget_value,
|
||
NULL::numeric as otb_value
|
||
FROM date_range dr
|
||
LEFT JOIN actuals a ON dr.date = a.date
|
||
LEFT JOIN prior_year py ON dr.date = py.target_date
|
||
LEFT JOIN budgets b ON dr.date = b.date
|
||
ORDER BY dr.date
|
||
""")
|
||
elif metric == 'occupancy':
|
||
# Occupancy from newbook_bookings_stats
|
||
# Budget occupancy calculated from: (net_accom_budget / ARR) / bookable_cap * 100
|
||
query = text("""
|
||
WITH date_range AS (
|
||
SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date
|
||
),
|
||
actuals AS (
|
||
SELECT date, total_occupancy_pct as value
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
prior_year AS (
|
||
SELECT date + interval '364 days' as target_date, total_occupancy_pct as value
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days'
|
||
),
|
||
otb_data AS (
|
||
SELECT date, total_occupancy_pct as otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
budgets AS (
|
||
SELECT date, budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND budget_type = 'net_accom'
|
||
),
|
||
arr_forecast AS (
|
||
SELECT
|
||
target_date as date,
|
||
forecast_value as arr
|
||
FROM forecast_snapshots
|
||
WHERE target_date BETWEEN :start_date AND :end_date
|
||
AND metric_code = 'arr'
|
||
AND model = 'blended'
|
||
AND perception_date = (
|
||
SELECT MAX(perception_date)
|
||
FROM forecast_snapshots
|
||
WHERE metric_code = 'arr' AND model = 'blended'
|
||
)
|
||
),
|
||
bookable AS (
|
||
SELECT date, bookable_count
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND bookable_count IS NOT NULL
|
||
),
|
||
prior_year_pace AS (
|
||
SELECT
|
||
pace.arrival_date + 364 as target_date,
|
||
CASE
|
||
WHEN CAST(:today AS date) - 364 >= pace.arrival_date THEN NULL
|
||
ELSE CASE (pace.arrival_date - (CAST(:today AS date) - 364))
|
||
WHEN 0 THEN pace.d0 WHEN 1 THEN pace.d1 WHEN 2 THEN pace.d2 WHEN 3 THEN pace.d3
|
||
WHEN 4 THEN pace.d4 WHEN 5 THEN pace.d5 WHEN 6 THEN pace.d6 WHEN 7 THEN pace.d7
|
||
WHEN 8 THEN pace.d8 WHEN 9 THEN pace.d9 WHEN 10 THEN pace.d10 WHEN 11 THEN pace.d11
|
||
WHEN 12 THEN pace.d12 WHEN 13 THEN pace.d13 WHEN 14 THEN pace.d14 WHEN 15 THEN pace.d15
|
||
WHEN 16 THEN pace.d16 WHEN 17 THEN pace.d17 WHEN 18 THEN pace.d18 WHEN 19 THEN pace.d19
|
||
WHEN 20 THEN pace.d20 WHEN 21 THEN pace.d21 WHEN 22 THEN pace.d22 WHEN 23 THEN pace.d23
|
||
WHEN 24 THEN pace.d24 WHEN 25 THEN pace.d25 WHEN 26 THEN pace.d26 WHEN 27 THEN pace.d27
|
||
WHEN 28 THEN pace.d28 WHEN 29 THEN pace.d29 WHEN 30 THEN pace.d30
|
||
WHEN 37 THEN pace.d37 WHEN 44 THEN pace.d44 WHEN 51 THEN pace.d51 WHEN 58 THEN pace.d58
|
||
WHEN 65 THEN pace.d65 WHEN 72 THEN pace.d72 WHEN 79 THEN pace.d79 WHEN 86 THEN pace.d86
|
||
WHEN 93 THEN pace.d93 WHEN 100 THEN pace.d100 WHEN 107 THEN pace.d107 WHEN 114 THEN pace.d114
|
||
WHEN 121 THEN pace.d121 WHEN 128 THEN pace.d128 WHEN 135 THEN pace.d135 WHEN 142 THEN pace.d142
|
||
WHEN 149 THEN pace.d149 WHEN 156 THEN pace.d156 WHEN 163 THEN pace.d163 WHEN 170 THEN pace.d170
|
||
WHEN 177 THEN pace.d177 WHEN 210 THEN pace.d210 WHEN 240 THEN pace.d240 WHEN 270 THEN pace.d270
|
||
WHEN 300 THEN pace.d300 WHEN 330 THEN pace.d330 WHEN 365 THEN pace.d365
|
||
ELSE NULL
|
||
END
|
||
END as booking_count
|
||
FROM newbook_booking_pace pace
|
||
WHERE pace.arrival_date BETWEEN CAST(:start_date AS date) - 364
|
||
AND CAST(:end_date AS date) - 364
|
||
)
|
||
SELECT
|
||
dr.date,
|
||
EXTRACT(DOW FROM dr.date) as dow,
|
||
CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value,
|
||
CASE
|
||
WHEN dr.date < :today THEN py.value
|
||
WHEN pyp.booking_count IS NOT NULL AND bc.bookable_count IS NOT NULL AND bc.bookable_count > 0
|
||
THEN (pyp.booking_count::numeric / bc.bookable_count) * 100
|
||
ELSE NULL
|
||
END as prior_year_value,
|
||
CASE
|
||
WHEN b.budget_value IS NOT NULL AND arr.arr IS NOT NULL AND arr.arr > 0 AND COALESCE(bc.bookable_count, :default_cap) > 0
|
||
THEN (LEAST(COALESCE(bc.bookable_count, :default_cap), CEIL(b.budget_value / arr.arr)) / COALESCE(bc.bookable_count, :default_cap)) * 100
|
||
ELSE NULL
|
||
END as budget_value,
|
||
CASE WHEN dr.date >= :today THEN o.otb ELSE NULL END as otb_value
|
||
FROM date_range dr
|
||
LEFT JOIN actuals a ON dr.date = a.date
|
||
LEFT JOIN prior_year py ON dr.date = py.target_date
|
||
LEFT JOIN otb_data o ON dr.date = o.date
|
||
LEFT JOIN budgets b ON dr.date = b.date
|
||
LEFT JOIN arr_forecast arr ON dr.date = arr.date
|
||
LEFT JOIN bookable bc ON dr.date = bc.date
|
||
LEFT JOIN prior_year_pace pyp ON dr.date = pyp.target_date
|
||
ORDER BY dr.date
|
||
""")
|
||
elif metric == 'rooms':
|
||
# Room nights from newbook_bookings_stats
|
||
# Budget rooms calculated from: net_accom_budget / ARR (rounded up)
|
||
query = text("""
|
||
WITH date_range AS (
|
||
SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date
|
||
),
|
||
actuals AS (
|
||
SELECT date, booking_count as value
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
prior_year AS (
|
||
SELECT date + interval '364 days' as target_date, booking_count as value
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days'
|
||
),
|
||
otb_data AS (
|
||
SELECT date, booking_count as otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
budgets AS (
|
||
SELECT date, budget_value
|
||
FROM daily_budgets
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND budget_type = 'net_accom'
|
||
),
|
||
arr_forecast AS (
|
||
SELECT
|
||
target_date as date,
|
||
forecast_value as arr
|
||
FROM forecast_snapshots
|
||
WHERE target_date BETWEEN :start_date AND :end_date
|
||
AND metric_code = 'arr'
|
||
AND model = 'blended'
|
||
AND perception_date = (
|
||
SELECT MAX(perception_date)
|
||
FROM forecast_snapshots
|
||
WHERE metric_code = 'arr' AND model = 'blended'
|
||
)
|
||
),
|
||
bookable AS (
|
||
SELECT date, bookable_count
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
AND bookable_count IS NOT NULL
|
||
),
|
||
prior_year_pace AS (
|
||
SELECT
|
||
pace.arrival_date + 364 as target_date,
|
||
CASE
|
||
WHEN CAST(:today AS date) - 364 >= pace.arrival_date THEN NULL
|
||
ELSE CASE (pace.arrival_date - (CAST(:today AS date) - 364))
|
||
WHEN 0 THEN pace.d0 WHEN 1 THEN pace.d1 WHEN 2 THEN pace.d2 WHEN 3 THEN pace.d3
|
||
WHEN 4 THEN pace.d4 WHEN 5 THEN pace.d5 WHEN 6 THEN pace.d6 WHEN 7 THEN pace.d7
|
||
WHEN 8 THEN pace.d8 WHEN 9 THEN pace.d9 WHEN 10 THEN pace.d10 WHEN 11 THEN pace.d11
|
||
WHEN 12 THEN pace.d12 WHEN 13 THEN pace.d13 WHEN 14 THEN pace.d14 WHEN 15 THEN pace.d15
|
||
WHEN 16 THEN pace.d16 WHEN 17 THEN pace.d17 WHEN 18 THEN pace.d18 WHEN 19 THEN pace.d19
|
||
WHEN 20 THEN pace.d20 WHEN 21 THEN pace.d21 WHEN 22 THEN pace.d22 WHEN 23 THEN pace.d23
|
||
WHEN 24 THEN pace.d24 WHEN 25 THEN pace.d25 WHEN 26 THEN pace.d26 WHEN 27 THEN pace.d27
|
||
WHEN 28 THEN pace.d28 WHEN 29 THEN pace.d29 WHEN 30 THEN pace.d30
|
||
WHEN 37 THEN pace.d37 WHEN 44 THEN pace.d44 WHEN 51 THEN pace.d51 WHEN 58 THEN pace.d58
|
||
WHEN 65 THEN pace.d65 WHEN 72 THEN pace.d72 WHEN 79 THEN pace.d79 WHEN 86 THEN pace.d86
|
||
WHEN 93 THEN pace.d93 WHEN 100 THEN pace.d100 WHEN 107 THEN pace.d107 WHEN 114 THEN pace.d114
|
||
WHEN 121 THEN pace.d121 WHEN 128 THEN pace.d128 WHEN 135 THEN pace.d135 WHEN 142 THEN pace.d142
|
||
WHEN 149 THEN pace.d149 WHEN 156 THEN pace.d156 WHEN 163 THEN pace.d163 WHEN 170 THEN pace.d170
|
||
WHEN 177 THEN pace.d177 WHEN 210 THEN pace.d210 WHEN 240 THEN pace.d240 WHEN 270 THEN pace.d270
|
||
WHEN 300 THEN pace.d300 WHEN 330 THEN pace.d330 WHEN 365 THEN pace.d365
|
||
ELSE NULL
|
||
END
|
||
END as booking_count
|
||
FROM newbook_booking_pace pace
|
||
WHERE pace.arrival_date BETWEEN CAST(:start_date AS date) - 364
|
||
AND CAST(:end_date AS date) - 364
|
||
)
|
||
SELECT
|
||
dr.date,
|
||
EXTRACT(DOW FROM dr.date) as dow,
|
||
CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value,
|
||
CASE
|
||
WHEN dr.date < :today THEN py.value
|
||
ELSE pyp.booking_count
|
||
END as prior_year_value,
|
||
CASE
|
||
WHEN b.budget_value IS NOT NULL AND arr.arr IS NOT NULL AND arr.arr > 0
|
||
THEN LEAST(COALESCE(bc.bookable_count, :default_cap), CEIL(b.budget_value / arr.arr))
|
||
ELSE NULL
|
||
END as budget_value,
|
||
CASE WHEN dr.date >= :today THEN o.otb ELSE NULL END as otb_value
|
||
FROM date_range dr
|
||
LEFT JOIN actuals a ON dr.date = a.date
|
||
LEFT JOIN prior_year py ON dr.date = py.target_date
|
||
LEFT JOIN otb_data o ON dr.date = o.date
|
||
LEFT JOIN budgets b ON dr.date = b.date
|
||
LEFT JOIN arr_forecast arr ON dr.date = arr.date
|
||
LEFT JOIN bookable bc ON dr.date = bc.date
|
||
LEFT JOIN prior_year_pace pyp ON dr.date = pyp.target_date
|
||
ORDER BY dr.date
|
||
""")
|
||
elif metric == 'guests':
|
||
# Guests from newbook_bookings_stats
|
||
query = text("""
|
||
WITH date_range AS (
|
||
SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date
|
||
),
|
||
actuals AS (
|
||
SELECT date, guests_count as value
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
),
|
||
prior_year AS (
|
||
SELECT date + interval '364 days' as target_date, guests_count as value
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days'
|
||
),
|
||
otb_data AS (
|
||
SELECT date, guests_count as otb
|
||
FROM newbook_bookings_stats
|
||
WHERE date BETWEEN :start_date AND :end_date
|
||
)
|
||
SELECT
|
||
dr.date,
|
||
EXTRACT(DOW FROM dr.date) as dow,
|
||
CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value,
|
||
py.value as prior_year_value,
|
||
NULL::numeric as budget_value,
|
||
CASE WHEN dr.date >= :today THEN o.otb ELSE NULL END as otb_value
|
||
FROM date_range dr
|
||
LEFT JOIN actuals a ON dr.date = a.date
|
||
LEFT JOIN prior_year py ON dr.date = py.target_date
|
||
LEFT JOIN otb_data o ON dr.date = o.date
|
||
ORDER BY dr.date
|
||
""")
|
||
else:
|
||
# Default to rooms
|
||
return await get_actuals_data(start_date, end_date, 'rooms', db, current_user)
|
||
|
||
result = await db.execute(query, {"start_date": start, "end_date": end, "metric": metric, "today": today, "default_cap": default_cap})
|
||
rows = result.fetchall()
|
||
|
||
data = []
|
||
actual_total = 0
|
||
prior_total = 0
|
||
budget_total = 0
|
||
otb_total = 0
|
||
actual_count = 0
|
||
otb_count = 0
|
||
|
||
for row in rows:
|
||
actual_val = float(row.actual_value) if row.actual_value is not None else None
|
||
prior_val = float(row.prior_year_value) if row.prior_year_value is not None else None
|
||
budget_val = float(row.budget_value) if row.budget_value is not None else None
|
||
otb_val = float(row.otb_value) if row.otb_value is not None else None
|
||
|
||
data.append(ActualsDataPoint(
|
||
date=row.date.isoformat(),
|
||
day_of_week=day_names[int(row.dow)],
|
||
actual_value=actual_val,
|
||
prior_year_value=prior_val,
|
||
budget_value=budget_val,
|
||
otb_value=otb_val
|
||
))
|
||
|
||
if actual_val is not None:
|
||
actual_total += actual_val
|
||
actual_count += 1
|
||
if prior_val is not None:
|
||
prior_total += prior_val
|
||
if budget_val is not None:
|
||
budget_total += budget_val
|
||
if otb_val is not None:
|
||
otb_total += otb_val
|
||
otb_count += 1
|
||
|
||
summary = {
|
||
"actual_total": actual_total,
|
||
"prior_year_total": prior_total,
|
||
"budget_total": budget_total,
|
||
"otb_total": otb_total,
|
||
"days_with_actuals": actual_count,
|
||
"days_with_otb": otb_count,
|
||
"total_days": len(data)
|
||
}
|
||
|
||
return ActualsResponse(data=data, summary=summary)
|
||
|
||
|
||
# ============================================
|
||
# PICKUP-V2 PREVIEW ENDPOINT
|
||
# ============================================
|
||
|
||
class PickupV2DataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
lead_days: int
|
||
prior_year_date: str
|
||
# Revenue metrics
|
||
current_otb_rev: Optional[float] = None
|
||
prior_year_otb_rev: Optional[float] = None
|
||
prior_year_final_rev: Optional[float] = None
|
||
expected_pickup_rev: Optional[float] = None
|
||
forecast: float
|
||
upper_bound: Optional[float] = None
|
||
lower_bound: Optional[float] = None
|
||
ceiling: Optional[float] = None
|
||
# Scenario values
|
||
at_prior_adr: Optional[float] = None # Revenue at prior year pickup ADR
|
||
at_current_rate: Optional[float] = None # Revenue at current rack rates
|
||
at_cheaper_50: Optional[float] = None # Revenue at cheaper 50% of prior rates
|
||
at_expensive_50: Optional[float] = None # Revenue at expensive 50% of prior rates
|
||
# Pricing opportunity fields
|
||
has_pricing_opportunity: Optional[bool] = None # True if current rate < prior ADR
|
||
lost_potential: Optional[float] = None # Revenue left on table (0 if none)
|
||
rate_gap: Optional[float] = None # Negative = opportunity to raise rates
|
||
rate_vs_prior_pct: Optional[float] = None # % diff between current and prior rates
|
||
pace_vs_prior_pct: Optional[float] = None
|
||
pickup_rooms_total: Optional[int] = None # Number of pickup rooms expected
|
||
# Weighted average rates per room for display (net)
|
||
weighted_avg_prior_rate: Optional[float] = None # Prior year pickup ADR (net)
|
||
weighted_avg_current_rate: Optional[float] = None # Current rack rate (net)
|
||
# Gross rates (inc VAT) for UI display
|
||
weighted_avg_prior_rate_gross: Optional[float] = None # Prior year ADR (gross)
|
||
weighted_avg_current_rate_gross: Optional[float] = None # Current rate (gross)
|
||
# Listed rate at lead time (earliest bookings) - for rate comparison
|
||
weighted_avg_listed_rate: Optional[float] = None # LY listed rate at this lead time (net)
|
||
weighted_avg_listed_rate_gross: Optional[float] = None # LY listed rate (gross)
|
||
# Effective rate = rate actually used in forecast (min of prior and current)
|
||
effective_rate: Optional[float] = None # Rate used in forecast (net)
|
||
effective_rate_gross: Optional[float] = None # Rate used in forecast (gross)
|
||
# Room metrics (when metric is rooms/occupancy)
|
||
current_otb: Optional[float] = None
|
||
prior_year_otb: Optional[int] = None
|
||
prior_year_final: Optional[int] = None
|
||
expected_pickup: Optional[int] = None
|
||
floor: Optional[float] = None
|
||
category_breakdown: Optional[dict] = None
|
||
|
||
|
||
class PickupV2Summary(BaseModel):
|
||
otb_rev_total: Optional[float] = None
|
||
forecast_total: float
|
||
upper_total: Optional[float] = None
|
||
lower_total: Optional[float] = None
|
||
prior_final_total: Optional[float] = None
|
||
avg_adr_position: Optional[float] = None
|
||
avg_pace_pct: Optional[float] = None
|
||
days_count: int
|
||
# Pricing opportunity summary
|
||
lost_potential_total: Optional[float] = None # Total revenue left on table
|
||
opportunity_days_count: Optional[int] = None # Days with pricing opportunities
|
||
|
||
|
||
class PickupV2Response(BaseModel):
|
||
data: List[PickupV2DataPoint]
|
||
summary: PickupV2Summary
|
||
|
||
|
||
@router.get("/pickup-v2-preview", response_model=PickupV2Response)
|
||
async def get_pickup_v2_preview(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
metric: str = Query("net_accom", description="Metric type: net_accom, hotel_room_nights, hotel_occupancy_pct"),
|
||
include_details: bool = Query(False, description="Include category breakdown"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Pickup-V2 preview supporting both room and revenue metrics.
|
||
|
||
Revenue forecast uses additive pickup methodology:
|
||
- Forecast = Current OTB + (Prior Year Final - Prior Year OTB at same lead time)
|
||
- Floor: Current OTB (can't go below what's booked)
|
||
- Ceiling: Based on remaining capacity × current rates per category
|
||
|
||
Returns confidence bounds for revenue based on rate analysis:
|
||
- Upper bound: OTB + (remaining rooms × current rate per category)
|
||
- Lower bound: OTB + (remaining rooms × min historical rate per category)
|
||
- ADR position: where current ADR falls between min/max (0-1 scale, indicates pricing pressure)
|
||
"""
|
||
from datetime import datetime
|
||
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast, get_pickup_v2_summary
|
||
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
# Map frontend metric names to model metric codes
|
||
metric_map = {
|
||
'net_accom': 'net_accom',
|
||
'rooms': 'hotel_room_nights',
|
||
'hotel_room_nights': 'hotel_room_nights',
|
||
'occupancy': 'hotel_occupancy_pct',
|
||
'hotel_occupancy_pct': 'hotel_occupancy_pct'
|
||
}
|
||
metric_code = metric_map.get(metric, metric)
|
||
|
||
try:
|
||
# Run the pickup-v2 forecast
|
||
forecasts = await run_pickup_v2_forecast(
|
||
db, metric_code, start, end, include_details=include_details
|
||
)
|
||
|
||
# Build response data
|
||
data = []
|
||
for fc in forecasts:
|
||
data.append(PickupV2DataPoint(
|
||
date=fc['date'],
|
||
day_of_week=fc['day_of_week'],
|
||
lead_days=fc['lead_days'],
|
||
prior_year_date=fc['prior_year_date'],
|
||
current_otb_rev=fc.get('current_otb_rev'),
|
||
prior_year_otb_rev=fc.get('prior_year_otb_rev'),
|
||
prior_year_final_rev=fc.get('prior_year_final_rev'),
|
||
expected_pickup_rev=fc.get('expected_pickup_rev'),
|
||
forecast=fc.get('forecast', fc.get('predicted_value', 0)),
|
||
upper_bound=fc.get('upper_bound'),
|
||
lower_bound=fc.get('lower_bound'),
|
||
ceiling=fc.get('ceiling'),
|
||
# Scenario values
|
||
at_prior_adr=fc.get('at_prior_adr'),
|
||
at_current_rate=fc.get('at_current_rate'),
|
||
at_cheaper_50=fc.get('at_cheaper_50'),
|
||
at_expensive_50=fc.get('at_expensive_50'),
|
||
# Pricing opportunity fields
|
||
has_pricing_opportunity=fc.get('has_pricing_opportunity'),
|
||
lost_potential=fc.get('lost_potential'),
|
||
rate_gap=fc.get('rate_gap'),
|
||
rate_vs_prior_pct=fc.get('rate_vs_prior_pct'),
|
||
pace_vs_prior_pct=fc.get('pace_vs_prior_pct'),
|
||
pickup_rooms_total=fc.get('pickup_rooms_total'),
|
||
# Weighted average rates per room (net and gross)
|
||
weighted_avg_prior_rate=fc.get('weighted_avg_prior_rate'),
|
||
weighted_avg_current_rate=fc.get('weighted_avg_current_rate'),
|
||
weighted_avg_prior_rate_gross=fc.get('weighted_avg_prior_rate_gross'),
|
||
weighted_avg_current_rate_gross=fc.get('weighted_avg_current_rate_gross'),
|
||
# Listed rate at lead time (earliest bookings) - for rate comparison
|
||
weighted_avg_listed_rate=fc.get('weighted_avg_listed_rate'),
|
||
weighted_avg_listed_rate_gross=fc.get('weighted_avg_listed_rate_gross'),
|
||
# Effective rate = rate actually used in forecast (min of prior and current)
|
||
effective_rate=fc.get('effective_rate'),
|
||
effective_rate_gross=fc.get('effective_rate_gross'),
|
||
# Room metrics
|
||
current_otb=fc.get('current_otb'),
|
||
prior_year_otb=fc.get('prior_year_otb'),
|
||
prior_year_final=fc.get('prior_year_final'),
|
||
expected_pickup=fc.get('expected_pickup'),
|
||
floor=fc.get('floor'),
|
||
category_breakdown=fc.get('category_breakdown') if include_details else None
|
||
))
|
||
|
||
# Calculate summary
|
||
if metric_code == 'net_accom':
|
||
# Calculate pricing opportunity totals
|
||
lost_potential_total = sum(f.get('lost_potential', 0) or 0 for f in forecasts)
|
||
opportunity_days = sum(1 for f in forecasts if f.get('has_pricing_opportunity', False))
|
||
|
||
summary = PickupV2Summary(
|
||
otb_rev_total=sum(f.get('current_otb_rev', 0) or 0 for f in forecasts),
|
||
forecast_total=sum(f.get('forecast', 0) or 0 for f in forecasts),
|
||
upper_total=sum(f.get('upper_bound', 0) or 0 for f in forecasts),
|
||
lower_total=sum(f.get('lower_bound', 0) or 0 for f in forecasts),
|
||
prior_final_total=sum(f.get('prior_year_final_rev', 0) or 0 for f in forecasts),
|
||
avg_adr_position=sum(f.get('adr_position', 0.5) or 0.5 for f in forecasts) / max(len(forecasts), 1),
|
||
avg_pace_pct=sum(f.get('pace_vs_prior_pct', 0) or 0 for f in forecasts) / max(len(forecasts), 1),
|
||
days_count=len(forecasts),
|
||
lost_potential_total=lost_potential_total,
|
||
opportunity_days_count=opportunity_days
|
||
)
|
||
else:
|
||
summary = PickupV2Summary(
|
||
forecast_total=sum(f.get('forecast', 0) or 0 for f in forecasts),
|
||
prior_final_total=sum(f.get('prior_year_final', 0) or 0 for f in forecasts),
|
||
avg_pace_pct=sum(f.get('pace_vs_prior_pct', 0) or 0 for f in forecasts) / max(len(forecasts), 1),
|
||
days_count=len(forecasts)
|
||
)
|
||
|
||
return PickupV2Response(data=data, summary=summary)
|
||
|
||
except Exception as e:
|
||
import logging
|
||
logging.error(f"Pickup-V2 preview failed: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Forecast generation failed: {str(e)}")
|
||
|
||
|
||
# ============================================
|
||
# RESTAURANT COVERS FORECAST
|
||
# ============================================
|
||
|
||
class CoversDataPoint(BaseModel):
|
||
date: str
|
||
day_of_week: str
|
||
lead_days: int
|
||
prior_year_date: str
|
||
# Breakfast (based on hotel guest count from night before)
|
||
breakfast_otb: int
|
||
breakfast_pickup: int
|
||
breakfast_forecast: int
|
||
breakfast_prior: int
|
||
breakfast_hotel_guests_otb: int
|
||
breakfast_hotel_guests_prior: int
|
||
breakfast_calc: Optional[dict] = None # Calculation breakdown for tooltip
|
||
# Lunch (simple OTB + pickup)
|
||
lunch_otb: int
|
||
lunch_pickup: int
|
||
lunch_forecast: int
|
||
lunch_prior: int
|
||
lunch_calc: Optional[dict] = None # Calculation breakdown for tooltip
|
||
# Dinner
|
||
dinner_otb: int
|
||
dinner_resident_otb: int
|
||
dinner_non_resident_otb: int
|
||
dinner_resident_pickup: int
|
||
dinner_non_resident_pickup: int
|
||
dinner_forecast: int
|
||
dinner_prior: int
|
||
dinner_resident_calc: Optional[dict] = None # Calculation breakdown for tooltip
|
||
dinner_non_resident_calc: Optional[dict] = None # Calculation breakdown for tooltip
|
||
# Totals
|
||
total_otb: int
|
||
total_forecast: int
|
||
total_prior: int
|
||
pace_vs_prior_pct: Optional[float]
|
||
# Hotel context
|
||
hotel_occupancy_pct: float
|
||
hotel_rooms: int
|
||
|
||
|
||
class CoversSummary(BaseModel):
|
||
breakfast_otb: int
|
||
breakfast_forecast: int
|
||
breakfast_prior: int
|
||
lunch_otb: int
|
||
lunch_forecast: int
|
||
lunch_prior: int
|
||
dinner_otb: int
|
||
dinner_forecast: int
|
||
dinner_prior: int
|
||
total_otb: int
|
||
total_forecast: int
|
||
total_prior: int
|
||
days_count: int
|
||
|
||
|
||
class CoversResponse(BaseModel):
|
||
data: List[CoversDataPoint]
|
||
summary: CoversSummary
|
||
|
||
|
||
@router.get("/covers-forecast", response_model=CoversResponse)
|
||
async def get_covers_forecast(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
include_details: bool = Query(False, description="Include detailed breakdown"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get restaurant covers forecast for a date range.
|
||
|
||
Returns covers forecast by meal period (breakfast, lunch, dinner) with
|
||
breakdown by guest segment (resident/non-resident).
|
||
|
||
Breakfast is forecast based on previous night's hotel occupancy.
|
||
Lunch and dinner use OTB bookings plus pickup forecasts.
|
||
"""
|
||
from datetime import datetime
|
||
from services.forecasting.covers_model import forecast_covers_range
|
||
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
try:
|
||
result = await forecast_covers_range(db, start, end, include_details)
|
||
|
||
# Transform to response format
|
||
data = []
|
||
for fc in result["data"]:
|
||
data.append(CoversDataPoint(
|
||
date=fc["date"],
|
||
day_of_week=fc["day_of_week"],
|
||
lead_days=fc["lead_days"],
|
||
prior_year_date=fc["prior_year_date"],
|
||
# Breakfast (based on hotel guest count from night before)
|
||
breakfast_otb=fc["breakfast"]["otb"],
|
||
breakfast_pickup=fc["breakfast"]["pickup"],
|
||
breakfast_forecast=fc["breakfast"]["forecast"],
|
||
breakfast_prior=fc["breakfast"]["prior_year"],
|
||
breakfast_hotel_guests_otb=fc["breakfast"]["hotel_guests_otb"],
|
||
breakfast_hotel_guests_prior=fc["breakfast"]["hotel_guests_prior"],
|
||
breakfast_calc=fc["breakfast"].get("calc"),
|
||
# Lunch (simple OTB + pickup)
|
||
lunch_otb=fc["lunch"]["otb"],
|
||
lunch_pickup=fc["lunch"]["pickup"],
|
||
lunch_forecast=fc["lunch"]["forecast"],
|
||
lunch_prior=fc["lunch"]["prior_year"],
|
||
lunch_calc=fc["lunch"].get("calc"),
|
||
# Dinner
|
||
dinner_otb=fc["dinner"]["otb"],
|
||
dinner_resident_otb=fc["dinner"]["resident_otb"],
|
||
dinner_non_resident_otb=fc["dinner"]["non_resident_otb"],
|
||
dinner_resident_pickup=fc["dinner"]["resident_pickup"],
|
||
dinner_non_resident_pickup=fc["dinner"]["non_resident_pickup"],
|
||
dinner_forecast=fc["dinner"]["forecast"],
|
||
dinner_prior=fc["dinner"]["prior_year"],
|
||
dinner_resident_calc=fc["dinner"].get("resident_calc"),
|
||
dinner_non_resident_calc=fc["dinner"].get("non_resident_calc"),
|
||
# Totals
|
||
total_otb=fc["totals"]["otb"],
|
||
total_forecast=fc["totals"]["forecast"],
|
||
total_prior=fc["totals"]["prior_year"],
|
||
pace_vs_prior_pct=fc["totals"]["pace_vs_prior_pct"],
|
||
# Hotel context
|
||
hotel_occupancy_pct=fc["hotel_context"]["night_before_occupancy"],
|
||
hotel_rooms=fc["hotel_context"]["night_before_rooms"]
|
||
))
|
||
|
||
summary = CoversSummary(
|
||
breakfast_otb=result["summary"]["breakfast_otb"],
|
||
breakfast_forecast=result["summary"]["breakfast_forecast"],
|
||
breakfast_prior=result["summary"]["breakfast_prior"],
|
||
lunch_otb=result["summary"]["lunch_otb"],
|
||
lunch_forecast=result["summary"]["lunch_forecast"],
|
||
lunch_prior=result["summary"]["lunch_prior"],
|
||
dinner_otb=result["summary"]["dinner_otb"],
|
||
dinner_forecast=result["summary"]["dinner_forecast"],
|
||
dinner_prior=result["summary"]["dinner_prior"],
|
||
total_otb=result["summary"]["total_otb"],
|
||
total_forecast=result["summary"]["total_forecast"],
|
||
total_prior=result["summary"]["total_prior"],
|
||
days_count=result["summary"]["days_count"]
|
||
)
|
||
|
||
return CoversResponse(data=data, summary=summary)
|
||
|
||
except Exception as e:
|
||
import logging
|
||
logging.error(f"Covers forecast failed: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Covers forecast failed: {str(e)}")
|
||
|
||
|
||
@router.get("/revenue-forecast")
|
||
async def get_revenue_forecast(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
revenue_type: str = Query("dry", description="Revenue type: dry, wet, or total"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get restaurant revenue forecast for a date range.
|
||
|
||
Returns:
|
||
- Past dates: Actual revenue from newbook_net_revenue_data
|
||
- Future dates: Forecast revenue (covers × spend)
|
||
- Prior year values for comparison
|
||
"""
|
||
from datetime import datetime, date as date_type
|
||
from services.forecasting.covers_model import forecast_covers_range
|
||
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
today = date_type.today()
|
||
VAT_RATE = 1.20
|
||
|
||
def get_prior_year_date(d: date_type) -> date_type:
|
||
"""
|
||
Get prior year date with 364-day offset for day-of-week alignment.
|
||
52 weeks = 364 days, so Monday aligns with Monday.
|
||
"""
|
||
return d - timedelta(days=364)
|
||
|
||
# Get spend settings
|
||
spend_result = await db.execute(
|
||
text("""
|
||
SELECT config_key, config_value
|
||
FROM system_config
|
||
WHERE config_key LIKE 'resos_%_spend'
|
||
""")
|
||
)
|
||
spend_rows = spend_result.fetchall()
|
||
spend_settings = {row.config_key: float(row.config_value or 0) for row in spend_rows}
|
||
|
||
def get_spend_by_period(period: str) -> float:
|
||
"""Get net spend per cover for a period"""
|
||
if revenue_type == 'dry':
|
||
return spend_settings.get(f'resos_{period}_food_spend', 0) / VAT_RATE
|
||
elif revenue_type == 'wet':
|
||
return spend_settings.get(f'resos_{period}_drinks_spend', 0) / VAT_RATE
|
||
else: # total
|
||
food = spend_settings.get(f'resos_{period}_food_spend', 0)
|
||
drinks = spend_settings.get(f'resos_{period}_drinks_spend', 0)
|
||
return (food + drinks) / VAT_RATE
|
||
|
||
# Get actual revenue for past dates
|
||
actual_result = await db.execute(
|
||
text("""
|
||
SELECT date, dry, wet, (dry + wet) as total
|
||
FROM newbook_net_revenue_data
|
||
WHERE date >= :start_date AND date <= :end_date
|
||
"""),
|
||
{"start_date": start, "end_date": end}
|
||
)
|
||
actual_rows = actual_result.fetchall()
|
||
actual_by_date = {row.date: row for row in actual_rows}
|
||
|
||
# Get prior year actual revenue
|
||
prior_start = get_prior_year_date(start)
|
||
prior_end = get_prior_year_date(end)
|
||
prior_result = await db.execute(
|
||
text("""
|
||
SELECT date, dry, wet, (dry + wet) as total
|
||
FROM newbook_net_revenue_data
|
||
WHERE date >= :start_date AND date <= :end_date
|
||
"""),
|
||
{"start_date": prior_start, "end_date": prior_end}
|
||
)
|
||
prior_rows = prior_result.fetchall()
|
||
prior_by_date = {row.date: row for row in prior_rows}
|
||
|
||
# Get covers forecast for future dates
|
||
covers_data = await forecast_covers_range(db, start, end, include_details=False)
|
||
|
||
# Build response
|
||
data = []
|
||
current = start
|
||
while current <= end:
|
||
is_past = current < today
|
||
prior_date = get_prior_year_date(current)
|
||
|
||
# Get prior year revenue
|
||
prior_row = prior_by_date.get(prior_date)
|
||
if revenue_type == 'dry':
|
||
prior_revenue = float(prior_row.dry) if prior_row else 0
|
||
elif revenue_type == 'wet':
|
||
prior_revenue = float(prior_row.wet) if prior_row else 0
|
||
else:
|
||
prior_revenue = float(prior_row.total) if prior_row else 0
|
||
|
||
if is_past:
|
||
# Past: use actual revenue
|
||
actual_row = actual_by_date.get(current)
|
||
if revenue_type == 'dry':
|
||
actual_revenue = float(actual_row.dry) if actual_row else 0
|
||
elif revenue_type == 'wet':
|
||
actual_revenue = float(actual_row.wet) if actual_row else 0
|
||
else:
|
||
actual_revenue = float(actual_row.total) if actual_row else 0
|
||
|
||
data.append({
|
||
"date": current.isoformat(),
|
||
"day_of_week": current.strftime("%A"),
|
||
"is_past": True,
|
||
"actual_revenue": actual_revenue,
|
||
"otb_revenue": actual_revenue, # For past, OTB = actual
|
||
"pickup_revenue": 0,
|
||
"forecast_revenue": actual_revenue,
|
||
"prior_revenue": prior_revenue,
|
||
})
|
||
else:
|
||
# Future: calculate from covers forecast
|
||
day_covers = next((c for c in covers_data["data"] if c["date"] == current.isoformat()), None)
|
||
|
||
if day_covers:
|
||
breakfast_otb = day_covers["breakfast"]["otb"]
|
||
lunch_otb = day_covers["lunch"]["otb"]
|
||
dinner_otb = day_covers["dinner"]["otb"]
|
||
|
||
breakfast_pickup = day_covers["breakfast"]["pickup"]
|
||
lunch_pickup = day_covers["lunch"]["pickup"]
|
||
dinner_resident_pickup = day_covers["dinner"]["resident_pickup"]
|
||
dinner_non_resident_pickup = day_covers["dinner"]["non_resident_pickup"]
|
||
dinner_pickup = dinner_resident_pickup + dinner_non_resident_pickup
|
||
|
||
otb_revenue = (
|
||
breakfast_otb * get_spend_by_period('breakfast') +
|
||
lunch_otb * get_spend_by_period('lunch') +
|
||
dinner_otb * get_spend_by_period('dinner')
|
||
)
|
||
|
||
pickup_revenue = (
|
||
breakfast_pickup * get_spend_by_period('breakfast') +
|
||
lunch_pickup * get_spend_by_period('lunch') +
|
||
dinner_pickup * get_spend_by_period('dinner')
|
||
)
|
||
else:
|
||
otb_revenue = 0
|
||
pickup_revenue = 0
|
||
|
||
data.append({
|
||
"date": current.isoformat(),
|
||
"day_of_week": current.strftime("%A"),
|
||
"is_past": False,
|
||
"actual_revenue": 0,
|
||
"otb_revenue": otb_revenue,
|
||
"pickup_revenue": pickup_revenue,
|
||
"forecast_revenue": otb_revenue + pickup_revenue,
|
||
"prior_revenue": prior_revenue,
|
||
})
|
||
|
||
current += timedelta(days=1)
|
||
|
||
# Calculate summary
|
||
past_data = [d for d in data if d["is_past"]]
|
||
future_data = [d for d in data if not d["is_past"]]
|
||
|
||
summary = {
|
||
"actual_total": sum(d["actual_revenue"] for d in past_data),
|
||
"prior_actual_total": sum(d["prior_revenue"] for d in past_data),
|
||
"otb_total": sum(d["otb_revenue"] for d in future_data),
|
||
"pickup_total": sum(d["pickup_revenue"] for d in future_data),
|
||
"forecast_remaining": sum(d["forecast_revenue"] for d in future_data),
|
||
"prior_future_total": sum(d["prior_revenue"] for d in future_data),
|
||
"prior_year_total": sum(d["prior_revenue"] for d in data),
|
||
"projected_total": sum(d["actual_revenue"] for d in past_data) + sum(d["forecast_revenue"] for d in future_data),
|
||
"days_actual": len(past_data),
|
||
"days_forecast": len(future_data),
|
||
}
|
||
|
||
return {"data": data, "summary": summary}
|
||
|
||
|
||
@router.get("/combined-revenue-forecast")
|
||
async def get_combined_revenue_forecast(
|
||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: dict = Depends(get_current_user)
|
||
):
|
||
"""
|
||
Get combined total revenue forecast (accom + dry + wet) for a date range.
|
||
|
||
Returns:
|
||
- Past dates: Actual revenue from newbook_net_revenue_data (all revenue types)
|
||
- Future dates: Forecast revenue (accom from pickup-v2, dry/wet from covers × spend)
|
||
- Prior year actual values for comparison
|
||
"""
|
||
from datetime import datetime, date as date_type
|
||
from services.forecasting.covers_model import forecast_covers_range
|
||
from services.forecasting.pickup_v2_model import forecast_revenue_for_date
|
||
|
||
try:
|
||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||
|
||
if start > end:
|
||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||
|
||
today = date_type.today()
|
||
VAT_RATE = 1.20
|
||
|
||
def get_prior_year_date(d: date_type) -> date_type:
|
||
"""
|
||
Get prior year date with 364-day offset for day-of-week alignment.
|
||
52 weeks = 364 days, so Monday aligns with Monday.
|
||
"""
|
||
return d - timedelta(days=364)
|
||
|
||
# Get spend settings for restaurant revenue
|
||
spend_result = await db.execute(
|
||
text("""
|
||
SELECT config_key, config_value
|
||
FROM system_config
|
||
WHERE config_key LIKE 'resos_%_spend'
|
||
""")
|
||
)
|
||
spend_rows = spend_result.fetchall()
|
||
spend_settings = {row.config_key: float(row.config_value or 0) for row in spend_rows}
|
||
|
||
def get_spend_by_period(period: str, revenue_type: str) -> float:
|
||
"""Get net spend per cover for a period"""
|
||
if revenue_type == 'dry':
|
||
return spend_settings.get(f'resos_{period}_food_spend', 0) / VAT_RATE
|
||
elif revenue_type == 'wet':
|
||
return spend_settings.get(f'resos_{period}_drinks_spend', 0) / VAT_RATE
|
||
else: # total
|
||
food = spend_settings.get(f'resos_{period}_food_spend', 0)
|
||
drinks = spend_settings.get(f'resos_{period}_drinks_spend', 0)
|
||
return (food + drinks) / VAT_RATE
|
||
|
||
# Get actual revenue for past dates (all types: accom, dry, wet)
|
||
actual_result = await db.execute(
|
||
text("""
|
||
SELECT date, accommodation, dry, wet
|
||
FROM newbook_net_revenue_data
|
||
WHERE date >= :start_date AND date <= :end_date
|
||
"""),
|
||
{"start_date": start, "end_date": end}
|
||
)
|
||
actual_rows = actual_result.fetchall()
|
||
actual_by_date = {row.date: row for row in actual_rows}
|
||
|
||
# Get prior year actual revenue
|
||
prior_start = get_prior_year_date(start)
|
||
prior_end = get_prior_year_date(end)
|
||
prior_result = await db.execute(
|
||
text("""
|
||
SELECT date, accommodation, dry, wet
|
||
FROM newbook_net_revenue_data
|
||
WHERE date >= :start_date AND date <= :end_date
|
||
"""),
|
||
{"start_date": prior_start, "end_date": prior_end}
|
||
)
|
||
prior_rows = prior_result.fetchall()
|
||
prior_by_date = {row.date: row for row in prior_rows}
|
||
|
||
# Get covers forecast for restaurant revenue (future dates)
|
||
covers_data = await forecast_covers_range(db, start, end, include_details=False)
|
||
|
||
# Build response
|
||
data = []
|
||
current = start
|
||
while current <= end:
|
||
is_past = current < today
|
||
prior_date = get_prior_year_date(current)
|
||
lead_days = (current - today).days if current >= today else 0
|
||
|
||
# Get prior year revenue (all types combined)
|
||
prior_row = prior_by_date.get(prior_date)
|
||
prior_accom = float(prior_row.accommodation) if prior_row and prior_row.accommodation else 0
|
||
prior_dry = float(prior_row.dry) if prior_row and prior_row.dry else 0
|
||
prior_wet = float(prior_row.wet) if prior_row and prior_row.wet else 0
|
||
prior_total = prior_accom + prior_dry + prior_wet
|
||
|
||
if is_past:
|
||
# Past: use actual revenue from database
|
||
actual_row = actual_by_date.get(current)
|
||
actual_accom = float(actual_row.accommodation) if actual_row and actual_row.accommodation else 0
|
||
actual_dry = float(actual_row.dry) if actual_row and actual_row.dry else 0
|
||
actual_wet = float(actual_row.wet) if actual_row and actual_row.wet else 0
|
||
actual_total = actual_accom + actual_dry + actual_wet
|
||
|
||
data.append({
|
||
"date": current.isoformat(),
|
||
"day_of_week": current.strftime("%A"),
|
||
"is_past": True,
|
||
"actual_accom": actual_accom,
|
||
"actual_dry": actual_dry,
|
||
"actual_wet": actual_wet,
|
||
"actual_revenue": actual_total,
|
||
"otb_revenue": actual_total, # For past, OTB = actual
|
||
"pickup_revenue": 0,
|
||
"forecast_revenue": actual_total,
|
||
"prior_accom": prior_accom,
|
||
"prior_dry": prior_dry,
|
||
"prior_wet": prior_wet,
|
||
"prior_revenue": prior_total,
|
||
})
|
||
else:
|
||
# Future: calculate forecast
|
||
# 1. Accommodation from pickup-v2 revenue model
|
||
try:
|
||
accom_forecast = await forecast_revenue_for_date(
|
||
db, current, lead_days, prior_date
|
||
)
|
||
accom_otb = accom_forecast.get('current_otb_rev', 0) or 0
|
||
accom_pickup = accom_forecast.get('forecast_pickup_rev', 0) or 0
|
||
except Exception as e:
|
||
logger.warning(f"Accom forecast failed for {current}: {e}")
|
||
accom_otb = 0
|
||
accom_pickup = 0
|
||
|
||
# 2. Restaurant from covers forecast × spend
|
||
day_covers = next((c for c in covers_data["data"] if c["date"] == current.isoformat()), None)
|
||
|
||
if day_covers:
|
||
breakfast_otb = day_covers["breakfast"]["otb"]
|
||
lunch_otb = day_covers["lunch"]["otb"]
|
||
dinner_otb = day_covers["dinner"]["otb"]
|
||
|
||
breakfast_pickup = day_covers["breakfast"]["pickup"]
|
||
lunch_pickup = day_covers["lunch"]["pickup"]
|
||
dinner_resident_pickup = day_covers["dinner"]["resident_pickup"]
|
||
dinner_non_resident_pickup = day_covers["dinner"]["non_resident_pickup"]
|
||
dinner_pickup = dinner_resident_pickup + dinner_non_resident_pickup
|
||
|
||
dry_otb = (
|
||
breakfast_otb * get_spend_by_period('breakfast', 'dry') +
|
||
lunch_otb * get_spend_by_period('lunch', 'dry') +
|
||
dinner_otb * get_spend_by_period('dinner', 'dry')
|
||
)
|
||
dry_pickup = (
|
||
breakfast_pickup * get_spend_by_period('breakfast', 'dry') +
|
||
lunch_pickup * get_spend_by_period('lunch', 'dry') +
|
||
dinner_pickup * get_spend_by_period('dinner', 'dry')
|
||
)
|
||
|
||
wet_otb = (
|
||
breakfast_otb * get_spend_by_period('breakfast', 'wet') +
|
||
lunch_otb * get_spend_by_period('lunch', 'wet') +
|
||
dinner_otb * get_spend_by_period('dinner', 'wet')
|
||
)
|
||
wet_pickup = (
|
||
breakfast_pickup * get_spend_by_period('breakfast', 'wet') +
|
||
lunch_pickup * get_spend_by_period('lunch', 'wet') +
|
||
dinner_pickup * get_spend_by_period('dinner', 'wet')
|
||
)
|
||
else:
|
||
dry_otb = dry_pickup = wet_otb = wet_pickup = 0
|
||
|
||
total_otb = accom_otb + dry_otb + wet_otb
|
||
total_pickup = accom_pickup + dry_pickup + wet_pickup
|
||
|
||
data.append({
|
||
"date": current.isoformat(),
|
||
"day_of_week": current.strftime("%A"),
|
||
"is_past": False,
|
||
"actual_accom": 0,
|
||
"actual_dry": 0,
|
||
"actual_wet": 0,
|
||
"actual_revenue": 0,
|
||
"otb_accom": accom_otb,
|
||
"otb_dry": dry_otb,
|
||
"otb_wet": wet_otb,
|
||
"otb_revenue": total_otb,
|
||
"pickup_accom": accom_pickup,
|
||
"pickup_dry": dry_pickup,
|
||
"pickup_wet": wet_pickup,
|
||
"pickup_revenue": total_pickup,
|
||
"forecast_revenue": total_otb + total_pickup,
|
||
"prior_accom": prior_accom,
|
||
"prior_dry": prior_dry,
|
||
"prior_wet": prior_wet,
|
||
"prior_revenue": prior_total,
|
||
})
|
||
|
||
current += timedelta(days=1)
|
||
|
||
# Calculate summary
|
||
past_data = [d for d in data if d["is_past"]]
|
||
future_data = [d for d in data if not d["is_past"]]
|
||
|
||
summary = {
|
||
"actual_total": sum(d["actual_revenue"] for d in past_data),
|
||
"actual_accom": sum(d.get("actual_accom", 0) for d in past_data),
|
||
"actual_dry": sum(d.get("actual_dry", 0) for d in past_data),
|
||
"actual_wet": sum(d.get("actual_wet", 0) for d in past_data),
|
||
"prior_actual_total": sum(d["prior_revenue"] for d in past_data),
|
||
"otb_total": sum(d["otb_revenue"] for d in future_data),
|
||
"otb_accom": sum(d.get("otb_accom", 0) for d in future_data),
|
||
"otb_dry": sum(d.get("otb_dry", 0) for d in future_data),
|
||
"otb_wet": sum(d.get("otb_wet", 0) for d in future_data),
|
||
"pickup_total": sum(d["pickup_revenue"] for d in future_data),
|
||
"pickup_accom": sum(d.get("pickup_accom", 0) for d in future_data),
|
||
"pickup_dry": sum(d.get("pickup_dry", 0) for d in future_data),
|
||
"pickup_wet": sum(d.get("pickup_wet", 0) for d in future_data),
|
||
"forecast_remaining": sum(d["forecast_revenue"] for d in future_data),
|
||
"prior_future_total": sum(d["prior_revenue"] for d in future_data),
|
||
"prior_year_total": sum(d["prior_revenue"] for d in data),
|
||
"projected_total": sum(d["actual_revenue"] for d in past_data) + sum(d["forecast_revenue"] for d in future_data),
|
||
"days_actual": len(past_data),
|
||
"days_forecast": len(future_data),
|
||
}
|
||
|
||
return {"data": data, "summary": summary}
|