kitchen/backend/api/budget.py
2026-07-12 12:16:28 +00:00

1121 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)}"
)