forecasting/backend/api/historical.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

221 lines
7.3 KiB
Python

"""
Historical data API endpoints
Provides access to aggregated actual data from daily_occupancy and daily_covers
"""
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/occupancy")
async def get_occupancy_data(
from_date: Optional[date] = Query(None, description="Start date"),
to_date: Optional[date] = Query(None, description="End date"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get historical occupancy data from daily_occupancy table.
"""
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
query = """
SELECT
date,
total_rooms,
occupied_rooms,
occupancy_pct,
total_guests,
total_adults,
total_children,
total_infants,
arrival_count,
room_revenue,
adr,
revpar,
agr,
breakfast_allocation_qty,
dinner_allocation_qty,
by_room_type,
revenue_by_room_type
FROM daily_occupancy
WHERE date BETWEEN :from_date AND :to_date
ORDER BY date
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.date,
"total_rooms": row.total_rooms,
"occupied_rooms": row.occupied_rooms,
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct else 0,
"total_guests": row.total_guests,
"total_adults": row.total_adults,
"total_children": row.total_children,
"total_infants": row.total_infants,
"arrival_count": row.arrival_count,
"room_revenue": float(row.room_revenue) if row.room_revenue else 0,
"adr": float(row.adr) if row.adr else 0,
"revpar": float(row.revpar) if row.revpar else 0,
"agr": float(row.agr) if row.agr else 0,
"breakfast_allocation_qty": row.breakfast_allocation_qty,
"dinner_allocation_qty": row.dinner_allocation_qty,
"by_room_type": row.by_room_type,
"revenue_by_room_type": row.revenue_by_room_type
}
for row in rows
]
@router.get("/covers")
async def get_covers_data(
from_date: Optional[date] = Query(None, description="Start date"),
to_date: Optional[date] = Query(None, description="End date"),
service_period: Optional[str] = Query(None, description="Filter by: lunch, dinner"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get historical covers data from daily_covers table.
"""
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
query = """
SELECT
date,
service_period,
total_bookings,
total_covers,
avg_party_size,
hotel_guest_covers,
external_covers,
dbb_covers,
package_covers,
cancelled_bookings,
cancelled_covers,
no_show_bookings,
no_show_covers,
by_source
FROM daily_covers
WHERE date BETWEEN :from_date AND :to_date
"""
params = {"from_date": from_date, "to_date": to_date}
if service_period:
query += " AND service_period = :service_period"
params["service_period"] = service_period
query += " ORDER BY date, service_period"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"date": row.date,
"service_period": row.service_period,
"total_bookings": row.total_bookings,
"total_covers": row.total_covers,
"avg_party_size": float(row.avg_party_size) if row.avg_party_size else 0,
"hotel_guest_covers": row.hotel_guest_covers,
"external_covers": row.external_covers,
"dbb_covers": row.dbb_covers,
"package_covers": row.package_covers,
"cancelled_bookings": row.cancelled_bookings,
"cancelled_covers": row.cancelled_covers,
"no_show_bookings": row.no_show_bookings,
"no_show_covers": row.no_show_covers,
"by_source": row.by_source
}
for row in rows
]
@router.get("/summary")
async def get_daily_summary(
from_date: Optional[date] = Query(None, description="Start date"),
to_date: Optional[date] = Query(None, description="End date"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get combined daily summary with occupancy and covers data.
"""
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
query = """
SELECT
o.date,
EXTRACT(DOW FROM o.date) as day_of_week,
o.total_rooms,
o.available_rooms,
o.occupied_rooms,
o.occupancy_pct,
o.total_guests,
o.room_revenue,
o.adr,
o.revpar,
o.agr,
o.arrival_count,
o.breakfast_allocation_qty,
o.dinner_allocation_qty,
COALESCE(cl.total_covers, 0) as lunch_covers,
COALESCE(cd.total_covers, 0) as dinner_covers,
COALESCE(cl.total_bookings, 0) as lunch_bookings,
COALESCE(cd.total_bookings, 0) as dinner_bookings
FROM daily_occupancy o
LEFT JOIN daily_covers cl ON o.date = cl.date AND cl.service_period = 'lunch'
LEFT JOIN daily_covers cd ON o.date = cd.date AND cd.service_period = 'dinner'
WHERE o.date BETWEEN :from_date AND :to_date
ORDER BY o.date
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
return [
{
"date": row.date,
"day_of_week": day_names[int(row.day_of_week)],
"total_rooms": row.total_rooms,
"available_rooms": row.available_rooms,
"occupied_rooms": row.occupied_rooms,
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct else 0,
"total_guests": row.total_guests,
"room_revenue": float(row.room_revenue) if row.room_revenue else 0,
"adr": float(row.adr) if row.adr else 0,
"revpar": float(row.revpar) if row.revpar else 0,
"agr": float(row.agr) if row.agr else 0,
"arrival_count": row.arrival_count,
"breakfast_allocation_qty": row.breakfast_allocation_qty,
"dinner_allocation_qty": row.dinner_allocation_qty,
"lunch_covers": row.lunch_covers,
"dinner_covers": row.dinner_covers,
"lunch_bookings": row.lunch_bookings,
"dinner_bookings": row.dinner_bookings
}
for row in rows
]