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:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
286
backend/services/forecast_api.py
Normal file
286
backend/services/forecast_api.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue