Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)

FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View file

@ -0,0 +1,286 @@
"""
Forecast API Client Service
Handles communication with the external forecasting Docker app to fetch
forecasted revenue, rooms, and covers data for the Spend Budget feature.
API Endpoints: /public/forecast/revenue, /public/forecast/rooms, /public/forecast/covers
Auth: X-API-Key header
"""
import logging
import httpx
from datetime import date
from decimal import Decimal
from typing import Optional
logger = logging.getLogger(__name__)
class ForecastAPIError(Exception):
"""Custom exception for Forecast API errors"""
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
self.status_code = status_code
self.response_data = response_data
super().__init__(self.message)
class ForecastAPIClient:
"""
Async client for external Forecast API.
Usage:
async with ForecastAPIClient(base_url, api_key) as client:
forecast = await client.get_revenue_forecast(start_date, days=7)
"""
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self._client: httpx.AsyncClient = None
async def __aenter__(self):
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0, connect=15.0),
follow_redirects=True,
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._client:
await self._client.aclose()
async def _request(
self,
endpoint: str,
params: dict = None,
method: str = "GET"
) -> dict:
"""
Make an authenticated request to the Forecast API.
All requests include X-API-Key header for authentication.
"""
url = f"{self.base_url}{endpoint}"
headers = {"X-API-Key": self.api_key}
try:
logger.info(f"Forecast API request: {method} {endpoint}")
if method == "GET":
response = await self._client.get(url, params=params, headers=headers)
else:
response = await self._client.post(url, json=params, headers=headers)
if response.status_code == 401:
raise ForecastAPIError("Authentication failed. Check API key.", 401)
if response.status_code == 403:
raise ForecastAPIError("Access denied. Check API key permissions.", 403)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"Forecast API HTTP error: {e.response.status_code}")
raise ForecastAPIError(f"HTTP {e.response.status_code}: {str(e)}", e.response.status_code)
except httpx.RequestError as e:
logger.error(f"Forecast API request error: {e}")
raise ForecastAPIError(f"Request failed: {str(e)}")
async def test_connection(self) -> tuple[bool, str]:
"""
Test API connection by making a minimal forecast request.
Returns (success, message) tuple.
"""
try:
# Request just 1 day of forecast to test connection
await self.get_revenue_forecast(date.today(), days=1)
return True, "Connection successful"
except ForecastAPIError as e:
return False, str(e.message)
except Exception as e:
return False, f"Connection failed: {str(e)}"
async def get_revenue_forecast(
self,
start_date: date,
days: int = 7,
revenue_type: str = "all"
) -> list[dict]:
"""
Fetch revenue forecast from /public/forecast/revenue
Args:
start_date: Start date for forecast
days: Number of days to fetch (default 7 for a week)
revenue_type: Type filter - "all", "dry", "wet", "total"
Returns list of daily forecasts with:
- date: ISO date string
- day: Day name
- lead_days: Days from today
- dry: {otb, forecast, prior_final, budget}
- wet: {otb, forecast, prior_final, budget}
- total: {otb, forecast, prior_final, budget}
"""
params = {
"start_date": start_date.isoformat(),
"days": days,
}
if revenue_type != "all":
params["type"] = revenue_type
response = await self._request("/public/forecast/revenue", params)
# Response format: {"data": [...], "meta": {...}}
data = response.get("data", [])
logger.info(f"Fetched {len(data)} days of revenue forecast from {start_date}")
return data
def calculate_food_revenue(self, forecast_data: list[dict]) -> tuple[Decimal, Decimal]:
"""
Calculate total food revenue (dry only) from forecast data.
Args:
forecast_data: List of daily forecasts from get_revenue_forecast
Returns tuple of (otb_revenue, forecast_revenue):
- otb_revenue: On The Books (current bookings only) - conservative minimum
- forecast_revenue: Full forecast including expected pickup
"""
total_otb = Decimal("0")
total_forecast = Decimal("0")
for day in forecast_data:
# Get dry values only (wet is beverages, not food cost)
dry = day.get("dry", {})
# OTB is current bookings, forecast includes expected pickup
dry_otb = Decimal(str(dry.get("otb", 0) or 0))
dry_forecast = Decimal(str(dry.get("forecast", 0) or 0))
total_otb += dry_otb
total_forecast += dry_forecast
return total_otb, total_forecast
async def get_rooms_forecast(
self,
start_date: date,
days: int = 7,
) -> list[dict]:
"""
Fetch rooms forecast from /public/forecast/rooms
Returns list of daily data with:
- otb_rooms, pickup_rooms, forecast_rooms
- otb_guests, pickup_guests, forecast_guests
"""
params = {
"start_date": start_date.isoformat(),
"days": days,
}
response = await self._request("/public/forecast/rooms", params)
data = response.get("data", [])
logger.info(f"Fetched {len(data)} days of rooms forecast from {start_date}")
return data
async def get_covers_forecast(
self,
start_date: date,
days: int = 7,
) -> list[dict]:
"""
Fetch covers forecast from /public/forecast/covers
Returns list of daily data with breakfast, lunch, dinner:
- otb, forecast per period
"""
params = {
"start_date": start_date.isoformat(),
"days": days,
}
response = await self._request("/public/forecast/covers", params)
data = response.get("data", [])
logger.info(f"Fetched {len(data)} days of covers forecast from {start_date}")
return data
def aggregate_rooms(self, rooms_data: list[dict]) -> dict:
"""Aggregate weekly room/guest totals from daily rooms forecast."""
totals = {
"otb_rooms": 0, "pickup_rooms": 0, "forecast_rooms": 0,
"otb_guests": 0, "pickup_guests": 0, "forecast_guests": 0,
}
for day in rooms_data:
totals["otb_rooms"] += day.get("otb_rooms", 0) or 0
totals["pickup_rooms"] += day.get("pickup_rooms", 0) or 0
totals["forecast_rooms"] += day.get("forecast_rooms", 0) or 0
totals["otb_guests"] += day.get("otb_guests", 0) or 0
totals["pickup_guests"] += day.get("pickup_guests", 0) or 0
totals["forecast_guests"] += day.get("forecast_guests", 0) or 0
return totals
def aggregate_covers(self, covers_data: list[dict]) -> dict:
"""Aggregate weekly covers totals from daily covers forecast."""
totals = {
"breakfast": {"otb": 0, "pickup": 0, "forecast": 0},
"lunch": {"otb": 0, "pickup": 0, "forecast": 0},
"dinner": {"otb": 0, "pickup": 0, "forecast": 0},
}
for day in covers_data:
for period in ("breakfast", "lunch", "dinner"):
p = day.get(period, {})
otb = p.get("otb", 0) or 0
forecast = p.get("forecast", 0) or 0
totals[period]["otb"] += otb
totals[period]["pickup"] += forecast - otb
totals[period]["forecast"] += forecast
return totals
async def get_spend_rates(self) -> dict:
"""
Fetch spend-per-cover rates from /public/forecast/spend-rates
Returns dict with:
- vat_rate: float
- periods: {breakfast/lunch/dinner: {food_spend_gross, drinks_spend_gross, food_spend_net, drinks_spend_net}}
"""
response = await self._request("/public/forecast/spend-rates")
logger.info("Fetched spend rates from forecast API")
return response
def get_daily_breakdown(self, forecast_data: list[dict]) -> list[dict]:
"""
Process forecast data into daily revenue breakdown for budget tracking.
Args:
forecast_data: List of daily forecasts from get_revenue_forecast
Returns list of daily data with:
- date: ISO date string
- day_name: Day name (Mon, Tue, etc.)
- forecast_revenue: dry + wet forecast
- forecast_dry: dry forecast only
- forecast_wet: wet forecast only
"""
daily = []
for day in forecast_data:
dry = day.get("dry", {})
wet = day.get("wet", {})
dry_forecast = Decimal(str(dry.get("forecast", 0) or 0))
wet_forecast = Decimal(str(wet.get("forecast", 0) or 0))
daily.append({
"date": day.get("date"),
"day_name": day.get("day", ""),
"forecast_revenue": dry_forecast + wet_forecast,
"forecast_dry": dry_forecast,
"forecast_wet": wet_forecast,
})
return daily