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