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>
1121 lines
45 KiB
Python
1121 lines
45 KiB
Python
"""
|
||
Budget API endpoints for Spend Budget feature.
|
||
|
||
Calculates spending budgets based on forecasted revenue and target GP%,
|
||
allocated to suppliers based on their historical spending percentage.
|
||
"""
|
||
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
from typing import Optional
|
||
from collections import defaultdict
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, case, and_, or_, text
|
||
from sqlalchemy.orm import selectinload
|
||
from pydantic import BaseModel, field_serializer
|
||
|
||
from database import get_db
|
||
from models.user import User
|
||
from models.invoice import Invoice, InvoiceStatus
|
||
from models.supplier import Supplier
|
||
from models.line_item import LineItem
|
||
from models.settings import KitchenSettings
|
||
from models.purchase_order import PurchaseOrder
|
||
from models.cost_distribution import CostDistribution, CostDistributionEntry, DistributionStatus
|
||
from auth import get_current_user, require_cap
|
||
from services.forecast_api import ForecastAPIClient, ForecastAPIError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# Response Models
|
||
|
||
class BudgetInvoice(BaseModel):
|
||
"""Individual invoice in budget table"""
|
||
id: int
|
||
invoice_number: Optional[str]
|
||
invoice_date: Optional[date]
|
||
net_stock: Decimal
|
||
document_type: Optional[str] = None
|
||
|
||
@field_serializer('net_stock')
|
||
def serialize_net_stock(self, v: Decimal) -> float:
|
||
return float(v)
|
||
|
||
|
||
class BudgetPOItem(BaseModel):
|
||
"""Individual PO in budget table"""
|
||
id: int
|
||
order_type: str
|
||
status: str
|
||
total_amount: Optional[Decimal]
|
||
order_reference: Optional[str]
|
||
|
||
@field_serializer('total_amount')
|
||
def serialize_total(self, v: Optional[Decimal]) -> Optional[float]:
|
||
return float(v) if v is not None else None
|
||
|
||
|
||
class SupplierBudgetRow(BaseModel):
|
||
"""Supplier row in weekly budget table"""
|
||
supplier_id: Optional[int]
|
||
supplier_name: str
|
||
historical_pct: Decimal # 4-week average percentage
|
||
allocated_budget: Decimal # total_budget * historical_pct
|
||
invoices_by_date: dict[str, list[BudgetInvoice]] # date -> invoices
|
||
purchase_orders_by_date: dict[str, list[BudgetPOItem]] # date -> POs
|
||
actual_spent: Decimal # Sum of invoices this week (excluding CD)
|
||
cd_adjustments_by_date: dict[str, float] = {} # date -> CD +/- for this supplier
|
||
cd_total: float = 0 # Total CD adjustment for this supplier
|
||
po_ordered: Decimal # Sum of pending PO totals this week
|
||
remaining: Decimal # allocated - (spent + cd) - po_ordered
|
||
status: str # "under", "on_track", "over"
|
||
|
||
@field_serializer('historical_pct', 'allocated_budget', 'actual_spent', 'po_ordered', 'remaining')
|
||
def serialize_decimals(self, v: Decimal) -> float:
|
||
return float(v)
|
||
|
||
|
||
class DailyBudgetData(BaseModel):
|
||
"""Daily budget tracking data"""
|
||
date: date
|
||
day_name: str
|
||
forecast_revenue: Decimal
|
||
budget_split_pct: Decimal # % of weekly budget this day gets (from historical spend)
|
||
historical_budget: Decimal # Budget allocated based on historical spend patterns
|
||
revenue_budget: Decimal # Budget allocated based on forecast revenue proportion
|
||
actual_spent: Optional[Decimal] # Only for past/today
|
||
cumulative_budget: Decimal
|
||
cumulative_spent: Optional[Decimal]
|
||
|
||
@field_serializer('forecast_revenue', 'budget_split_pct', 'historical_budget', 'revenue_budget', 'cumulative_budget')
|
||
def serialize_decimals(self, v: Decimal) -> float:
|
||
return float(v)
|
||
|
||
@field_serializer('actual_spent', 'cumulative_spent')
|
||
def serialize_optional_decimals(self, v: Optional[Decimal]) -> Optional[float]:
|
||
return float(v) if v is not None else None
|
||
|
||
|
||
class CoversSummary(BaseModel):
|
||
"""Covers summary for a meal period"""
|
||
otb: int
|
||
pickup: int
|
||
forecast: int
|
||
|
||
|
||
class DailyCoverData(BaseModel):
|
||
"""Daily covers breakdown for a single day"""
|
||
date: date
|
||
day_name: str
|
||
otb_rooms: int = 0
|
||
pickup_rooms: int = 0
|
||
otb_guests: int = 0
|
||
pickup_guests: int = 0
|
||
breakfast: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
|
||
lunch: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
|
||
dinner: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
|
||
|
||
|
||
class ForecastSummary(BaseModel):
|
||
"""Weekly forecast summary for rooms and covers"""
|
||
otb_rooms: int = 0
|
||
pickup_rooms: int = 0
|
||
forecast_rooms: int = 0
|
||
otb_guests: int = 0
|
||
pickup_guests: int = 0
|
||
forecast_guests: int = 0
|
||
breakfast: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
|
||
lunch: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
|
||
dinner: CoversSummary = CoversSummary(otb=0, pickup=0, forecast=0)
|
||
daily_covers: list[DailyCoverData] = []
|
||
|
||
|
||
class WeeklyBudgetResponse(BaseModel):
|
||
"""Response for weekly budget endpoint"""
|
||
week_start: date
|
||
week_end: date
|
||
dates: list[date] # All 7 days of the week
|
||
|
||
# Forecast data
|
||
otb_revenue: Decimal # On The Books revenue (current bookings only)
|
||
forecast_revenue: Decimal # Full forecast revenue (OTB + expected pickup)
|
||
forecast_source: str # "forecast_api" or "fallback"
|
||
|
||
# Forecast summary (rooms + covers)
|
||
forecast_summary: Optional[ForecastSummary] = None
|
||
|
||
# Override info
|
||
has_overrides: bool = False
|
||
snapshot_revenue: Optional[Decimal] = None # Original snapshotted revenue
|
||
adjusted_revenue: Optional[Decimal] = None # Revenue after applying overrides
|
||
|
||
# Budget calculation
|
||
gp_target_pct: Decimal # e.g., 65.00
|
||
min_budget: Decimal # Minimum budget based on OTB only
|
||
total_budget: Decimal # Full budget based on forecast
|
||
|
||
# Actuals
|
||
total_spent: Decimal # Actual spend from confirmed invoices
|
||
total_po_ordered: Decimal # Sum of pending PO totals
|
||
total_remaining: Decimal # budget - spent - po_ordered (negative = overspend)
|
||
cd_budget_reservation: Decimal = Decimal("0") # Budget reserved for cost distributions
|
||
|
||
# Supplier breakdown
|
||
suppliers: list[SupplierBudgetRow]
|
||
all_supplier_names: list[str]
|
||
|
||
# Daily breakdown
|
||
daily_data: list[DailyBudgetData]
|
||
daily_totals: dict[str, Decimal] # date -> actual spend
|
||
|
||
@field_serializer('otb_revenue', 'forecast_revenue', 'gp_target_pct', 'min_budget', 'total_budget', 'total_spent', 'total_po_ordered', 'total_remaining', 'cd_budget_reservation')
|
||
def serialize_decimals(self, v: Decimal) -> float:
|
||
return float(v)
|
||
|
||
@field_serializer('snapshot_revenue', 'adjusted_revenue')
|
||
def serialize_optional_revenue(self, v: Optional[Decimal]) -> Optional[float]:
|
||
return float(v) if v is not None else None
|
||
|
||
@field_serializer('daily_totals')
|
||
def serialize_daily_totals(self, v: dict[str, Decimal]) -> dict[str, float]:
|
||
return {k: float(val) for k, val in v.items()}
|
||
|
||
|
||
class BudgetSettingsResponse(BaseModel):
|
||
"""Budget settings response"""
|
||
forecast_api_url: Optional[str]
|
||
forecast_api_configured: bool
|
||
budget_gp_target: Decimal
|
||
budget_lookback_weeks: int
|
||
|
||
@field_serializer('budget_gp_target')
|
||
def serialize_gp_target(self, v: Decimal) -> float:
|
||
return float(v)
|
||
|
||
|
||
class BudgetSettingsUpdate(BaseModel):
|
||
"""Budget settings update request"""
|
||
forecast_api_url: Optional[str] = None
|
||
forecast_api_key: Optional[str] = None
|
||
budget_gp_target: Optional[Decimal] = None
|
||
budget_lookback_weeks: Optional[int] = None
|
||
|
||
|
||
class TestConnectionResponse(BaseModel):
|
||
"""Response for forecast API connection test"""
|
||
success: bool
|
||
message: str
|
||
|
||
|
||
# Helper Functions
|
||
|
||
async def get_settings(db: AsyncSession, kitchen_id: int) -> KitchenSettings:
|
||
"""Get kitchen settings, creating if not exists"""
|
||
result = await db.execute(
|
||
select(KitchenSettings).where(KitchenSettings.kitchen_id == kitchen_id)
|
||
)
|
||
settings = result.scalar_one_or_none()
|
||
|
||
if not settings:
|
||
settings = KitchenSettings(kitchen_id=kitchen_id)
|
||
db.add(settings)
|
||
await db.commit()
|
||
await db.refresh(settings)
|
||
|
||
return settings
|
||
|
||
|
||
async def get_historical_supplier_percentages(
|
||
db: AsyncSession,
|
||
kitchen_id: int,
|
||
lookback_start: date,
|
||
lookback_end: date
|
||
) -> list[tuple[int | None, str, Decimal]]:
|
||
"""
|
||
Calculate supplier spend percentages over the lookback period.
|
||
|
||
Returns list of (supplier_id, supplier_name, percentage) sorted by percentage desc.
|
||
"""
|
||
# Build subquery to sum per invoice first (for credit note handling)
|
||
invoice_stock_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.supplier_id.label('supplier_id'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == kitchen_id,
|
||
Invoice.invoice_date >= lookback_start,
|
||
Invoice.invoice_date <= lookback_end,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.supplier_id, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
|
||
# Get total for period
|
||
total_result = await db.execute(
|
||
select(func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
))
|
||
.select_from(invoice_stock_subq)
|
||
)
|
||
total_spend = total_result.scalar() or Decimal("0")
|
||
|
||
if total_spend <= 0:
|
||
return []
|
||
|
||
# Get supplier breakdown
|
||
supplier_result = await db.execute(
|
||
select(
|
||
invoice_stock_subq.c.supplier_id,
|
||
Supplier.name,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
).label('net_total')
|
||
)
|
||
.select_from(invoice_stock_subq)
|
||
.outerjoin(Supplier, invoice_stock_subq.c.supplier_id == Supplier.id)
|
||
.group_by(invoice_stock_subq.c.supplier_id, Supplier.name)
|
||
.order_by(func.sum(
|
||
case(
|
||
(and_(invoice_stock_subq.c.doc_type == 'credit_note',
|
||
invoice_stock_subq.c.stock_total > 0),
|
||
-invoice_stock_subq.c.stock_total),
|
||
else_=invoice_stock_subq.c.stock_total
|
||
)
|
||
).desc())
|
||
)
|
||
|
||
result = []
|
||
for supplier_id, supplier_name, net_total in supplier_result.all():
|
||
if net_total and net_total != 0:
|
||
pct = (net_total / total_spend * 100)
|
||
result.append((
|
||
supplier_id,
|
||
supplier_name or "Unmatched",
|
||
round(pct, 2)
|
||
))
|
||
|
||
return result
|
||
|
||
|
||
async def get_historical_daily_distribution(
|
||
db: AsyncSession,
|
||
kitchen_id: int,
|
||
lookback_start: date,
|
||
lookback_end: date
|
||
) -> dict[int, Decimal]:
|
||
"""
|
||
Calculate historical spend distribution by day of week.
|
||
|
||
Returns dict of weekday (0=Mon, 6=Sun) -> percentage of weekly spend
|
||
"""
|
||
# Get all confirmed invoices in the lookback period with their totals
|
||
invoice_subq = (
|
||
select(
|
||
Invoice.id.label('inv_id'),
|
||
Invoice.invoice_date.label('inv_date'),
|
||
Invoice.document_type.label('doc_type'),
|
||
func.sum(LineItem.amount).label('stock_total')
|
||
)
|
||
.join(Invoice, LineItem.invoice_id == Invoice.id)
|
||
.where(
|
||
Invoice.kitchen_id == kitchen_id,
|
||
Invoice.invoice_date >= lookback_start,
|
||
Invoice.invoice_date <= lookback_end,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
LineItem.amount.isnot(None),
|
||
or_(LineItem.is_non_stock == False, LineItem.is_non_stock.is_(None))
|
||
)
|
||
.group_by(Invoice.id, Invoice.invoice_date, Invoice.document_type)
|
||
.subquery()
|
||
)
|
||
|
||
# Get spend by day
|
||
result = await db.execute(
|
||
select(
|
||
invoice_subq.c.inv_date,
|
||
func.sum(
|
||
case(
|
||
(and_(invoice_subq.c.doc_type == 'credit_note',
|
||
invoice_subq.c.stock_total > 0),
|
||
-invoice_subq.c.stock_total),
|
||
else_=invoice_subq.c.stock_total
|
||
)
|
||
).label('net_total')
|
||
)
|
||
.select_from(invoice_subq)
|
||
.group_by(invoice_subq.c.inv_date)
|
||
)
|
||
|
||
# Aggregate by day of week
|
||
weekday_totals: dict[int, Decimal] = {i: Decimal("0") for i in range(7)}
|
||
total_spend = Decimal("0")
|
||
|
||
for inv_date, net_total in result.all():
|
||
if inv_date and net_total:
|
||
weekday = inv_date.weekday() # 0=Monday, 6=Sunday
|
||
weekday_totals[weekday] += Decimal(str(net_total))
|
||
total_spend += Decimal(str(net_total))
|
||
|
||
# Convert to percentages
|
||
if total_spend > 0:
|
||
return {day: (amount / total_spend * 100).quantize(Decimal("0.01"))
|
||
for day, amount in weekday_totals.items()}
|
||
else:
|
||
# Default to even distribution if no historical data
|
||
return {i: Decimal("14.29") for i in range(7)}
|
||
|
||
|
||
async def get_weekly_invoices_by_supplier(
|
||
db: AsyncSession,
|
||
kitchen_id: int,
|
||
week_start: date,
|
||
week_end: date
|
||
) -> dict[tuple[int | None, str], list[dict]]:
|
||
"""
|
||
Get all confirmed invoices for the week grouped by supplier.
|
||
|
||
Returns dict of (supplier_id, supplier_name) -> list of invoice data
|
||
"""
|
||
# Get all confirmed invoices for the week with line items
|
||
result = await db.execute(
|
||
select(Invoice)
|
||
.where(
|
||
Invoice.kitchen_id == kitchen_id,
|
||
Invoice.status == InvoiceStatus.CONFIRMED,
|
||
Invoice.invoice_date >= week_start,
|
||
Invoice.invoice_date <= week_end,
|
||
)
|
||
.options(selectinload(Invoice.line_items))
|
||
.order_by(Invoice.invoice_date)
|
||
)
|
||
invoices = result.scalars().all()
|
||
|
||
# Get supplier names
|
||
supplier_result = await db.execute(
|
||
select(Supplier).where(Supplier.kitchen_id == kitchen_id)
|
||
)
|
||
suppliers_map = {s.id: s.name for s in supplier_result.scalars().all()}
|
||
|
||
# Group invoices by supplier
|
||
supplier_invoices: dict[tuple[int | None, str], list[dict]] = defaultdict(list)
|
||
|
||
for inv in invoices:
|
||
# Calculate net stock for this invoice
|
||
net_stock = Decimal("0")
|
||
if inv.line_items:
|
||
for item in inv.line_items:
|
||
if not (item.is_non_stock or False):
|
||
net_stock += item.amount or Decimal("0")
|
||
|
||
# Handle credit notes
|
||
if inv.document_type == 'credit_note' and net_stock > 0:
|
||
net_stock = -net_stock
|
||
|
||
# Skip invoices with no stock value (non-stock only invoices)
|
||
if net_stock == 0:
|
||
continue
|
||
|
||
# Get supplier key
|
||
if inv.supplier_id:
|
||
supplier_name = suppliers_map.get(inv.supplier_id, "Unknown")
|
||
key = (inv.supplier_id, supplier_name)
|
||
else:
|
||
vendor = inv.vendor_name or "Unknown Supplier"
|
||
key = (None, vendor)
|
||
|
||
supplier_invoices[key].append({
|
||
"id": inv.id,
|
||
"invoice_number": inv.invoice_number,
|
||
"invoice_date": inv.invoice_date,
|
||
"net_stock": net_stock,
|
||
"document_type": inv.document_type,
|
||
})
|
||
|
||
return supplier_invoices
|
||
|
||
|
||
# API Endpoints
|
||
|
||
@router.get("/weekly", response_model=WeeklyBudgetResponse)
|
||
async def get_weekly_budget(
|
||
week_offset: int = 0, # 0 = current week, -1 = last week, etc.
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
Get weekly budget breakdown with supplier allocations.
|
||
|
||
week_offset: 0 = current week (Mon-Sun), -1 = previous week, etc.
|
||
"""
|
||
# Calculate week dates (Mon-Sun)
|
||
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)]
|
||
|
||
# Get settings
|
||
settings = await get_settings(db, current_user.kitchen_id)
|
||
|
||
# Get forecast revenue + rooms + covers
|
||
otb_revenue = Decimal("0")
|
||
forecast_revenue = Decimal("0")
|
||
forecast_source = "fallback"
|
||
forecast_data = None # Keep the daily forecast data
|
||
forecast_summary = None
|
||
|
||
api_spend_rates = {} # spend rates from API for override recalculation
|
||
covers_data_raw = [] # raw covers data from API for override recalculation
|
||
|
||
if settings.forecast_api_url and settings.forecast_api_key:
|
||
try:
|
||
async with ForecastAPIClient(
|
||
settings.forecast_api_url,
|
||
settings.forecast_api_key
|
||
) as client:
|
||
forecast_data = await client.get_revenue_forecast(week_start, days=7)
|
||
otb_revenue, forecast_revenue = client.calculate_food_revenue(forecast_data)
|
||
forecast_source = "forecast_api"
|
||
logger.info(f"Fetched revenue - OTB: {otb_revenue}, Forecast: {forecast_revenue}")
|
||
|
||
# Fetch rooms and covers for summary banner
|
||
try:
|
||
rooms_data = await client.get_rooms_forecast(week_start, days=7)
|
||
covers_data = await client.get_covers_forecast(week_start, days=7)
|
||
covers_data_raw = covers_data # Save for override recalculation
|
||
rooms_agg = client.aggregate_rooms(rooms_data)
|
||
covers_agg = client.aggregate_covers(covers_data)
|
||
|
||
# Index rooms data by date for matching
|
||
rooms_by_date = {d.get("date", ""): d for d in rooms_data}
|
||
|
||
# Build daily covers breakdown
|
||
daily_covers_list = []
|
||
for day in covers_data:
|
||
day_date_str = day.get("date", "")
|
||
day_name = day.get("day", "")
|
||
room_day = rooms_by_date.get(day_date_str, {})
|
||
daily_covers_list.append(DailyCoverData(
|
||
date=day_date_str,
|
||
day_name=day_name,
|
||
otb_rooms=room_day.get("otb_rooms", 0) or 0,
|
||
pickup_rooms=(room_day.get("forecast_rooms", 0) or 0) - (room_day.get("otb_rooms", 0) or 0),
|
||
otb_guests=room_day.get("otb_guests", 0) or 0,
|
||
pickup_guests=(room_day.get("forecast_guests", 0) or 0) - (room_day.get("otb_guests", 0) or 0),
|
||
breakfast=CoversSummary(
|
||
otb=day.get("breakfast", {}).get("otb", 0) or 0,
|
||
pickup=(day.get("breakfast", {}).get("forecast", 0) or 0) - (day.get("breakfast", {}).get("otb", 0) or 0),
|
||
forecast=day.get("breakfast", {}).get("forecast", 0) or 0,
|
||
),
|
||
lunch=CoversSummary(
|
||
otb=day.get("lunch", {}).get("otb", 0) or 0,
|
||
pickup=(day.get("lunch", {}).get("forecast", 0) or 0) - (day.get("lunch", {}).get("otb", 0) or 0),
|
||
forecast=day.get("lunch", {}).get("forecast", 0) or 0,
|
||
),
|
||
dinner=CoversSummary(
|
||
otb=day.get("dinner", {}).get("otb", 0) or 0,
|
||
pickup=(day.get("dinner", {}).get("forecast", 0) or 0) - (day.get("dinner", {}).get("otb", 0) or 0),
|
||
forecast=day.get("dinner", {}).get("forecast", 0) or 0,
|
||
),
|
||
))
|
||
|
||
forecast_summary = ForecastSummary(
|
||
otb_rooms=rooms_agg["otb_rooms"],
|
||
pickup_rooms=rooms_agg["pickup_rooms"],
|
||
forecast_rooms=rooms_agg["forecast_rooms"],
|
||
otb_guests=rooms_agg["otb_guests"],
|
||
pickup_guests=rooms_agg["pickup_guests"],
|
||
forecast_guests=rooms_agg["forecast_guests"],
|
||
breakfast=CoversSummary(**covers_agg["breakfast"]),
|
||
lunch=CoversSummary(**covers_agg["lunch"]),
|
||
dinner=CoversSummary(**covers_agg["dinner"]),
|
||
daily_covers=daily_covers_list,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch rooms/covers forecast: {e}")
|
||
|
||
# Fetch spend rates for override recalculation
|
||
try:
|
||
sr_response = await client.get_spend_rates()
|
||
api_spend_rates = sr_response.get("periods", {})
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch spend rates: {e}")
|
||
except ForecastAPIError as e:
|
||
logger.warning(f"Failed to fetch forecast: {e.message}")
|
||
forecast_data = None
|
||
|
||
# Calculate budgets
|
||
gp_target = settings.budget_gp_target or Decimal("65.00")
|
||
cost_target_pct = (100 - gp_target) / 100
|
||
min_budget = (otb_revenue * cost_target_pct).quantize(Decimal("0.01"))
|
||
total_budget = (forecast_revenue * cost_target_pct).quantize(Decimal("0.01"))
|
||
|
||
# --- Apply cover/spend rate overrides to adjust forecast revenue ---
|
||
has_overrides_flag = False
|
||
snapshot_revenue_val = None
|
||
adjusted_revenue_val = None
|
||
adjusted_daily = {} # date_str -> adjusted revenue for daily breakdown
|
||
|
||
if forecast_source == "forecast_api":
|
||
# Check if a forecast snapshot exists for this week
|
||
week_snap_result = await db.execute(text("""
|
||
SELECT total_forecast_revenue FROM forecast_week_snapshots
|
||
WHERE kitchen_id = :kid AND week_start = :ws
|
||
"""), {"kid": current_user.kitchen_id, "ws": week_start})
|
||
week_snap = week_snap_result.fetchone()
|
||
|
||
if week_snap:
|
||
snapshot_revenue_val = Decimal(str(week_snap.total_forecast_revenue)) if week_snap.total_forecast_revenue else None
|
||
has_overrides_flag = True
|
||
|
||
# Only recalculate if we have live covers data
|
||
if covers_data_raw:
|
||
# Load cover overrides for this week
|
||
ovr_result = await db.execute(text("""
|
||
SELECT override_date, period, override_covers
|
||
FROM cover_overrides
|
||
WHERE kitchen_id = :kid AND override_date >= :ws AND override_date <= :we
|
||
"""), {"kid": current_user.kitchen_id, "ws": week_start, "we": week_end})
|
||
override_lookup = {}
|
||
for row in ovr_result.fetchall():
|
||
override_lookup[(row.override_date.isoformat(), row.period)] = row.override_covers
|
||
|
||
# Load snapshot spend rates
|
||
snap_spend_result = await db.execute(text("""
|
||
SELECT snapshot_date, period, food_spend, drinks_spend
|
||
FROM forecast_snapshots
|
||
WHERE kitchen_id = :kid AND week_start = :ws
|
||
"""), {"kid": current_user.kitchen_id, "ws": week_start})
|
||
snap_spend_lookup = {}
|
||
for row in snap_spend_result.fetchall():
|
||
snap_spend_lookup[(row.snapshot_date.isoformat(), row.period)] = row
|
||
|
||
# Load spend rate overrides
|
||
spend_ovr_result = await db.execute(text("""
|
||
SELECT period, food_spend, drinks_spend
|
||
FROM spend_rate_overrides
|
||
WHERE kitchen_id = :kid AND week_start = :ws
|
||
"""), {"kid": current_user.kitchen_id, "ws": week_start})
|
||
spend_ovr_lookup = {row.period: row for row in spend_ovr_result.fetchall()}
|
||
|
||
# Resolve effective spend rates per period (override > snapshot > API)
|
||
spend_effective = {}
|
||
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)
|
||
|
||
snap_food = snap_drinks = None
|
||
for d_check in week_dates:
|
||
key = (d_check.isoformat(), period)
|
||
if key in snap_spend_lookup:
|
||
snap_food = float(snap_spend_lookup[key].food_spend) if snap_spend_lookup[key].food_spend else None
|
||
snap_drinks = float(snap_spend_lookup[key].drinks_spend) if snap_spend_lookup[key].drinks_spend else None
|
||
break
|
||
|
||
ovr_sp = spend_ovr_lookup.get(period)
|
||
ovr_food = float(ovr_sp.food_spend) if ovr_sp and ovr_sp.food_spend else None
|
||
ovr_drinks = float(ovr_sp.drinks_spend) if ovr_sp and ovr_sp.drinks_spend else None
|
||
|
||
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}
|
||
|
||
# Recalculate revenue per day
|
||
covers_by_date_ovr = {d.get("date", ""): d for d in covers_data_raw}
|
||
adjusted_total = Decimal("0")
|
||
|
||
for d in week_dates:
|
||
date_str = d.isoformat()
|
||
is_past = d < today
|
||
|
||
if is_past and forecast_data:
|
||
# Past: use actual dry revenue from forecast API
|
||
day_rev = Decimal("0")
|
||
for fd in forecast_data:
|
||
if fd.get("date") == date_str:
|
||
dry = fd.get("dry", {})
|
||
day_rev = Decimal(str(dry.get("forecast", 0) or 0))
|
||
break
|
||
else:
|
||
# Today/future: recalculate from effective covers × effective spend
|
||
day_covers = covers_by_date_ovr.get(date_str, {})
|
||
day_rev = Decimal("0")
|
||
|
||
for period in ("breakfast", "lunch", "dinner"):
|
||
p_covers = day_covers.get(period, {})
|
||
otb_cvr = p_covers.get("otb", 0) or 0
|
||
forecast_cvr = p_covers.get("forecast", 0) or 0
|
||
|
||
ovr_val = override_lookup.get((date_str, period))
|
||
if ovr_val is not None:
|
||
effective = max(otb_cvr, ovr_val) # OTB always supersedes upward
|
||
else:
|
||
effective = forecast_cvr
|
||
|
||
eff_food = Decimal(str(spend_effective.get(period, {}).get("food", 0)))
|
||
day_rev += Decimal(str(effective)) * eff_food
|
||
|
||
adjusted_daily[date_str] = day_rev.quantize(Decimal("0.01"))
|
||
adjusted_total += day_rev
|
||
|
||
adjusted_revenue_val = adjusted_total.quantize(Decimal("0.01"))
|
||
|
||
# Replace forecast_revenue and recalculate budget with adjusted values
|
||
forecast_revenue = adjusted_revenue_val
|
||
total_budget = (forecast_revenue * cost_target_pct).quantize(Decimal("0.01"))
|
||
|
||
# Get historical supplier percentages
|
||
lookback_weeks = settings.budget_lookback_weeks or 4
|
||
lookback_start = week_start - timedelta(weeks=lookback_weeks)
|
||
lookback_end = week_start - timedelta(days=1)
|
||
|
||
supplier_pcts = await get_historical_supplier_percentages(
|
||
db, current_user.kitchen_id, lookback_start, lookback_end
|
||
)
|
||
|
||
# Get historical daily distribution (for budget allocation by day of week)
|
||
daily_distribution = await get_historical_daily_distribution(
|
||
db, current_user.kitchen_id, lookback_start, lookback_end
|
||
)
|
||
|
||
# Get this week's invoices by supplier
|
||
weekly_invoices = await get_weekly_invoices_by_supplier(
|
||
db, current_user.kitchen_id, week_start, week_end
|
||
)
|
||
|
||
# Get this week's purchase orders (DRAFT + PENDING) grouped by supplier
|
||
po_result = await db.execute(
|
||
select(PurchaseOrder)
|
||
.where(
|
||
PurchaseOrder.kitchen_id == current_user.kitchen_id,
|
||
PurchaseOrder.status.in_(["DRAFT", "PENDING"]),
|
||
PurchaseOrder.order_date >= week_start,
|
||
PurchaseOrder.order_date <= week_end,
|
||
)
|
||
)
|
||
weekly_pos = po_result.scalars().all()
|
||
|
||
# Group POs by supplier_id -> {date_str -> [BudgetPOItem]}
|
||
po_by_supplier: dict[int, dict[str, list[BudgetPOItem]]] = defaultdict(lambda: defaultdict(list))
|
||
po_totals_by_supplier: dict[int, Decimal] = defaultdict(lambda: Decimal("0"))
|
||
po_supplier_names: dict[int, str] = {}
|
||
|
||
for po in weekly_pos:
|
||
sid = po.supplier_id
|
||
ds = po.order_date.isoformat()
|
||
amt = po.total_amount or Decimal("0")
|
||
po_by_supplier[sid][ds].append(BudgetPOItem(
|
||
id=po.id,
|
||
order_type=po.order_type,
|
||
status=po.status,
|
||
total_amount=amt,
|
||
order_reference=po.order_reference,
|
||
))
|
||
po_totals_by_supplier[sid] += amt
|
||
|
||
# Get supplier names for PO-only suppliers
|
||
if po_by_supplier:
|
||
sup_ids = list(po_by_supplier.keys())
|
||
sup_result = await db.execute(
|
||
select(Supplier.id, Supplier.name).where(Supplier.id.in_(sup_ids))
|
||
)
|
||
for sid, sname in sup_result.all():
|
||
po_supplier_names[sid] = sname
|
||
|
||
# Compute cost distribution adjustments per supplier per date.
|
||
# CD entries are attributed back to the supplier whose invoice was distributed.
|
||
cd_supplier_result = await db.execute(
|
||
select(
|
||
Invoice.supplier_id,
|
||
CostDistributionEntry.entry_date,
|
||
func.sum(CostDistributionEntry.amount)
|
||
)
|
||
.join(CostDistribution, CostDistributionEntry.distribution_id == CostDistribution.id)
|
||
.join(Invoice, CostDistribution.invoice_id == Invoice.id)
|
||
.where(
|
||
CostDistributionEntry.kitchen_id == current_user.kitchen_id,
|
||
CostDistributionEntry.entry_date >= week_start,
|
||
CostDistributionEntry.entry_date <= week_end,
|
||
CostDistribution.status.in_([DistributionStatus.ACTIVE.value, DistributionStatus.COMPLETED.value]),
|
||
)
|
||
.group_by(Invoice.supplier_id, CostDistributionEntry.entry_date)
|
||
)
|
||
# Per-supplier per-date amounts, plus aggregated totals
|
||
cd_by_supplier_date: dict[Optional[int], dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: Decimal("0")))
|
||
cd_total_by_supplier: dict[Optional[int], Decimal] = defaultdict(lambda: Decimal("0"))
|
||
cd_daily_amounts: dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
|
||
cd_total_adjustment = Decimal("0")
|
||
for row in cd_supplier_result.all():
|
||
sup_id, cd_date, cd_amount = row[0], row[1], row[2] or Decimal("0")
|
||
date_str = cd_date.isoformat()
|
||
cd_by_supplier_date[sup_id][date_str] += cd_amount
|
||
cd_total_by_supplier[sup_id] += cd_amount
|
||
cd_daily_amounts[date_str] += cd_amount
|
||
cd_total_adjustment += cd_amount
|
||
|
||
# Build supplier rows
|
||
supplier_rows = []
|
||
all_supplier_names = []
|
||
total_spent = Decimal("0")
|
||
total_po_ordered = Decimal("0")
|
||
|
||
# Process historical suppliers first
|
||
processed_suppliers = set()
|
||
processed_po_suppliers = set()
|
||
for supplier_id, supplier_name, hist_pct in supplier_pcts:
|
||
key = (supplier_id, supplier_name)
|
||
processed_suppliers.add(key)
|
||
if supplier_id:
|
||
processed_po_suppliers.add(supplier_id)
|
||
all_supplier_names.append(supplier_name)
|
||
|
||
# Calculate allocated budget from full total_budget
|
||
allocated = (total_budget * Decimal(str(hist_pct)) / 100).quantize(Decimal("0.01"))
|
||
|
||
# Get invoices for this supplier this week
|
||
invoices = weekly_invoices.get(key, [])
|
||
|
||
# Organize invoices by date
|
||
invoices_by_date: dict[str, list[BudgetInvoice]] = defaultdict(list)
|
||
actual_spent = Decimal("0")
|
||
|
||
for inv in invoices:
|
||
date_str = inv["invoice_date"].isoformat() if inv["invoice_date"] else ""
|
||
if date_str:
|
||
invoices_by_date[date_str].append(BudgetInvoice(
|
||
id=inv["id"],
|
||
invoice_number=inv["invoice_number"],
|
||
invoice_date=inv["invoice_date"],
|
||
net_stock=inv["net_stock"],
|
||
document_type=inv["document_type"],
|
||
))
|
||
actual_spent += inv["net_stock"]
|
||
|
||
# Get POs for this supplier
|
||
supplier_po_dates = dict(po_by_supplier.get(supplier_id, {})) if supplier_id else {}
|
||
supplier_po_total = po_totals_by_supplier.get(supplier_id, Decimal("0")) if supplier_id else Decimal("0")
|
||
|
||
# Get CD adjustments for this supplier
|
||
supplier_cd_dates = {k: float(v) for k, v in cd_by_supplier_date.get(supplier_id, {}).items()}
|
||
supplier_cd_total = cd_total_by_supplier.get(supplier_id, Decimal("0"))
|
||
|
||
total_spent += actual_spent
|
||
total_po_ordered += supplier_po_total
|
||
remaining = allocated - actual_spent - supplier_cd_total - supplier_po_total
|
||
|
||
# Determine status
|
||
if remaining < 0:
|
||
status = "over"
|
||
elif remaining < allocated * Decimal("0.1"): # Less than 10% remaining
|
||
status = "on_track"
|
||
else:
|
||
status = "under"
|
||
|
||
supplier_rows.append(SupplierBudgetRow(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name,
|
||
historical_pct=hist_pct,
|
||
allocated_budget=allocated,
|
||
invoices_by_date=dict(invoices_by_date),
|
||
purchase_orders_by_date=supplier_po_dates,
|
||
actual_spent=actual_spent,
|
||
cd_adjustments_by_date=supplier_cd_dates,
|
||
cd_total=float(supplier_cd_total),
|
||
po_ordered=supplier_po_total,
|
||
remaining=remaining,
|
||
status=status,
|
||
))
|
||
|
||
# Add any suppliers with invoices this week that weren't in historical data
|
||
for key, invoices in weekly_invoices.items():
|
||
if key not in processed_suppliers:
|
||
supplier_id, supplier_name = key
|
||
if supplier_id:
|
||
processed_po_suppliers.add(supplier_id)
|
||
all_supplier_names.append(supplier_name)
|
||
|
||
invoices_by_date: dict[str, list[BudgetInvoice]] = defaultdict(list)
|
||
actual_spent = Decimal("0")
|
||
|
||
for inv in invoices:
|
||
date_str = inv["invoice_date"].isoformat() if inv["invoice_date"] else ""
|
||
if date_str:
|
||
invoices_by_date[date_str].append(BudgetInvoice(
|
||
id=inv["id"],
|
||
invoice_number=inv["invoice_number"],
|
||
invoice_date=inv["invoice_date"],
|
||
net_stock=inv["net_stock"],
|
||
document_type=inv["document_type"],
|
||
))
|
||
actual_spent += inv["net_stock"]
|
||
|
||
supplier_po_dates = dict(po_by_supplier.get(supplier_id, {})) if supplier_id else {}
|
||
supplier_po_total = po_totals_by_supplier.get(supplier_id, Decimal("0")) if supplier_id else Decimal("0")
|
||
|
||
supplier_cd_dates = {k: float(v) for k, v in cd_by_supplier_date.get(supplier_id, {}).items()}
|
||
supplier_cd_total = cd_total_by_supplier.get(supplier_id, Decimal("0"))
|
||
|
||
total_spent += actual_spent
|
||
total_po_ordered += supplier_po_total
|
||
combined = actual_spent + supplier_cd_total + supplier_po_total
|
||
|
||
supplier_rows.append(SupplierBudgetRow(
|
||
supplier_id=supplier_id,
|
||
supplier_name=supplier_name,
|
||
historical_pct=Decimal("0"), # No historical data
|
||
allocated_budget=Decimal("0"),
|
||
invoices_by_date=dict(invoices_by_date),
|
||
purchase_orders_by_date=supplier_po_dates,
|
||
actual_spent=actual_spent,
|
||
cd_adjustments_by_date=supplier_cd_dates,
|
||
cd_total=float(supplier_cd_total),
|
||
po_ordered=supplier_po_total,
|
||
remaining=-combined, # Over by definition
|
||
status="over" if combined > 0 else "under",
|
||
))
|
||
|
||
# Add suppliers with POs but no invoices and no historical data
|
||
for sid, po_dates in po_by_supplier.items():
|
||
if sid not in processed_po_suppliers:
|
||
sname = po_supplier_names.get(sid, f"Supplier #{sid}")
|
||
all_supplier_names.append(sname)
|
||
supplier_po_total = po_totals_by_supplier.get(sid, Decimal("0"))
|
||
total_po_ordered += supplier_po_total
|
||
|
||
supplier_rows.append(SupplierBudgetRow(
|
||
supplier_id=sid,
|
||
supplier_name=sname,
|
||
historical_pct=Decimal("0"),
|
||
allocated_budget=Decimal("0"),
|
||
invoices_by_date={},
|
||
purchase_orders_by_date=dict(po_dates),
|
||
actual_spent=Decimal("0"),
|
||
po_ordered=supplier_po_total,
|
||
remaining=-supplier_po_total,
|
||
status="over" if supplier_po_total > 0 else "under",
|
||
))
|
||
|
||
# Build daily breakdown with both historical and revenue-based budgets
|
||
daily_data = []
|
||
daily_totals: dict[str, Decimal] = {}
|
||
cumulative_budget = Decimal("0")
|
||
cumulative_spent = Decimal("0")
|
||
|
||
# Extract daily revenue from forecast API data
|
||
daily_forecast_revenue: dict[str, Decimal] = {}
|
||
total_forecast_rev = Decimal("0")
|
||
|
||
if forecast_data:
|
||
# Use actual daily forecast from API
|
||
for day in forecast_data:
|
||
date_str = day.get("date")
|
||
if date_str:
|
||
dry = day.get("dry", {})
|
||
dry_forecast = Decimal(str(dry.get("forecast", 0) or 0))
|
||
daily_forecast_revenue[date_str] = dry_forecast
|
||
total_forecast_rev += dry_forecast
|
||
else:
|
||
# Fallback: distribute total forecast using historical patterns
|
||
for d in week_dates:
|
||
weekday = d.weekday()
|
||
day_pct = daily_distribution.get(weekday, Decimal("14.29"))
|
||
day_forecast_rev = (forecast_revenue * day_pct / 100).quantize(Decimal("0.01"))
|
||
daily_forecast_revenue[d.isoformat()] = day_forecast_rev
|
||
total_forecast_rev += day_forecast_rev
|
||
|
||
# Apply adjusted daily revenue from overrides
|
||
if adjusted_daily:
|
||
for adj_date_str, adj_rev in adjusted_daily.items():
|
||
daily_forecast_revenue[adj_date_str] = adj_rev
|
||
total_forecast_rev = sum(daily_forecast_revenue.values())
|
||
|
||
for d in week_dates:
|
||
date_str = d.isoformat()
|
||
day_name = d.strftime("%a")
|
||
weekday = d.weekday() # 0=Monday, 6=Sunday
|
||
|
||
# Historical spend-based budget
|
||
budget_split_pct = daily_distribution.get(weekday, Decimal("14.29"))
|
||
historical_budget = (total_budget * budget_split_pct / 100).quantize(Decimal("0.01"))
|
||
|
||
# Revenue-based budget (proportional to actual daily forecast revenue)
|
||
day_forecast_rev = daily_forecast_revenue.get(date_str, Decimal("0"))
|
||
if total_forecast_rev > 0:
|
||
revenue_pct = (day_forecast_rev / total_forecast_rev * 100).quantize(Decimal("0.01"))
|
||
revenue_budget = (total_budget * revenue_pct / 100).quantize(Decimal("0.01"))
|
||
else:
|
||
revenue_budget = (total_budget / 7).quantize(Decimal("0.01"))
|
||
|
||
# Get actual spend for this day
|
||
day_spent = Decimal("0")
|
||
for supplier_row in supplier_rows:
|
||
for inv in supplier_row.invoices_by_date.get(date_str, []):
|
||
day_spent += inv.net_stock
|
||
|
||
daily_totals[date_str] = day_spent
|
||
cumulative_budget += historical_budget # Use historical for cumulative
|
||
|
||
# Only show cumulative spent for past/today
|
||
if d <= today:
|
||
cumulative_spent += day_spent
|
||
daily_data.append(DailyBudgetData(
|
||
date=d,
|
||
day_name=day_name,
|
||
forecast_revenue=day_forecast_rev,
|
||
budget_split_pct=budget_split_pct,
|
||
historical_budget=historical_budget,
|
||
revenue_budget=revenue_budget,
|
||
actual_spent=day_spent,
|
||
cumulative_budget=cumulative_budget,
|
||
cumulative_spent=cumulative_spent,
|
||
))
|
||
else:
|
||
daily_data.append(DailyBudgetData(
|
||
date=d,
|
||
day_name=day_name,
|
||
forecast_revenue=day_forecast_rev,
|
||
budget_split_pct=budget_split_pct,
|
||
historical_budget=historical_budget,
|
||
revenue_budget=revenue_budget,
|
||
actual_spent=None,
|
||
cumulative_budget=cumulative_budget,
|
||
cumulative_spent=None,
|
||
))
|
||
|
||
# Apply pre-computed cost distribution adjustments to daily totals and total_spent
|
||
for date_str, cd_amount in cd_daily_amounts.items():
|
||
if date_str in daily_totals:
|
||
daily_totals[date_str] += cd_amount
|
||
else:
|
||
daily_totals[date_str] = cd_amount
|
||
total_spent += cd_total_adjustment
|
||
|
||
total_remaining = total_budget - total_spent - total_po_ordered
|
||
|
||
return WeeklyBudgetResponse(
|
||
week_start=week_start,
|
||
week_end=week_end,
|
||
dates=week_dates,
|
||
otb_revenue=otb_revenue,
|
||
forecast_revenue=forecast_revenue,
|
||
forecast_source=forecast_source,
|
||
forecast_summary=forecast_summary,
|
||
has_overrides=has_overrides_flag,
|
||
snapshot_revenue=snapshot_revenue_val,
|
||
adjusted_revenue=adjusted_revenue_val,
|
||
gp_target_pct=gp_target,
|
||
min_budget=min_budget,
|
||
total_budget=total_budget,
|
||
total_spent=total_spent,
|
||
total_po_ordered=total_po_ordered,
|
||
total_remaining=total_remaining,
|
||
cd_budget_reservation=Decimal("0"), # Not used; CD is attributed per-supplier
|
||
suppliers=supplier_rows,
|
||
all_supplier_names=all_supplier_names,
|
||
daily_data=daily_data,
|
||
daily_totals=daily_totals,
|
||
)
|
||
|
||
|
||
@router.get("/settings", response_model=BudgetSettingsResponse)
|
||
async def get_budget_settings(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Get current budget settings"""
|
||
settings = await get_settings(db, current_user.kitchen_id)
|
||
|
||
return BudgetSettingsResponse(
|
||
forecast_api_url=settings.forecast_api_url,
|
||
forecast_api_configured=bool(settings.forecast_api_url and settings.forecast_api_key),
|
||
budget_gp_target=settings.budget_gp_target or Decimal("65.00"),
|
||
budget_lookback_weeks=settings.budget_lookback_weeks or 4,
|
||
)
|
||
|
||
|
||
@router.patch("/settings", response_model=BudgetSettingsResponse)
|
||
async def update_budget_settings(
|
||
updates: BudgetSettingsUpdate,
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Update budget settings"""
|
||
settings = await get_settings(db, current_user.kitchen_id)
|
||
|
||
if updates.forecast_api_url is not None:
|
||
settings.forecast_api_url = updates.forecast_api_url or None
|
||
|
||
if updates.forecast_api_key is not None:
|
||
settings.forecast_api_key = updates.forecast_api_key or None
|
||
|
||
if updates.budget_gp_target is not None:
|
||
settings.budget_gp_target = updates.budget_gp_target
|
||
|
||
if updates.budget_lookback_weeks is not None:
|
||
settings.budget_lookback_weeks = updates.budget_lookback_weeks
|
||
|
||
await db.commit()
|
||
await db.refresh(settings)
|
||
|
||
return BudgetSettingsResponse(
|
||
forecast_api_url=settings.forecast_api_url,
|
||
forecast_api_configured=bool(settings.forecast_api_url and settings.forecast_api_key),
|
||
budget_gp_target=settings.budget_gp_target or Decimal("65.00"),
|
||
budget_lookback_weeks=settings.budget_lookback_weeks or 4,
|
||
)
|
||
|
||
|
||
@router.post("/test-forecast-connection", response_model=TestConnectionResponse)
|
||
async def test_forecast_connection(
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""Test connection to forecast API"""
|
||
settings = await get_settings(db, current_user.kitchen_id)
|
||
|
||
if not settings.forecast_api_url:
|
||
return TestConnectionResponse(
|
||
success=False,
|
||
message="Forecast API URL not configured"
|
||
)
|
||
|
||
if not settings.forecast_api_key:
|
||
return TestConnectionResponse(
|
||
success=False,
|
||
message="Forecast API key not configured"
|
||
)
|
||
|
||
try:
|
||
async with ForecastAPIClient(
|
||
settings.forecast_api_url,
|
||
settings.forecast_api_key
|
||
) as client:
|
||
success, message = await client.test_connection()
|
||
return TestConnectionResponse(success=success, message=message)
|
||
except Exception as e:
|
||
return TestConnectionResponse(
|
||
success=False,
|
||
message=f"Connection failed: {str(e)}"
|
||
)
|