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>
648 lines
24 KiB
Python
648 lines
24 KiB
Python
"""
|
|
Cover Override API endpoints.
|
|
|
|
Handles forecast snapshots, cover overrides, and spend rate overrides
|
|
for the Spend Budget feature.
|
|
"""
|
|
from datetime import date, timedelta
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import text, select, delete
|
|
from pydantic import BaseModel
|
|
|
|
from database import get_db
|
|
from models.user import User
|
|
from models.settings import KitchenSettings
|
|
from auth import get_current_user, require_cap
|
|
from services.forecast_api import ForecastAPIClient, ForecastAPIError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# --- Request/Response Models ---
|
|
|
|
class SnapshotRequest(BaseModel):
|
|
week_offset: int = 0
|
|
|
|
|
|
class CoverOverrideRequest(BaseModel):
|
|
override_date: str # YYYY-MM-DD
|
|
period: str # 'lunch' or 'dinner'
|
|
override_covers: int # target total covers
|
|
|
|
|
|
class SpendRateOverrideRequest(BaseModel):
|
|
week_offset: int = 0
|
|
period: str # 'breakfast', 'lunch', or 'dinner'
|
|
food_spend: Optional[float] = None
|
|
drinks_spend: Optional[float] = None
|
|
|
|
|
|
class SnapshotData(BaseModel):
|
|
date: str
|
|
period: str
|
|
forecast_covers: int
|
|
otb_covers: int
|
|
food_spend: Optional[float]
|
|
drinks_spend: Optional[float]
|
|
forecast_dry_revenue: Optional[float]
|
|
|
|
|
|
class OverrideData(BaseModel):
|
|
id: int
|
|
override_date: str
|
|
period: str
|
|
override_covers: int
|
|
original_forecast: Optional[int]
|
|
original_otb: Optional[int]
|
|
|
|
|
|
class SpendRateData(BaseModel):
|
|
period: str
|
|
food_spend_api: Optional[float]
|
|
drinks_spend_api: Optional[float]
|
|
food_spend_snapshot: Optional[float]
|
|
drinks_spend_snapshot: Optional[float]
|
|
food_spend_override: Optional[float]
|
|
drinks_spend_override: Optional[float]
|
|
food_spend_effective: float
|
|
drinks_spend_effective: float
|
|
|
|
|
|
class RecalcDay(BaseModel):
|
|
date: str
|
|
day_name: str
|
|
is_past: bool
|
|
periods: dict # period -> {actual, otb, pickup, effective, override, snapshot, variance}
|
|
day_revenue: float
|
|
|
|
|
|
class WeeklyOverrideResponse(BaseModel):
|
|
week_start: str
|
|
week_end: str
|
|
has_snapshot: bool
|
|
vat_rate: float = 1.20
|
|
snapshot_revenue: Optional[float] = None
|
|
adjusted_revenue: Optional[float] = None
|
|
snapshots: list[SnapshotData] = []
|
|
overrides: list[OverrideData] = []
|
|
spend_rates: list[SpendRateData] = []
|
|
recalc_days: list[RecalcDay] = []
|
|
|
|
|
|
# --- Helpers ---
|
|
|
|
def get_week_dates(week_offset: int = 0) -> tuple[date, date, list[date]]:
|
|
today = date.today()
|
|
current_monday = today - timedelta(days=today.weekday())
|
|
week_start = current_monday + timedelta(weeks=week_offset)
|
|
week_end = week_start + timedelta(days=6)
|
|
week_dates = [week_start + timedelta(days=i) for i in range(7)]
|
|
return week_start, week_end, week_dates
|
|
|
|
|
|
async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings:
|
|
result = await db.execute(
|
|
select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
|
|
)
|
|
settings = result.scalar_one_or_none()
|
|
if not settings:
|
|
raise HTTPException(status_code=404, detail="Kitchen settings not found")
|
|
return settings
|
|
|
|
|
|
# --- Endpoints ---
|
|
|
|
@router.get("/weekly", response_model=WeeklyOverrideResponse)
|
|
async def get_weekly_overrides(
|
|
week_offset: int = 0,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get snapshot, overrides, spend rates, and recalculated breakdown for a week."""
|
|
week_start, week_end, week_dates = get_week_dates(week_offset)
|
|
kitchen_id = current_user.kitchen_id
|
|
today = date.today()
|
|
|
|
# Check if snapshot exists
|
|
snap_result = await db.execute(text("""
|
|
SELECT snapshot_date, period, forecast_covers, otb_covers,
|
|
food_spend, drinks_spend, forecast_dry_revenue
|
|
FROM forecast_snapshots
|
|
WHERE kitchen_id = :kid AND week_start = :ws
|
|
ORDER BY snapshot_date, period
|
|
"""), {"kid": kitchen_id, "ws": week_start})
|
|
snap_rows = snap_result.fetchall()
|
|
|
|
has_snapshot = len(snap_rows) > 0
|
|
snapshots = []
|
|
snap_lookup = {} # (date_str, period) -> row
|
|
|
|
for row in snap_rows:
|
|
date_str = row.snapshot_date.isoformat() if hasattr(row.snapshot_date, 'isoformat') else str(row.snapshot_date)
|
|
snapshots.append(SnapshotData(
|
|
date=date_str,
|
|
period=row.period,
|
|
forecast_covers=row.forecast_covers,
|
|
otb_covers=row.otb_covers,
|
|
food_spend=float(row.food_spend) if row.food_spend else None,
|
|
drinks_spend=float(row.drinks_spend) if row.drinks_spend else None,
|
|
forecast_dry_revenue=float(row.forecast_dry_revenue) if row.forecast_dry_revenue else None,
|
|
))
|
|
snap_lookup[(date_str, row.period)] = row
|
|
|
|
# Get week snapshot for revenue totals
|
|
week_snap_result = await db.execute(text("""
|
|
SELECT total_forecast_revenue, total_otb_revenue, gp_target
|
|
FROM forecast_week_snapshots
|
|
WHERE kitchen_id = :kid AND week_start = :ws
|
|
"""), {"kid": kitchen_id, "ws": week_start})
|
|
week_snap = week_snap_result.fetchone()
|
|
snapshot_revenue = float(week_snap.total_forecast_revenue) if week_snap and week_snap.total_forecast_revenue else None
|
|
|
|
# Get overrides
|
|
override_result = await db.execute(text("""
|
|
SELECT id, override_date, period, override_covers, original_forecast, original_otb
|
|
FROM cover_overrides
|
|
WHERE kitchen_id = :kid
|
|
AND override_date >= :ws AND override_date <= :we
|
|
ORDER BY override_date, period
|
|
"""), {"kid": kitchen_id, "ws": week_start, "we": week_end})
|
|
override_rows = override_result.fetchall()
|
|
|
|
overrides = []
|
|
override_lookup = {} # (date_str, period) -> row
|
|
for row in override_rows:
|
|
date_str = row.override_date.isoformat() if hasattr(row.override_date, 'isoformat') else str(row.override_date)
|
|
overrides.append(OverrideData(
|
|
id=row.id,
|
|
override_date=date_str,
|
|
period=row.period,
|
|
override_covers=row.override_covers,
|
|
original_forecast=row.original_forecast,
|
|
original_otb=row.original_otb,
|
|
))
|
|
override_lookup[(date_str, row.period)] = row
|
|
|
|
# Get spend rate overrides
|
|
spend_override_result = await db.execute(text("""
|
|
SELECT id, period, food_spend, drinks_spend
|
|
FROM spend_rate_overrides
|
|
WHERE kitchen_id = :kid AND week_start = :ws
|
|
"""), {"kid": kitchen_id, "ws": week_start})
|
|
spend_override_rows = spend_override_result.fetchall()
|
|
spend_override_lookup = {row.period: row for row in spend_override_rows}
|
|
|
|
# Fetch live forecast data + spend rates from API
|
|
settings = await get_settings(db, kitchen_id)
|
|
api_spend_rates = {}
|
|
api_vat_rate = 1.20
|
|
covers_data = []
|
|
revenue_data = []
|
|
|
|
if settings.forecast_api_url and settings.forecast_api_key:
|
|
try:
|
|
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
|
try:
|
|
sr = await client.get_spend_rates()
|
|
api_spend_rates = sr.get("periods", {})
|
|
api_vat_rate = sr.get("vat_rate", 1.20)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch spend rates: {e}")
|
|
|
|
try:
|
|
covers_data = await client.get_covers_forecast(week_start, days=7)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch covers: {e}")
|
|
|
|
try:
|
|
revenue_data = await client.get_revenue_forecast(week_start, days=7)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch revenue: {e}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to connect to forecast API: {e}")
|
|
|
|
# Build spend rates response (per period)
|
|
spend_rates = []
|
|
spend_effective = {} # period -> {food, drinks}
|
|
for period in ("breakfast", "lunch", "dinner"):
|
|
api_food = api_spend_rates.get(period, {}).get("food_spend_net", 0)
|
|
api_drinks = api_spend_rates.get(period, {}).get("drinks_spend_net", 0)
|
|
|
|
# Get snapshot values (from first day of snapshot, same for all days in a period)
|
|
snap_food = None
|
|
snap_drinks = None
|
|
for d in week_dates:
|
|
key = (d.isoformat(), period)
|
|
if key in snap_lookup:
|
|
snap_food = float(snap_lookup[key].food_spend) if snap_lookup[key].food_spend else None
|
|
snap_drinks = float(snap_lookup[key].drinks_spend) if snap_lookup[key].drinks_spend else None
|
|
break
|
|
|
|
# Get override values
|
|
ovr = spend_override_lookup.get(period)
|
|
ovr_food = float(ovr.food_spend) if ovr and ovr.food_spend else None
|
|
ovr_drinks = float(ovr.drinks_spend) if ovr and ovr.drinks_spend else None
|
|
|
|
# Resolve effective: override > snapshot > API
|
|
eff_food = ovr_food if ovr_food is not None else (snap_food if snap_food is not None else api_food)
|
|
eff_drinks = ovr_drinks if ovr_drinks is not None else (snap_drinks if snap_drinks is not None else api_drinks)
|
|
|
|
spend_effective[period] = {"food": eff_food, "drinks": eff_drinks}
|
|
spend_rates.append(SpendRateData(
|
|
period=period,
|
|
food_spend_api=api_food,
|
|
drinks_spend_api=api_drinks,
|
|
food_spend_snapshot=snap_food,
|
|
drinks_spend_snapshot=snap_drinks,
|
|
food_spend_override=ovr_food,
|
|
drinks_spend_override=ovr_drinks,
|
|
food_spend_effective=eff_food,
|
|
drinks_spend_effective=eff_drinks,
|
|
))
|
|
|
|
# Build recalculated days
|
|
covers_by_date = {d.get("date", ""): d for d in covers_data}
|
|
revenue_by_date = {d.get("date", ""): d for d in revenue_data}
|
|
recalc_days = []
|
|
total_adjusted_revenue = Decimal("0")
|
|
|
|
for d in week_dates:
|
|
date_str = d.isoformat()
|
|
is_past = d < today
|
|
day_covers = covers_by_date.get(date_str, {})
|
|
day_revenue = revenue_by_date.get(date_str, {})
|
|
day_name = day_covers.get("day", d.strftime("%a"))
|
|
|
|
periods_data = {}
|
|
day_rev = Decimal("0")
|
|
|
|
for period in ("breakfast", "lunch", "dinner"):
|
|
p_covers = day_covers.get(period, {})
|
|
otb = p_covers.get("otb", 0) or 0
|
|
forecast = p_covers.get("forecast", 0) or 0
|
|
pickup = forecast - otb
|
|
|
|
# Get snapshot and override for this day/period
|
|
snap = snap_lookup.get((date_str, period))
|
|
ovr = override_lookup.get((date_str, period))
|
|
|
|
snap_forecast = snap.forecast_covers if snap else None
|
|
override_val = ovr.override_covers if ovr else None
|
|
|
|
if is_past:
|
|
# Past day: use actual (forecast value from API is actual for past dates)
|
|
effective = forecast
|
|
actual = forecast
|
|
# Calculate variance vs override or snapshot
|
|
variance = None
|
|
if override_val is not None:
|
|
diff = actual - override_val
|
|
if diff != 0:
|
|
variance = diff
|
|
elif snap_forecast is not None:
|
|
diff = actual - snap_forecast
|
|
if diff != 0:
|
|
variance = diff
|
|
|
|
# Past revenue from API (dry revenue)
|
|
dry = day_revenue.get("dry", {})
|
|
period_rev = Decimal(str(dry.get("forecast", 0) or 0)) if period == "breakfast" else Decimal("0")
|
|
# For past days, use actual total revenue from the API (proportioned by period isn't available,
|
|
# so we'll use effective_covers * spend_rates as approximation, but actual total from revenue API)
|
|
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
|
period_rev = Decimal(str(effective)) * eff_food
|
|
|
|
periods_data[period] = {
|
|
"actual": actual,
|
|
"otb": otb,
|
|
"pickup": 0,
|
|
"effective": effective,
|
|
"override": override_val,
|
|
"snapshot": snap_forecast,
|
|
"variance": variance,
|
|
"is_overridden": False,
|
|
}
|
|
else:
|
|
# Today/future: apply override logic
|
|
if override_val is not None:
|
|
if otb >= override_val:
|
|
effective = otb # OTB supersedes upward
|
|
adj_pickup = 0
|
|
else:
|
|
effective = override_val
|
|
adj_pickup = override_val - otb
|
|
is_overridden = True
|
|
else:
|
|
effective = forecast
|
|
adj_pickup = pickup
|
|
is_overridden = False
|
|
|
|
# Calculate revenue for this period (dry/food only - budget tracks food revenue)
|
|
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
|
period_rev = Decimal(str(effective)) * eff_food
|
|
|
|
periods_data[period] = {
|
|
"actual": None,
|
|
"otb": otb,
|
|
"pickup": adj_pickup,
|
|
"effective": effective,
|
|
"override": override_val,
|
|
"snapshot": snap_forecast,
|
|
"variance": None,
|
|
"is_overridden": is_overridden,
|
|
}
|
|
|
|
day_rev += period_rev
|
|
|
|
# For past days, use actual revenue from API if available
|
|
if is_past and day_revenue:
|
|
dry = day_revenue.get("dry", {})
|
|
actual_dry_rev = Decimal(str(dry.get("forecast", 0) or 0))
|
|
if actual_dry_rev > 0:
|
|
day_rev = actual_dry_rev
|
|
|
|
total_adjusted_revenue += day_rev
|
|
|
|
recalc_days.append(RecalcDay(
|
|
date=date_str,
|
|
day_name=day_name,
|
|
is_past=is_past,
|
|
periods=periods_data,
|
|
day_revenue=float(round(day_rev, 2)),
|
|
))
|
|
|
|
return WeeklyOverrideResponse(
|
|
week_start=week_start.isoformat(),
|
|
week_end=week_end.isoformat(),
|
|
has_snapshot=has_snapshot,
|
|
vat_rate=api_vat_rate,
|
|
snapshot_revenue=snapshot_revenue,
|
|
adjusted_revenue=float(round(total_adjusted_revenue, 2)),
|
|
snapshots=snapshots,
|
|
overrides=overrides,
|
|
spend_rates=spend_rates,
|
|
recalc_days=recalc_days,
|
|
)
|
|
|
|
|
|
@router.post("/snapshot")
|
|
async def create_snapshot(
|
|
req: SnapshotRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Take a snapshot of the current forecast for a week (all periods, all days)."""
|
|
week_start, week_end, week_dates = get_week_dates(req.week_offset)
|
|
kitchen_id = current_user.kitchen_id
|
|
settings = await get_settings(db, kitchen_id)
|
|
|
|
if not settings.forecast_api_url or not settings.forecast_api_key:
|
|
raise HTTPException(status_code=400, detail="Forecast API not configured")
|
|
|
|
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
|
covers_data = await client.get_covers_forecast(week_start, days=7)
|
|
revenue_data = await client.get_revenue_forecast(week_start, days=7)
|
|
spend_rates_response = await client.get_spend_rates()
|
|
|
|
api_spend = spend_rates_response.get("periods", {})
|
|
vat_rate = spend_rates_response.get("vat_rate", 1.20)
|
|
|
|
covers_by_date = {d.get("date", ""): d for d in covers_data}
|
|
revenue_by_date = {d.get("date", ""): d for d in revenue_data}
|
|
|
|
# Delete existing snapshots for this week (re-snapshot)
|
|
await db.execute(text("""
|
|
DELETE FROM forecast_snapshots
|
|
WHERE kitchen_id = :kid AND week_start = :ws
|
|
"""), {"kid": kitchen_id, "ws": week_start})
|
|
|
|
# Insert snapshot for each day/period
|
|
total_forecast_rev = Decimal("0")
|
|
total_otb_rev = Decimal("0")
|
|
|
|
for d in week_dates:
|
|
date_str = d.isoformat()
|
|
day_covers = covers_by_date.get(date_str, {})
|
|
day_revenue = revenue_by_date.get(date_str, {})
|
|
|
|
for period in ("breakfast", "lunch", "dinner"):
|
|
p = day_covers.get(period, {})
|
|
otb = p.get("otb", 0) or 0
|
|
forecast = p.get("forecast", 0) or 0
|
|
|
|
# Spend rates (net, ex VAT)
|
|
food_net = api_spend.get(period, {}).get("food_spend_net", 0)
|
|
drinks_net = api_spend.get(period, {}).get("drinks_spend_net", 0)
|
|
|
|
# Calculate dry revenue for this period/day
|
|
dry_rev = Decimal(str(forecast)) * Decimal(str(food_net))
|
|
|
|
await db.execute(text("""
|
|
INSERT INTO forecast_snapshots
|
|
(kitchen_id, snapshot_date, period, forecast_covers, otb_covers,
|
|
food_spend, drinks_spend, forecast_dry_revenue, week_start)
|
|
VALUES (:kid, :sd, :period, :fc, :oc, :fs, :ds, :dr, :ws)
|
|
"""), {
|
|
"kid": kitchen_id, "sd": d, "period": period,
|
|
"fc": forecast, "oc": otb,
|
|
"fs": food_net, "ds": drinks_net,
|
|
"dr": float(round(dry_rev, 2)),
|
|
"ws": week_start,
|
|
})
|
|
|
|
# Accumulate weekly totals from revenue API
|
|
dry = day_revenue.get("dry", {})
|
|
total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
|
|
total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
|
|
|
|
# Divide by 3 since we're iterating 3 periods but revenue data is per-day total
|
|
# Actually, the revenue API returns per-day totals, not per-period.
|
|
# We accumulated 3x per day. Let's recalculate from revenue_data directly.
|
|
total_forecast_rev = Decimal("0")
|
|
total_otb_rev = Decimal("0")
|
|
for d in revenue_data:
|
|
dry = d.get("dry", {})
|
|
total_forecast_rev += Decimal(str(dry.get("forecast", 0) or 0))
|
|
total_otb_rev += Decimal(str(dry.get("otb", 0) or 0))
|
|
|
|
# Upsert week snapshot
|
|
gp_target = float(settings.budget_gp_target) if settings.budget_gp_target else 65.0
|
|
|
|
await db.execute(text("""
|
|
DELETE FROM forecast_week_snapshots
|
|
WHERE kitchen_id = :kid AND week_start = :ws
|
|
"""), {"kid": kitchen_id, "ws": week_start})
|
|
|
|
await db.execute(text("""
|
|
INSERT INTO forecast_week_snapshots
|
|
(kitchen_id, week_start, total_forecast_revenue, total_otb_revenue, gp_target)
|
|
VALUES (:kid, :ws, :tfr, :tor, :gp)
|
|
"""), {
|
|
"kid": kitchen_id, "ws": week_start,
|
|
"tfr": float(round(total_forecast_rev, 2)),
|
|
"tor": float(round(total_otb_rev, 2)),
|
|
"gp": gp_target,
|
|
})
|
|
|
|
await db.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Forecast snapshot created",
|
|
"week_start": week_start.isoformat(),
|
|
"total_forecast_revenue": float(round(total_forecast_rev, 2)),
|
|
"total_otb_revenue": float(round(total_otb_rev, 2)),
|
|
"days_snapshotted": len(week_dates),
|
|
"periods_per_day": 3,
|
|
}
|
|
|
|
|
|
@router.put("")
|
|
async def upsert_cover_override(
|
|
req: CoverOverrideRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Set or update a cover override for a specific date+period."""
|
|
kitchen_id = current_user.kitchen_id
|
|
|
|
if req.period not in ("lunch", "dinner"):
|
|
raise HTTPException(status_code=400, detail="Period must be 'lunch' or 'dinner'")
|
|
|
|
if req.override_covers < 0:
|
|
raise HTTPException(status_code=400, detail="Override covers cannot be negative")
|
|
|
|
override_date = date.fromisoformat(req.override_date)
|
|
|
|
# Check existing
|
|
existing = await db.execute(text("""
|
|
SELECT id FROM cover_overrides
|
|
WHERE kitchen_id = :kid AND override_date = :od AND period = :p
|
|
"""), {"kid": kitchen_id, "od": override_date, "p": req.period})
|
|
row = existing.fetchone()
|
|
|
|
if row:
|
|
await db.execute(text("""
|
|
UPDATE cover_overrides
|
|
SET override_covers = :oc, updated_by = :uid, updated_at = NOW()
|
|
WHERE id = :id
|
|
"""), {"oc": req.override_covers, "uid": current_user.id, "id": row.id})
|
|
else:
|
|
# Get current forecast for snapshot
|
|
settings = await get_settings(db, kitchen_id)
|
|
original_forecast = None
|
|
original_otb = None
|
|
|
|
if settings.forecast_api_url and settings.forecast_api_key:
|
|
try:
|
|
async with ForecastAPIClient(settings.forecast_api_url, settings.forecast_api_key) as client:
|
|
covers = await client.get_covers_forecast(override_date, days=1)
|
|
if covers:
|
|
p = covers[0].get(req.period, {})
|
|
original_forecast = p.get("forecast", 0) or 0
|
|
original_otb = p.get("otb", 0) or 0
|
|
except Exception as e:
|
|
logger.warning(f"Failed to get forecast for snapshot: {e}")
|
|
|
|
await db.execute(text("""
|
|
INSERT INTO cover_overrides
|
|
(kitchen_id, override_date, period, override_covers,
|
|
original_forecast, original_otb, created_by, updated_by)
|
|
VALUES (:kid, :od, :p, :oc, :of, :oo, :uid, :uid)
|
|
"""), {
|
|
"kid": kitchen_id, "od": override_date, "p": req.period,
|
|
"oc": req.override_covers, "of": original_forecast, "oo": original_otb,
|
|
"uid": current_user.id,
|
|
})
|
|
|
|
await db.commit()
|
|
return {"success": True, "date": req.override_date, "period": req.period, "covers": req.override_covers}
|
|
|
|
|
|
@router.delete("/{override_id}")
|
|
async def delete_cover_override(
|
|
override_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Remove a cover override (revert to forecast)."""
|
|
result = await db.execute(text("""
|
|
DELETE FROM cover_overrides
|
|
WHERE id = :id AND kitchen_id = :kid
|
|
RETURNING id
|
|
"""), {"id": override_id, "kid": current_user.kitchen_id})
|
|
deleted = result.fetchone()
|
|
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Override not found")
|
|
|
|
await db.commit()
|
|
return {"success": True, "deleted_id": override_id}
|
|
|
|
|
|
@router.put("/spend-rates")
|
|
async def upsert_spend_rate_override(
|
|
req: SpendRateOverrideRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Set or update a spend rate override for a week+period."""
|
|
kitchen_id = current_user.kitchen_id
|
|
week_start, _, _ = get_week_dates(req.week_offset)
|
|
|
|
if req.period not in ("breakfast", "lunch", "dinner"):
|
|
raise HTTPException(status_code=400, detail="Period must be 'breakfast', 'lunch', or 'dinner'")
|
|
|
|
# Check existing
|
|
existing = await db.execute(text("""
|
|
SELECT id FROM spend_rate_overrides
|
|
WHERE kitchen_id = :kid AND week_start = :ws AND period = :p
|
|
"""), {"kid": kitchen_id, "ws": week_start, "p": req.period})
|
|
row = existing.fetchone()
|
|
|
|
if row:
|
|
await db.execute(text("""
|
|
UPDATE spend_rate_overrides
|
|
SET food_spend = :fs, drinks_spend = :ds, updated_by = :uid, updated_at = NOW()
|
|
WHERE id = :id
|
|
"""), {"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id, "id": row.id})
|
|
else:
|
|
await db.execute(text("""
|
|
INSERT INTO spend_rate_overrides
|
|
(kitchen_id, week_start, period, food_spend, drinks_spend, created_by, updated_by)
|
|
VALUES (:kid, :ws, :p, :fs, :ds, :uid, :uid)
|
|
"""), {
|
|
"kid": kitchen_id, "ws": week_start, "p": req.period,
|
|
"fs": req.food_spend, "ds": req.drinks_spend, "uid": current_user.id,
|
|
})
|
|
|
|
await db.commit()
|
|
return {"success": True, "week_start": week_start.isoformat(), "period": req.period}
|
|
|
|
|
|
@router.delete("/spend-rates/{override_id}")
|
|
async def delete_spend_rate_override(
|
|
override_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Remove a spend rate override (revert to snapshot/API value)."""
|
|
result = await db.execute(text("""
|
|
DELETE FROM spend_rate_overrides
|
|
WHERE id = :id AND kitchen_id = :kid
|
|
RETURNING id
|
|
"""), {"id": override_id, "kid": current_user.kitchen_id})
|
|
deleted = result.fetchone()
|
|
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Spend rate override not found")
|
|
|
|
await db.commit()
|
|
return {"success": True, "deleted_id": override_id}
|