Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
221
backend/api/historical.py
Normal file
221
backend/api/historical.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"""
|
||||
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
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue