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:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

718
backend/api/resos.py Normal file
View file

@ -0,0 +1,718 @@
"""
Resos Mapping API Endpoints
Handles Resos custom field and opening hours configuration.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
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 services.resos_client import ResosClient, ResosAPIError
logger = logging.getLogger(__name__)
router = APIRouter()
# ============ Pydantic Schemas ============
class CustomFieldMappingItem(BaseModel):
field_id: str
field_name: str
field_type: str
maps_to: str # 'hotel_guest', 'dbb', 'booking_number', 'allergies', 'ignore'
value_for_true: Optional[str] = None # For radio/checkbox: which value means "yes"
class OpeningHoursMappingItem(BaseModel):
opening_hour_id: str
opening_hour_name: str
period_type: str # 'lunch', 'afternoon', 'dinner', 'ignore'
is_regular: bool = True
class MappingUpdate(BaseModel):
custom_fields: Optional[List[CustomFieldMappingItem]] = None
opening_hours: Optional[List[OpeningHoursMappingItem]] = None
# ============ Custom Fields Endpoints ============
@router.get("/custom-fields")
async def fetch_custom_fields(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Fetch custom field definitions from Resos API.
Returns fields that can be mapped to hotel_guest, dbb, etc.
"""
try:
async with await ResosClient.from_db(db) as client:
fields = await client.get_custom_field_definitions()
except ResosAPIError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to fetch Resos custom fields: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch custom fields from Resos")
# Return all custom fields (no filtering by type - let user decide what to map)
formatted_fields = [
{
"id": f.get("_id") or f.get("id"),
"name": f.get("name", ""),
"type": f.get("type", ""),
"values": f.get("choices", []) # For radio/dropdown fields
}
for f in fields
]
return formatted_fields
@router.get("/opening-hours")
async def fetch_opening_hours(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Fetch opening hours/service periods from Resos API.
Filters to regular (non-special) periods only.
"""
try:
async with await ResosClient.from_db(db) as client:
hours = await client.get_opening_hours()
except ResosAPIError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to fetch Resos opening hours: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch opening hours from Resos")
logger.info(f"Raw opening hours from Resos API: {len(hours)} periods")
# Filter out special/one-off periods - only return regular service periods
# special=True means one-off events, special=False means recurring
regular_hours = [h for h in hours if h.get('special') == False]
# Day of week mapping (Resos uses 1=Monday, 7=Sunday)
day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
# Transform and add helpful fields
formatted_hours = []
for hour in regular_hours:
hour_id = hour.get('_id') or hour.get('id', '')
hour_name = hour.get('name', '')
day_num = hour.get('day', 0)
# Convert open/close times from HHMM integers to HH:MM strings
start_time = None
end_time = None
if 'open' in hour:
open_val = hour['open']
hours_part = open_val // 100
mins_part = open_val % 100
start_time = f"{hours_part:02d}:{mins_part:02d}"
if 'close' in hour:
close_val = hour['close']
hours_part = close_val // 100
mins_part = close_val % 100
end_time = f"{hours_part:02d}:{mins_part:02d}"
formatted_hours.append({
"id": hour_id,
"name": hour_name,
"day": day_num,
"day_name": day_names[day_num] if 1 <= day_num <= 7 else "Unknown",
"start_time": start_time,
"end_time": end_time
})
# Sort by day of week first, then by open time
formatted_hours.sort(key=lambda h: (h.get('day', 0), h.get('start_time', '')))
logger.info(f"After filtering: {len(formatted_hours)} regular periods (filtered out {len(hours) - len(formatted_hours)} special periods)")
return formatted_hours
# ============ Mapping Storage Endpoints ============
@router.get("/custom-field-mapping")
async def get_custom_field_mappings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get saved custom field mappings."""
cf_result = await db.execute(text("""
SELECT field_id, field_name, field_type, maps_to, value_for_true
FROM resos_custom_field_mapping
ORDER BY field_name
"""))
custom_field_rows = cf_result.fetchall()
return [
{
"custom_field_id": row.field_id,
"mapping_type": row.maps_to,
"value_for_true": row.value_for_true
}
for row in custom_field_rows
]
@router.get("/opening-hours-mapping")
async def get_opening_hours_mappings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get saved opening hours mappings."""
oh_result = await db.execute(text("""
SELECT opening_hour_id, opening_hour_name, period_type, display_name, is_regular
FROM resos_opening_hours_mapping
ORDER BY opening_hour_name
"""))
opening_hour_rows = oh_result.fetchall()
return [
{
"opening_hour_id": row.opening_hour_id,
"opening_hour_name": row.opening_hour_name,
"period_type": row.period_type,
"display_name": row.display_name,
"is_regular": row.is_regular
}
for row in opening_hour_rows
]
@router.get("/manual-breakfast-periods")
async def get_manual_breakfast_periods(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get manual breakfast period configuration."""
# Get enabled flag
result = await db.execute(text("""
SELECT config_value FROM system_config WHERE config_key = 'resos_enable_manual_breakfast'
"""))
row = result.fetchone()
enabled = row.config_value.lower() == 'true' if row and row.config_value else False
# Get periods
result = await db.execute(text("""
SELECT day_of_week, start_time, end_time, is_active
FROM resos_manual_breakfast_periods
ORDER BY day_of_week
"""))
rows = result.fetchall()
periods = [
{
"day_of_week": row.day_of_week,
"start_time": str(row.start_time) if row.start_time else None,
"end_time": str(row.end_time) if row.end_time else None,
"is_active": row.is_active
}
for row in rows
]
return {
"enabled": enabled,
"periods": periods
}
@router.post("/custom-field-mapping")
async def save_custom_field_mappings(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save custom field mappings."""
mappings = data.get("mappings", [])
saved = 0
for mapping in mappings:
field_id = mapping.get("custom_field_id")
mapping_type = mapping.get("mapping_type")
value_for_true = mapping.get("value_for_true")
if not field_id or not mapping_type:
continue
await db.execute(text("""
INSERT INTO resos_custom_field_mapping
(field_id, field_name, field_type, maps_to, value_for_true, updated_at)
VALUES
(:field_id, '', '', :maps_to, :value_for_true, NOW())
ON CONFLICT (field_id) DO UPDATE SET
maps_to = EXCLUDED.maps_to,
value_for_true = EXCLUDED.value_for_true,
updated_at = NOW()
"""), {
"field_id": field_id,
"maps_to": mapping_type,
"value_for_true": value_for_true
})
saved += 1
await db.commit()
logger.info(f"Saved {saved} custom field mappings")
return {
"message": "Custom field mappings saved successfully",
"saved": saved
}
@router.post("/opening-hours-mapping")
async def save_opening_hours_mappings(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save opening hours mappings."""
mappings = data.get("mappings", [])
saved = 0
for mapping in mappings:
opening_hour_id = mapping.get("opening_hour_id")
period_type = mapping.get("period_type")
display_name = mapping.get("display_name")
if not opening_hour_id or not period_type:
continue
await db.execute(text("""
INSERT INTO resos_opening_hours_mapping
(opening_hour_id, opening_hour_name, period_type, display_name, is_regular, updated_at)
VALUES
(:opening_hour_id, '', :period_type, :display_name, TRUE, NOW())
ON CONFLICT (opening_hour_id) DO UPDATE SET
period_type = EXCLUDED.period_type,
display_name = EXCLUDED.display_name,
updated_at = NOW()
"""), {
"opening_hour_id": opening_hour_id,
"period_type": period_type,
"display_name": display_name
})
saved += 1
await db.commit()
logger.info(f"Saved {saved} opening hours mappings")
return {
"message": "Opening hours mappings saved successfully",
"saved": saved
}
@router.post("/manual-breakfast-periods")
async def save_manual_breakfast_periods(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save manual breakfast period configuration."""
from datetime import time as dt_time
enabled = data.get("enabled", False)
periods = data.get("periods", [])
# Save enabled flag to system_config
await db.execute(text("""
INSERT INTO system_config (config_key, config_value, description, updated_at)
VALUES ('resos_enable_manual_breakfast', :value, 'Enable manual breakfast configuration', NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_value = EXCLUDED.config_value,
updated_at = NOW()
"""), {"value": str(enabled).lower()})
# Save periods
for period in periods:
# Convert time strings to Python time objects for asyncpg
start_time_str = period.get("start_time")
end_time_str = period.get("end_time")
start_time_obj = None
end_time_obj = None
if start_time_str:
hour, minute = map(int, start_time_str.split(':'))
start_time_obj = dt_time(hour, minute)
if end_time_str:
hour, minute = map(int, end_time_str.split(':'))
end_time_obj = dt_time(hour, minute)
await db.execute(text("""
INSERT INTO resos_manual_breakfast_periods
(day_of_week, start_time, end_time, is_active, updated_at)
VALUES
(:day_of_week, :start_time, :end_time, :is_active, NOW())
ON CONFLICT (day_of_week) DO UPDATE SET
start_time = EXCLUDED.start_time,
end_time = EXCLUDED.end_time,
is_active = EXCLUDED.is_active,
updated_at = NOW()
"""), {
"day_of_week": period["day_of_week"],
"start_time": start_time_obj,
"end_time": end_time_obj,
"is_active": period.get("is_active", True)
})
await db.commit()
return {"message": "Manual breakfast periods saved successfully"}
@router.get("/mapping")
async def get_mappings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get saved custom field and opening hours mappings.
"""
# Get custom field mappings
cf_result = await db.execute(text("""
SELECT field_id, field_name, field_type, maps_to, value_for_true
FROM resos_custom_field_mapping
ORDER BY field_name
"""))
custom_field_rows = cf_result.fetchall()
# Get opening hours mappings
oh_result = await db.execute(text("""
SELECT opening_hour_id, opening_hour_name, period_type, is_regular
FROM resos_opening_hours_mapping
ORDER BY opening_hour_name
"""))
opening_hour_rows = oh_result.fetchall()
return {
"custom_fields": [
{
"field_id": row.field_id,
"field_name": row.field_name,
"field_type": row.field_type,
"maps_to": row.maps_to,
"value_for_true": row.value_for_true
}
for row in custom_field_rows
],
"opening_hours": [
{
"opening_hour_id": row.opening_hour_id,
"opening_hour_name": row.opening_hour_name,
"period_type": row.period_type,
"is_regular": row.is_regular
}
for row in opening_hour_rows
]
}
@router.post("/mapping")
async def save_mappings(
update: MappingUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Save custom field and opening hours mappings.
Uses upsert to update existing or insert new mappings.
"""
saved_cf = 0
saved_oh = 0
# Save custom field mappings
if update.custom_fields:
for cf in update.custom_fields:
await db.execute(text("""
INSERT INTO resos_custom_field_mapping
(field_id, field_name, field_type, maps_to, value_for_true, updated_at)
VALUES
(:field_id, :field_name, :field_type, :maps_to, :value_for_true, NOW())
ON CONFLICT (field_id) DO UPDATE SET
field_name = EXCLUDED.field_name,
field_type = EXCLUDED.field_type,
maps_to = EXCLUDED.maps_to,
value_for_true = EXCLUDED.value_for_true,
updated_at = NOW()
"""), {
"field_id": cf.field_id,
"field_name": cf.field_name,
"field_type": cf.field_type,
"maps_to": cf.maps_to,
"value_for_true": cf.value_for_true
})
saved_cf += 1
# Save opening hours mappings
if update.opening_hours:
for oh in update.opening_hours:
await db.execute(text("""
INSERT INTO resos_opening_hours_mapping
(opening_hour_id, opening_hour_name, period_type, is_regular, updated_at)
VALUES
(:opening_hour_id, :opening_hour_name, :period_type, :is_regular, NOW())
ON CONFLICT (opening_hour_id) DO UPDATE SET
opening_hour_name = EXCLUDED.opening_hour_name,
period_type = EXCLUDED.period_type,
is_regular = EXCLUDED.is_regular,
updated_at = NOW()
"""), {
"opening_hour_id": oh.opening_hour_id,
"opening_hour_name": oh.opening_hour_name,
"period_type": oh.period_type,
"is_regular": oh.is_regular
})
saved_oh += 1
await db.commit()
logger.info(f"Saved {saved_cf} custom field mappings, {saved_oh} opening hours mappings")
return {
"message": "Mappings saved successfully",
"custom_fields_saved": saved_cf,
"opening_hours_saved": saved_oh
}
@router.delete("/mapping/custom-field/{field_id}")
async def delete_custom_field_mapping(
field_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete a custom field mapping."""
await db.execute(text("""
DELETE FROM resos_custom_field_mapping WHERE field_id = :field_id
"""), {"field_id": field_id})
await db.commit()
return {"message": "Mapping deleted"}
@router.delete("/mapping/opening-hour/{opening_hour_id}")
async def delete_opening_hour_mapping(
opening_hour_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete an opening hours mapping."""
await db.execute(text("""
DELETE FROM resos_opening_hours_mapping WHERE opening_hour_id = :opening_hour_id
"""), {"opening_hour_id": opening_hour_id})
await db.commit()
return {"message": "Mapping deleted"}
# ============ Average Spend Settings Endpoints ============
@router.get("/average-spend")
async def get_average_spend(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get average spend settings for breakfast, lunch and dinner."""
# Get values from system_config
result = await db.execute(text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN (
'resos_breakfast_food_spend',
'resos_breakfast_drinks_spend',
'resos_lunch_food_spend',
'resos_lunch_drinks_spend',
'resos_dinner_food_spend',
'resos_dinner_drinks_spend'
)
"""))
rows = result.fetchall()
settings = {}
for row in rows:
key = row.config_key.replace('resos_', '')
try:
settings[key] = float(row.config_value) if row.config_value else 0
except (ValueError, TypeError):
settings[key] = 0
return {
"breakfast_food_spend": settings.get('breakfast_food_spend', 0),
"breakfast_drinks_spend": settings.get('breakfast_drinks_spend', 0),
"lunch_food_spend": settings.get('lunch_food_spend', 0),
"lunch_drinks_spend": settings.get('lunch_drinks_spend', 0),
"dinner_food_spend": settings.get('dinner_food_spend', 0),
"dinner_drinks_spend": settings.get('dinner_drinks_spend', 0)
}
@router.post("/average-spend")
async def save_average_spend(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save average spend settings for breakfast, lunch and dinner."""
settings = [
('resos_breakfast_food_spend', data.get('breakfast_food_spend', 0), 'Average food spend per cover for breakfast'),
('resos_breakfast_drinks_spend', data.get('breakfast_drinks_spend', 0), 'Average drinks spend per cover for breakfast'),
('resos_lunch_food_spend', data.get('lunch_food_spend', 0), 'Average food spend per cover for lunch'),
('resos_lunch_drinks_spend', data.get('lunch_drinks_spend', 0), 'Average drinks spend per cover for lunch'),
('resos_dinner_food_spend', data.get('dinner_food_spend', 0), 'Average food spend per cover for dinner'),
('resos_dinner_drinks_spend', data.get('dinner_drinks_spend', 0), 'Average drinks spend per cover for dinner'),
]
for config_key, config_value, description in settings:
await db.execute(text("""
INSERT INTO system_config (config_key, config_value, description, updated_at)
VALUES (:config_key, :config_value, :description, NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_value = EXCLUDED.config_value,
updated_at = NOW()
"""), {
"config_key": config_key,
"config_value": str(config_value),
"description": description
})
await db.commit()
logger.info("Saved average spend settings")
return {"message": "Average spend settings saved successfully"}
# ============ Pace Data Backfill Endpoint ============
@router.post("/backfill-pace")
async def backfill_pace_data(
start_date: str,
end_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Backfill resos_booking_pace table for historical dates.
This calculates pace snapshots using booking_placed timestamps
to reconstruct what was on the books at each lead time.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
"""
from datetime import datetime, timedelta
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
return {"error": "Invalid date format. Use YYYY-MM-DD"}
if (end - start).days > 400:
return {"error": "Date range too large. Max 400 days at a time."}
# Valid statuses and pace intervals
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
PACE_INTERVALS = [
365, 330, 300, 270, 240, 210,
177, 170, 163, 156, 149, 142, 135, 128, 121, 114,
107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37,
30, 29, 28, 27, 26, 25, 24, 23, 22, 21,
20, 19, 18, 17, 16, 15, 14, 13, 12, 11,
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
]
dates_processed = 0
current = start
while current <= end:
for pace_type in ['total', 'resident', 'non_resident']:
pace_values = {}
for days_out in PACE_INTERVALS:
snapshot_date = current - timedelta(days=days_out)
# Build query based on pace_type
if pace_type == 'total':
result = await db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN ('approved', 'arrived', 'seated', 'left')
AND booking_placed <= :snapshot_date
"""),
{"target_date": current, "snapshot_date": snapshot_date}
)
elif pace_type == 'resident':
result = await db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN ('approved', 'arrived', 'seated', 'left')
AND booking_placed <= :snapshot_date
AND is_hotel_guest = true
"""),
{"target_date": current, "snapshot_date": snapshot_date}
)
else: # non_resident
result = await db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN ('approved', 'arrived', 'seated', 'left')
AND booking_placed <= :snapshot_date
AND (is_hotel_guest = false OR is_hotel_guest IS NULL)
"""),
{"target_date": current, "snapshot_date": snapshot_date}
)
row = result.fetchone()
pace_values[f"d{days_out}"] = row.total_covers if row else 0
# Upsert pace record
columns = ", ".join(pace_values.keys())
placeholders = ", ".join([f":{k}" for k in pace_values.keys()])
updates = ", ".join([f"{k} = :{k}" for k in pace_values.keys()])
await db.execute(
text(f"""
INSERT INTO resos_booking_pace (booking_date, pace_type, {columns}, updated_at)
VALUES (:booking_date, :pace_type, {placeholders}, NOW())
ON CONFLICT (booking_date, pace_type) DO UPDATE SET
{updates},
updated_at = NOW()
"""),
{"booking_date": current, "pace_type": pace_type, **pace_values}
)
dates_processed += 1
current += timedelta(days=1)
# Commit every 30 days to avoid large transactions
if dates_processed % 30 == 0:
await db.commit()
logger.info(f"Backfill progress: {dates_processed} dates processed")
await db.commit()
logger.info(f"Pace backfill completed: {dates_processed} dates from {start_date} to {end_date}")
return {
"message": f"Pace data backfilled successfully",
"dates_processed": dates_processed,
"start_date": start_date,
"end_date": end_date
}