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

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

537 lines
21 KiB
Python

"""
Public API endpoints - accessible with API key authentication
For external integrations like Kitchen Flash app
"""
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import logging
from database import get_db
from auth import get_api_key_auth
router = APIRouter()
logger = logging.getLogger(__name__)
def get_prior_year_date(target_date: date) -> date:
"""
Get prior year date with 364-day offset for day-of-week alignment.
52 weeks = 364 days, so Monday aligns with Monday.
"""
return target_date - timedelta(days=364)
@router.get("/forecast/rooms")
async def get_rooms_forecast(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Get forecasted room bookings for external applications.
Returns: OTB rooms, forecast rooms, occupancy %, prior year data
"""
from services.forecasting.pickup_v2_model import forecast_rooms_for_date
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
end = start + timedelta(days=days - 1)
today = date.today()
# Get total rooms for occupancy calculation
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'total_rooms'")
)
row = result.fetchone()
total_rooms = int(row.config_value) if row and row.config_value else 30
data = []
current = start
while current <= end:
lead_days = (current - today).days if current >= today else 0
prior_date = get_prior_year_date(current)
try:
if current >= today:
# Future date - get forecast
forecast = await forecast_rooms_for_date(
db, current, lead_days, prior_date, 'hotel_room_nights'
)
otb_rooms = forecast.get('current_otb', 0) or 0
pickup_rooms = forecast.get('expected_pickup', 0) or 0
forecast_rooms = otb_rooms + pickup_rooms
prior_otb = forecast.get('prior_year_otb', 0) or 0
prior_final = forecast.get('prior_year_final', 0) or 0
# Get guest counts from stats
stats_result = await db.execute(
text("""
SELECT booking_count, guests_count FROM newbook_bookings_stats
WHERE date = :target_date
"""),
{"target_date": current}
)
stats_row = stats_result.fetchone()
otb_guests = stats_row.guests_count if stats_row and stats_row.guests_count else 0
# Prior year guests for ratio
prior_stats = await db.execute(
text("""
SELECT booking_count, guests_count FROM newbook_bookings_stats
WHERE date = :prior_date
"""),
{"prior_date": prior_date}
)
prior_stats_row = prior_stats.fetchone()
prior_guests = prior_stats_row.guests_count if prior_stats_row and prior_stats_row.guests_count else 0
prior_rooms_actual = prior_stats_row.booking_count if prior_stats_row and prior_stats_row.booking_count else 0
# Calculate guests per room ratio (prior year -> current OTB -> default)
if prior_rooms_actual > 0:
guests_per_room = prior_guests / prior_rooms_actual
elif otb_rooms > 0:
guests_per_room = otb_guests / otb_rooms
else:
guests_per_room = 1.8
pickup_guests = round(pickup_rooms * guests_per_room)
forecast_guests = otb_guests + pickup_guests
else:
# Past date - get actuals from stats
stats_result = await db.execute(
text("""
SELECT booking_count, guests_count FROM newbook_bookings_stats
WHERE date = :target_date
"""),
{"target_date": current}
)
stats_row = stats_result.fetchone()
otb_rooms = stats_row.booking_count if stats_row else 0
otb_guests = stats_row.guests_count if stats_row and stats_row.guests_count else 0
forecast_rooms = otb_rooms
forecast_guests = otb_guests
pickup_rooms = 0
pickup_guests = 0
# Prior year stats
prior_result = await db.execute(
text("""
SELECT booking_count FROM newbook_bookings_stats
WHERE date = :prior_date
"""),
{"prior_date": prior_date}
)
prior_row = prior_result.fetchone()
prior_final = prior_row.booking_count if prior_row else 0
prior_otb = prior_final
occupancy_pct = round((forecast_rooms / total_rooms) * 100, 1) if total_rooms > 0 else 0
data.append({
"date": current.isoformat(),
"day": current.strftime("%A"),
"lead_days": lead_days,
"otb_rooms": otb_rooms,
"pickup_rooms": pickup_rooms,
"forecast_rooms": forecast_rooms,
"otb_guests": otb_guests,
"pickup_guests": pickup_guests,
"forecast_guests": forecast_guests,
"occupancy_pct": occupancy_pct,
"prior_year_otb": prior_otb,
"prior_year_final": prior_final,
})
except Exception as e:
logger.warning(f"Error forecasting rooms for {current}: {e}")
data.append({
"date": current.isoformat(),
"day": current.strftime("%A"),
"lead_days": lead_days,
"otb_rooms": 0,
"pickup_rooms": 0,
"forecast_rooms": 0,
"otb_guests": 0,
"pickup_guests": 0,
"forecast_guests": 0,
"occupancy_pct": 0,
"prior_year_otb": 0,
"prior_year_final": 0,
"error": str(e)
})
current += timedelta(days=1)
return {"data": data, "total_rooms": total_rooms}
@router.get("/forecast/covers")
async def get_covers_forecast(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Get forecasted restaurant covers by period for external applications.
Returns: OTB covers, forecast covers, prior year data for breakfast, lunch, dinner
"""
from services.forecasting.covers_model import forecast_covers_range
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
end = start + timedelta(days=days - 1)
today = date.today()
try:
# Get covers forecast
covers_data = await forecast_covers_range(db, start, end, include_details=False)
except Exception as e:
logger.error(f"Covers forecast failed: {e}")
raise HTTPException(status_code=500, detail=f"Forecast error: {str(e)}")
# Get prior year covers from stats
prior_start = get_prior_year_date(start)
prior_end = get_prior_year_date(end)
prior_result = await db.execute(
text("""
SELECT date, breakfast_covers, lunch_covers, dinner_covers
FROM resos_bookings_stats
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}
data = []
for day_data in covers_data.get("data", []):
target_date = datetime.strptime(day_data["date"], "%Y-%m-%d").date()
prior_date = get_prior_year_date(target_date)
lead_days = (target_date - today).days if target_date >= today else 0
prior_row = prior_by_date.get(prior_date)
prior_breakfast = prior_row.breakfast_covers if prior_row else 0
prior_lunch = prior_row.lunch_covers if prior_row else 0
prior_dinner = prior_row.dinner_covers if prior_row else 0
# For prior OTB, we don't have pace data, so use same as final for simplicity
data.append({
"date": day_data["date"],
"day": day_data["day_of_week"],
"lead_days": lead_days,
"breakfast": {
"otb": day_data["breakfast"]["otb"],
"forecast": day_data["breakfast"]["forecast"],
"prior_otb": prior_breakfast, # Same as final for prior year
"prior_final": prior_breakfast
},
"lunch": {
"otb": day_data["lunch"]["otb"],
"forecast": day_data["lunch"]["forecast"],
"prior_otb": prior_lunch,
"prior_final": prior_lunch
},
"dinner": {
"otb": day_data["dinner"]["otb"],
"forecast": day_data["dinner"]["forecast"],
"prior_otb": prior_dinner,
"prior_final": prior_dinner
},
"total": {
"otb": day_data["totals"]["otb"],
"forecast": day_data["totals"]["forecast"],
"prior_final": prior_breakfast + prior_lunch + prior_dinner
}
})
return {"data": data}
@router.get("/forecast/revenue")
async def get_revenue_forecast(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
type: str = Query("all", description="Revenue type: all, accom, dry, wet"),
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Get forecasted revenue by type for external applications.
Returns: OTB revenue, forecast revenue, prior year actuals, budget
"""
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()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
end = start + timedelta(days=days - 1)
today = date.today()
VAT_RATE = 1.20
# 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:
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:
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
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_by_date = {row.date: row for row in actual_result.fetchall()}
# Get prior year 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_by_date = {row.date: row for row in prior_result.fetchall()}
# Get budgets
budget_types = []
if type in ['all', 'accom']:
budget_types.append('net_accom')
if type in ['all', 'dry']:
budget_types.append('net_dry')
if type in ['all', 'wet']:
budget_types.append('net_wet')
if type == 'all':
budget_types.append('total_rev')
budget_result = await db.execute(
text("""
SELECT date, budget_type, budget_value
FROM daily_budgets
WHERE date >= :start_date AND date <= :end_date
AND budget_type = ANY(:types)
"""),
{"start_date": start, "end_date": end, "types": budget_types if budget_types else ['']}
)
budget_data = {}
for row in budget_result.fetchall():
if row.date not in budget_data:
budget_data[row.date] = {}
budget_data[row.date][row.budget_type] = float(row.budget_value or 0)
# Get covers forecast for restaurant revenue
try:
covers_data = await forecast_covers_range(db, start, end, include_details=False)
covers_by_date = {c["date"]: c for c in covers_data.get("data", [])}
except Exception as e:
logger.warning(f"Covers forecast failed: {e}")
covers_by_date = {}
data = []
current = start
while current <= end:
is_past = current < today
lead_days = (current - today).days if current >= today else 0
prior_date = get_prior_year_date(current)
# Prior year actual
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
# Budget
day_budget = budget_data.get(current, {})
budget_accom = day_budget.get('net_accom', 0)
budget_dry = day_budget.get('net_dry', 0)
budget_wet = day_budget.get('net_wet', 0)
budget_total = day_budget.get('total_rev', budget_accom + budget_dry + budget_wet)
if is_past:
# Past: use actual revenue
actual_row = actual_by_date.get(current)
accom_otb = float(actual_row.accommodation) if actual_row and actual_row.accommodation else 0
dry_otb = float(actual_row.dry) if actual_row and actual_row.dry else 0
wet_otb = float(actual_row.wet) if actual_row and actual_row.wet else 0
accom_forecast = accom_otb
dry_forecast = dry_otb
wet_forecast = wet_otb
accom_prior_otb = prior_accom
else:
# Future: forecast
# Accommodation - use revenue forecast model
try:
accom_forecast_data = await forecast_revenue_for_date(
db, current, lead_days, prior_date
)
accom_otb = accom_forecast_data.get('current_otb_rev', 0) or 0
accom_pickup = accom_forecast_data.get('forecast_pickup_rev', 0) or 0
accom_forecast = accom_otb + accom_pickup
accom_prior_otb = accom_forecast_data.get('prior_year_otb_rev', 0) or 0
except Exception:
accom_otb = accom_forecast = accom_prior_otb = 0
# Restaurant
day_covers = covers_by_date.get(current.isoformat())
if day_covers:
breakfast_otb = day_covers["breakfast"]["otb"]
lunch_otb = day_covers["lunch"]["otb"]
dinner_otb = day_covers["dinner"]["otb"]
breakfast_forecast = day_covers["breakfast"]["forecast"]
lunch_forecast = day_covers["lunch"]["forecast"]
dinner_forecast = day_covers["dinner"]["forecast"]
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_forecast = (
breakfast_forecast * get_spend_by_period('breakfast', 'dry') +
lunch_forecast * get_spend_by_period('lunch', 'dry') +
dinner_forecast * 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_forecast = (
breakfast_forecast * get_spend_by_period('breakfast', 'wet') +
lunch_forecast * get_spend_by_period('lunch', 'wet') +
dinner_forecast * get_spend_by_period('dinner', 'wet')
)
else:
dry_otb = dry_forecast = wet_otb = wet_forecast = 0
day_data = {
"date": current.isoformat(),
"day": current.strftime("%A"),
"is_past": is_past,
"lead_days": lead_days,
}
if type in ['all', 'accom']:
day_data["accom"] = {
"otb": round(accom_otb, 2),
"forecast": round(accom_forecast, 2),
"prior_otb": round(accom_prior_otb, 2) if not is_past else round(prior_accom, 2),
"prior_final": round(prior_accom, 2),
"budget": round(budget_accom, 2)
}
if type in ['all', 'dry']:
day_data["dry"] = {
"otb": round(dry_otb, 2),
"forecast": round(dry_forecast, 2),
"prior_final": round(prior_dry, 2),
"budget": round(budget_dry, 2)
}
if type in ['all', 'wet']:
day_data["wet"] = {
"otb": round(wet_otb, 2),
"forecast": round(wet_forecast, 2),
"prior_final": round(prior_wet, 2),
"budget": round(budget_wet, 2)
}
if type == 'all':
total_otb = accom_otb + dry_otb + wet_otb
total_forecast = accom_forecast + dry_forecast + wet_forecast
total_prior = prior_accom + prior_dry + prior_wet
day_data["total"] = {
"otb": round(total_otb, 2),
"forecast": round(total_forecast, 2),
"prior_final": round(total_prior, 2),
"budget": round(budget_total, 2)
}
data.append(day_data)
current += timedelta(days=1)
return {"data": data}
@router.get("/forecast/spend-rates")
async def get_spend_rates(
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Return spend-per-cover rates for each meal period.
Values are gross (inc VAT) from system_config, plus the VAT rate.
Kitchen app can divide by VAT rate to get net values.
"""
VAT_RATE = 1.20
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}
return {
"vat_rate": VAT_RATE,
"periods": {
"breakfast": {
"food_spend_gross": spend_settings.get("resos_breakfast_food_spend", 0),
"drinks_spend_gross": spend_settings.get("resos_breakfast_drinks_spend", 0),
"food_spend_net": round(spend_settings.get("resos_breakfast_food_spend", 0) / VAT_RATE, 2),
"drinks_spend_net": round(spend_settings.get("resos_breakfast_drinks_spend", 0) / VAT_RATE, 2),
},
"lunch": {
"food_spend_gross": spend_settings.get("resos_lunch_food_spend", 0),
"drinks_spend_gross": spend_settings.get("resos_lunch_drinks_spend", 0),
"food_spend_net": round(spend_settings.get("resos_lunch_food_spend", 0) / VAT_RATE, 2),
"drinks_spend_net": round(spend_settings.get("resos_lunch_drinks_spend", 0) / VAT_RATE, 2),
},
"dinner": {
"food_spend_gross": spend_settings.get("resos_dinner_food_spend", 0),
"drinks_spend_gross": spend_settings.get("resos_dinner_drinks_spend", 0),
"food_spend_net": round(spend_settings.get("resos_dinner_food_spend", 0) / VAT_RATE, 2),
"drinks_spend_net": round(spend_settings.get("resos_dinner_drinks_spend", 0) / VAT_RATE, 2),
},
}
}