Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
863
backend/api/cost_distributions.py
Normal file
863
backend/api/cost_distributions.py
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
"""
|
||||
Cost Distribution API endpoints — create, view, settle early, cancel,
|
||||
and weekly summaries for budget page integration.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, delete
|
||||
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.line_item import LineItem
|
||||
from models.supplier import Supplier
|
||||
from models.settings import KitchenSettings
|
||||
from models.cost_distribution import (
|
||||
CostDistribution,
|
||||
CostDistributionLineSelection,
|
||||
CostDistributionEntry,
|
||||
DistributionStatus,
|
||||
DistributionMethod,
|
||||
)
|
||||
from auth import get_current_user, require_cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Pydantic schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineSelectionIn(BaseModel):
|
||||
line_item_id: int
|
||||
selected_quantity: Decimal
|
||||
|
||||
|
||||
class CostDistributionCreate(BaseModel):
|
||||
invoice_id: int
|
||||
method: str # "OFFSET" or "DISTRIBUTE"
|
||||
notes: Optional[str] = None
|
||||
line_selections: list[LineSelectionIn]
|
||||
# For OFFSET
|
||||
target_date: Optional[date] = None
|
||||
# For DISTRIBUTE
|
||||
days_of_week: Optional[list[int]] = None # 0=Mon, 6=Sun (Python weekday())
|
||||
num_weeks: Optional[int] = None
|
||||
start_date: Optional[date] = None
|
||||
|
||||
|
||||
class CostDistributionUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SettleEarlyRequest(BaseModel):
|
||||
entry_date: date
|
||||
amount: Optional[Decimal] = None # null = settle all
|
||||
|
||||
|
||||
# ── Response schemas ──────────────────────────────────────────────────────────
|
||||
|
||||
class LineSelectionOut(BaseModel):
|
||||
id: int
|
||||
line_item_id: int
|
||||
description: Optional[str] = None
|
||||
original_quantity: Optional[Decimal] = None
|
||||
selected_quantity: Decimal
|
||||
unit_price: Decimal
|
||||
distributed_value: Decimal
|
||||
|
||||
@field_serializer('selected_quantity', 'unit_price', 'distributed_value', 'original_quantity')
|
||||
def ser(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class EntryOut(BaseModel):
|
||||
id: int
|
||||
entry_date: date
|
||||
amount: Decimal
|
||||
is_source_offset: bool
|
||||
is_overpay: bool
|
||||
|
||||
@field_serializer('amount')
|
||||
def ser_amount(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class CostDistributionOut(BaseModel):
|
||||
id: int
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str] = None
|
||||
invoice_date: Optional[date] = None
|
||||
supplier_name: Optional[str] = None
|
||||
status: str
|
||||
method: str
|
||||
notes: Optional[str]
|
||||
total_distributed_value: Decimal
|
||||
remaining_balance: Decimal
|
||||
source_date: date
|
||||
created_by_name: Optional[str] = None
|
||||
created_at: str
|
||||
line_selections: list[LineSelectionOut] = []
|
||||
entries: list[EntryOut] = []
|
||||
|
||||
@field_serializer('total_distributed_value', 'remaining_balance')
|
||||
def ser_dec(self, v: Decimal) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
class LineItemAvailability(BaseModel):
|
||||
id: int
|
||||
description: Optional[str]
|
||||
unit: Optional[str]
|
||||
quantity: Optional[Decimal]
|
||||
unit_price: Optional[Decimal]
|
||||
amount: Optional[Decimal]
|
||||
is_non_stock: bool
|
||||
already_distributed_qty: Decimal
|
||||
available_qty: Decimal
|
||||
|
||||
@field_serializer('quantity', 'unit_price', 'amount', 'already_distributed_qty', 'available_qty')
|
||||
def ser(self, v: Optional[Decimal]) -> Optional[float]:
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
class InvoiceAvailabilityOut(BaseModel):
|
||||
invoice_id: int
|
||||
invoice_number: Optional[str]
|
||||
invoice_date: Optional[date]
|
||||
supplier_name: Optional[str]
|
||||
line_items: list[LineItemAvailability] = []
|
||||
|
||||
|
||||
class WeeklyDistributionRow(BaseModel):
|
||||
distribution_id: int
|
||||
title: str # Compact: "dd/mm/yy - Supplier"
|
||||
supplier_name: Optional[str] = None
|
||||
invoice_number: Optional[str] = None
|
||||
source_date_str: str = "" # dd/mm/yy
|
||||
summary: str = "" # e.g. "£200 laid away to 28/02/26" or "£200 distributed over 4 weeks"
|
||||
notes: Optional[str] = None
|
||||
invoice_id: int
|
||||
entries_by_date: dict[str, float]
|
||||
total_distributed_value: float
|
||||
remaining_balance: float
|
||||
bf_balance: float # Outstanding at start of this week
|
||||
cf_balance: float # Outstanding at end of this week
|
||||
status: str
|
||||
|
||||
|
||||
class WeeklyDistributionsOut(BaseModel):
|
||||
week_start: date
|
||||
week_end: date
|
||||
distributions: list[WeeklyDistributionRow] = []
|
||||
daily_totals: dict[str, float] = {}
|
||||
bf_balance: float
|
||||
cf_balance: float
|
||||
week_total: float
|
||||
|
||||
|
||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||
|
||||
def _generate_target_dates(
|
||||
method: str,
|
||||
target_date: Optional[date],
|
||||
days_of_week: Optional[list[int]],
|
||||
num_weeks: Optional[int],
|
||||
start_date: Optional[date],
|
||||
) -> list[date]:
|
||||
"""Generate target dates based on distribution method."""
|
||||
if method == DistributionMethod.OFFSET.value:
|
||||
if not target_date:
|
||||
raise HTTPException(400, "target_date required for OFFSET method")
|
||||
return [target_date]
|
||||
|
||||
if method == DistributionMethod.DISTRIBUTE.value:
|
||||
if not days_of_week or not num_weeks or not start_date:
|
||||
raise HTTPException(400, "days_of_week, num_weeks, and start_date required for DISTRIBUTE method")
|
||||
if not days_of_week:
|
||||
raise HTTPException(400, "At least one day of week must be selected")
|
||||
if num_weeks < 1:
|
||||
raise HTTPException(400, "num_weeks must be at least 1")
|
||||
|
||||
dates = []
|
||||
for week in range(num_weeks):
|
||||
week_start = start_date + timedelta(weeks=week)
|
||||
for day_offset in range(7):
|
||||
d = week_start + timedelta(days=day_offset)
|
||||
if d.weekday() in days_of_week:
|
||||
dates.append(d)
|
||||
|
||||
# Deduplicate and sort (in case start_date is mid-week)
|
||||
dates = sorted(set(dates))
|
||||
if not dates:
|
||||
raise HTTPException(400, "No target dates generated from the selected days and weeks")
|
||||
return dates
|
||||
|
||||
raise HTTPException(400, f"Invalid method: {method}")
|
||||
|
||||
|
||||
def _distribute_amount(total: Decimal, count: int) -> list[Decimal]:
|
||||
"""Distribute a total amount evenly across count entries. Last entry absorbs rounding."""
|
||||
if count == 0:
|
||||
return []
|
||||
per_entry = (total / count).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
amounts = [per_entry] * count
|
||||
# Adjust last entry to absorb rounding difference
|
||||
distributed_sum = per_entry * (count - 1)
|
||||
amounts[-1] = total - distributed_sum
|
||||
return amounts
|
||||
|
||||
|
||||
async def _get_already_distributed_qty(
|
||||
db: AsyncSession, line_item_id: int, exclude_distribution_id: Optional[int] = None
|
||||
) -> Decimal:
|
||||
"""Get total quantity already distributed for a line item from ACTIVE distributions."""
|
||||
query = (
|
||||
select(func.coalesce(func.sum(CostDistributionLineSelection.selected_quantity), 0))
|
||||
.join(CostDistribution, CostDistributionLineSelection.distribution_id == CostDistribution.id)
|
||||
.where(
|
||||
CostDistributionLineSelection.line_item_id == line_item_id,
|
||||
CostDistribution.status == DistributionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
if exclude_distribution_id:
|
||||
query = query.where(CostDistribution.id != exclude_distribution_id)
|
||||
result = await db.execute(query)
|
||||
return Decimal(str(result.scalar() or 0))
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/invoice/{invoice_id}/availability", response_model=InvoiceAvailabilityOut)
|
||||
async def get_invoice_availability(
|
||||
invoice_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get line items available for distribution from an invoice."""
|
||||
invoice = await db.execute(
|
||||
select(Invoice)
|
||||
.options(selectinload(Invoice.line_items))
|
||||
.where(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if invoice.status != InvoiceStatus.CONFIRMED:
|
||||
raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
|
||||
if invoice.document_type == "credit_note":
|
||||
raise HTTPException(400, "Credit notes cannot be distributed")
|
||||
|
||||
supplier_name = None
|
||||
if invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
|
||||
items = []
|
||||
for li in invoice.line_items:
|
||||
already = await _get_already_distributed_qty(db, li.id)
|
||||
original_qty = li.quantity or Decimal("0")
|
||||
available = max(Decimal("0"), original_qty - already)
|
||||
items.append(LineItemAvailability(
|
||||
id=li.id,
|
||||
description=li.description,
|
||||
unit=li.unit,
|
||||
quantity=li.quantity,
|
||||
unit_price=li.unit_price,
|
||||
amount=li.amount,
|
||||
is_non_stock=li.is_non_stock or False,
|
||||
already_distributed_qty=already,
|
||||
available_qty=available,
|
||||
))
|
||||
|
||||
return InvoiceAvailabilityOut(
|
||||
invoice_id=invoice.id,
|
||||
invoice_number=invoice.invoice_number,
|
||||
invoice_date=invoice.invoice_date,
|
||||
supplier_name=supplier_name or invoice.vendor_name,
|
||||
line_items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=CostDistributionOut)
|
||||
async def create_cost_distribution(
|
||||
data: CostDistributionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new cost distribution."""
|
||||
# Validate invoice
|
||||
invoice = await db.execute(
|
||||
select(Invoice)
|
||||
.options(selectinload(Invoice.line_items))
|
||||
.where(
|
||||
Invoice.id == data.invoice_id,
|
||||
Invoice.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
if invoice.status != InvoiceStatus.CONFIRMED:
|
||||
raise HTTPException(400, "Only CONFIRMED invoices can be distributed")
|
||||
if invoice.document_type == "credit_note":
|
||||
raise HTTPException(400, "Credit notes cannot be distributed")
|
||||
if not invoice.invoice_date:
|
||||
raise HTTPException(400, "Invoice must have a date to be distributed")
|
||||
|
||||
# Validate method
|
||||
if data.method not in [m.value for m in DistributionMethod]:
|
||||
raise HTTPException(400, f"Invalid method: {data.method}")
|
||||
|
||||
# Get settings for max_days validation
|
||||
settings = await db.execute(
|
||||
select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id)
|
||||
)
|
||||
settings = settings.scalar_one_or_none()
|
||||
max_days = settings.cost_distribution_max_days if settings else 90
|
||||
|
||||
# Generate target dates
|
||||
target_dates = _generate_target_dates(
|
||||
data.method, data.target_date, data.days_of_week, data.num_weeks, data.start_date
|
||||
)
|
||||
|
||||
# Validate max days
|
||||
today = date.today()
|
||||
max_allowed_date = today + timedelta(days=max_days)
|
||||
for td in target_dates:
|
||||
if td > max_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Target date {td} exceeds maximum of {max_days} days into the future ({max_allowed_date})"
|
||||
)
|
||||
|
||||
# Build line item map
|
||||
line_item_map = {li.id: li for li in invoice.line_items}
|
||||
|
||||
# Validate line selections and calculate totals
|
||||
if not data.line_selections:
|
||||
raise HTTPException(400, "At least one line item must be selected")
|
||||
|
||||
total_distributed_value = Decimal("0")
|
||||
selections_data = []
|
||||
|
||||
for sel in data.line_selections:
|
||||
li = line_item_map.get(sel.line_item_id)
|
||||
if not li:
|
||||
raise HTTPException(400, f"Line item {sel.line_item_id} not found on invoice")
|
||||
if li.is_non_stock:
|
||||
raise HTTPException(400, f"Non-stock item '{li.description}' cannot be distributed")
|
||||
if sel.selected_quantity <= 0:
|
||||
raise HTTPException(400, "Selected quantity must be greater than 0")
|
||||
|
||||
# Check available quantity
|
||||
already = await _get_already_distributed_qty(db, li.id)
|
||||
original_qty = li.quantity or Decimal("0")
|
||||
available = original_qty - already
|
||||
if sel.selected_quantity > available:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Requested qty {sel.selected_quantity} exceeds available {available} for '{li.description}'"
|
||||
)
|
||||
|
||||
unit_price = li.unit_price or Decimal("0")
|
||||
distributed_value = (sel.selected_quantity * unit_price).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
total_distributed_value += distributed_value
|
||||
selections_data.append({
|
||||
"line_item_id": li.id,
|
||||
"selected_quantity": sel.selected_quantity,
|
||||
"unit_price": unit_price,
|
||||
"distributed_value": distributed_value,
|
||||
})
|
||||
|
||||
if total_distributed_value <= 0:
|
||||
raise HTTPException(400, "Total distributed value must be greater than 0")
|
||||
|
||||
# Create the distribution header
|
||||
distribution = CostDistribution(
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
invoice_id=invoice.id,
|
||||
status=DistributionStatus.ACTIVE.value,
|
||||
method=data.method,
|
||||
notes=data.notes,
|
||||
total_distributed_value=total_distributed_value,
|
||||
remaining_balance=total_distributed_value,
|
||||
source_date=invoice.invoice_date,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(distribution)
|
||||
await db.flush() # Get the distribution ID
|
||||
|
||||
# Create line selections
|
||||
for sel_data in selections_data:
|
||||
selection = CostDistributionLineSelection(
|
||||
distribution_id=distribution.id,
|
||||
**sel_data,
|
||||
)
|
||||
db.add(selection)
|
||||
|
||||
# Create source offset entry (negative on invoice date)
|
||||
source_entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=invoice.invoice_date,
|
||||
amount=-total_distributed_value,
|
||||
is_source_offset=True,
|
||||
is_overpay=False,
|
||||
)
|
||||
db.add(source_entry)
|
||||
|
||||
# Create target entries
|
||||
entry_amounts = _distribute_amount(total_distributed_value, len(target_dates))
|
||||
for td, amt in zip(target_dates, entry_amounts):
|
||||
entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=td,
|
||||
amount=amt,
|
||||
is_source_offset=False,
|
||||
is_overpay=False,
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.get("/weekly", response_model=WeeklyDistributionsOut)
|
||||
async def get_weekly_distributions(
|
||||
week_start: date,
|
||||
week_end: date,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all cost distributions relevant to a week view for the budget page."""
|
||||
kitchen_id = current_user.kitchen_id
|
||||
|
||||
# Get ALL active distributions (even if no entries this week) to prevent gaming
|
||||
# Also include COMPLETED distributions that have entries in this week period
|
||||
active_dists = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.entries),
|
||||
selectinload(CostDistribution.invoice),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.kitchen_id == kitchen_id,
|
||||
CostDistribution.status == DistributionStatus.ACTIVE.value,
|
||||
)
|
||||
.order_by(CostDistribution.id)
|
||||
)
|
||||
active_dists = active_dists.scalars().all()
|
||||
|
||||
# Completed distributions that have entries within the viewed week
|
||||
completed_dists = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.entries),
|
||||
selectinload(CostDistribution.invoice),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.kitchen_id == kitchen_id,
|
||||
CostDistribution.status == DistributionStatus.COMPLETED.value,
|
||||
CostDistribution.id.in_(
|
||||
select(CostDistributionEntry.distribution_id)
|
||||
.where(
|
||||
CostDistributionEntry.kitchen_id == kitchen_id,
|
||||
CostDistributionEntry.entry_date >= week_start,
|
||||
CostDistributionEntry.entry_date <= week_end,
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(CostDistribution.id)
|
||||
)
|
||||
completed_dists = completed_dists.scalars().all()
|
||||
|
||||
# Merge — active first, then completed with entries in period
|
||||
seen_ids = {d.id for d in active_dists}
|
||||
distributions = list(active_dists)
|
||||
for d in completed_dists:
|
||||
if d.id not in seen_ids:
|
||||
distributions.append(d)
|
||||
|
||||
rows = []
|
||||
daily_totals: dict[str, Decimal] = defaultdict(Decimal)
|
||||
bf_balance = Decimal("0")
|
||||
week_total = Decimal("0")
|
||||
|
||||
for dist in distributions:
|
||||
# Get supplier name
|
||||
supplier_name = None
|
||||
if dist.invoice:
|
||||
if dist.invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == dist.invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
if not supplier_name:
|
||||
supplier_name = dist.invoice.vendor_name
|
||||
|
||||
invoice_num = dist.invoice.invoice_number if dist.invoice else None
|
||||
source_date_short = dist.source_date.strftime("%d/%m/%y") if dist.source_date else ""
|
||||
title = f"{source_date_short} - {supplier_name or 'Unknown'}"
|
||||
|
||||
# Build summary text
|
||||
total_val = f"£{float(dist.total_distributed_value):.2f}"
|
||||
target_entries = [e for e in dist.entries if not e.is_source_offset and not e.is_overpay]
|
||||
if dist.method == "OFFSET" and target_entries:
|
||||
target_date = target_entries[0].entry_date.strftime("%d/%m/%y")
|
||||
summary = f"{total_val} laid away to {target_date}"
|
||||
elif target_entries:
|
||||
# DISTRIBUTE: count unique days of week and number of weeks
|
||||
target_dates = sorted(set(e.entry_date for e in target_entries))
|
||||
if len(target_dates) > 1:
|
||||
first, last = target_dates[0], target_dates[-1]
|
||||
num_weeks = max(1, ((last - first).days // 7) + 1)
|
||||
dow_set = set(d.strftime("%a") for d in target_dates)
|
||||
days_str = ",".join(sorted(dow_set, key=lambda x: ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].index(x)))
|
||||
summary = f"{total_val} distributed {days_str} over {num_weeks} weeks"
|
||||
else:
|
||||
summary = f"{total_val} distributed to {target_dates[0].strftime('%d/%m/%y')}"
|
||||
else:
|
||||
summary = f"{total_val} distributed"
|
||||
|
||||
# Get entries for this week (including source offset so user sees the deduction)
|
||||
entries_by_date: dict[str, float] = {}
|
||||
for entry in dist.entries:
|
||||
if week_start <= entry.entry_date <= week_end:
|
||||
date_key = entry.entry_date.isoformat()
|
||||
entries_by_date[date_key] = entries_by_date.get(date_key, 0) + float(entry.amount)
|
||||
daily_totals[date_key] += entry.amount
|
||||
week_total += entry.amount
|
||||
|
||||
# Per-distribution BF/CF as net running balance:
|
||||
# BF = sum of ALL entries (including source offset) before this week
|
||||
# CF = BF + sum of ALL entries within this week
|
||||
# e.g. new distribution: BF=0, source deducts -£200, CF=-£200
|
||||
# next week: BF=-£200, positives bring it toward 0
|
||||
dist_bf = sum(
|
||||
e.amount for e in dist.entries
|
||||
if e.entry_date < week_start
|
||||
)
|
||||
entries_this_week = sum(
|
||||
e.amount for e in dist.entries
|
||||
if week_start <= e.entry_date <= week_end
|
||||
)
|
||||
dist_cf = dist_bf + entries_this_week
|
||||
|
||||
# Only include distributions that are relevant to this period:
|
||||
# has a non-zero BF or CF, or has entries in this week
|
||||
if dist_bf == 0 and dist_cf == 0 and not entries_by_date:
|
||||
continue
|
||||
|
||||
bf_balance += dist_bf
|
||||
|
||||
rows.append(WeeklyDistributionRow(
|
||||
distribution_id=dist.id,
|
||||
title=title,
|
||||
supplier_name=supplier_name,
|
||||
invoice_number=invoice_num,
|
||||
source_date_str=source_date_short,
|
||||
summary=summary,
|
||||
notes=dist.notes,
|
||||
invoice_id=dist.invoice_id,
|
||||
entries_by_date=entries_by_date,
|
||||
total_distributed_value=float(dist.total_distributed_value),
|
||||
remaining_balance=float(dist.remaining_balance),
|
||||
bf_balance=float(dist_bf),
|
||||
cf_balance=float(dist_cf),
|
||||
status=dist.status,
|
||||
))
|
||||
|
||||
cf_balance = bf_balance + week_total
|
||||
|
||||
return WeeklyDistributionsOut(
|
||||
week_start=week_start,
|
||||
week_end=week_end,
|
||||
distributions=rows,
|
||||
daily_totals={k: float(v) for k, v in daily_totals.items()},
|
||||
bf_balance=float(bf_balance),
|
||||
cf_balance=float(cf_balance),
|
||||
week_total=float(week_total),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{distribution_id}", response_model=CostDistributionOut)
|
||||
async def get_cost_distribution(
|
||||
distribution_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single cost distribution with line selections and entries."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.put("/{distribution_id}", response_model=CostDistributionOut)
|
||||
async def update_cost_distribution(
|
||||
distribution_id: int,
|
||||
data: CostDistributionUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update notes on an existing distribution."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status != DistributionStatus.ACTIVE.value:
|
||||
raise HTTPException(400, "Can only update ACTIVE distributions")
|
||||
|
||||
if data.notes is not None:
|
||||
distribution.notes = data.notes
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
@router.delete("/{distribution_id}")
|
||||
async def cancel_cost_distribution(
|
||||
distribution_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a cost distribution. Non-admin restricted to distributions
|
||||
where the source (invoice) date is within 14 days."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution).where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status == DistributionStatus.CANCELLED.value:
|
||||
raise HTTPException(400, "Distribution is already cancelled")
|
||||
|
||||
# Anti-gaming: non-admin cannot cancel distributions where the source
|
||||
# date is more than 14 days in the past (cost would silently revert to
|
||||
# the old invoice date and could be overlooked)
|
||||
if not current_user.is_admin:
|
||||
min_allowed_date = date.today() - timedelta(days=14)
|
||||
if distribution.source_date < min_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot cancel — invoice date {distribution.source_date} is more than 14 days ago. Ask an admin."
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
distribution.status = DistributionStatus.CANCELLED.value
|
||||
distribution.cancelled_by = current_user.id
|
||||
distribution.cancelled_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Cost distribution cancelled", "id": distribution_id}
|
||||
|
||||
|
||||
@router.post("/{distribution_id}/settle-early", response_model=CostDistributionOut)
|
||||
async def settle_early(
|
||||
distribution_id: int,
|
||||
data: SettleEarlyRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Settle a distribution early by moving remaining balance to a chosen date."""
|
||||
distribution = await db.execute(
|
||||
select(CostDistribution)
|
||||
.options(
|
||||
selectinload(CostDistribution.line_selections).selectinload(CostDistributionLineSelection.line_item),
|
||||
selectinload(CostDistribution.entries),
|
||||
)
|
||||
.where(
|
||||
CostDistribution.id == distribution_id,
|
||||
CostDistribution.kitchen_id == current_user.kitchen_id,
|
||||
)
|
||||
)
|
||||
distribution = distribution.scalar_one_or_none()
|
||||
if not distribution:
|
||||
raise HTTPException(404, "Cost distribution not found")
|
||||
if distribution.status != DistributionStatus.ACTIVE.value:
|
||||
raise HTTPException(400, "Can only settle ACTIVE distributions")
|
||||
|
||||
today = date.today()
|
||||
|
||||
# Anti-gaming: non-admin cannot settle more than 14 days in the past
|
||||
if not current_user.is_admin:
|
||||
min_allowed_date = today - timedelta(days=14)
|
||||
if data.entry_date < min_allowed_date:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot settle more than 14 days in the past. Earliest allowed: {min_allowed_date}"
|
||||
)
|
||||
|
||||
# Calculate entries from the settle date onwards — these get replaced by the single
|
||||
# settle entry. Using entry_date (not today) so that settling for yesterday correctly
|
||||
# consolidates yesterday's entry + all future entries into the chosen date.
|
||||
settable_entries = [
|
||||
e for e in distribution.entries
|
||||
if not e.is_source_offset and not e.is_overpay and e.entry_date >= data.entry_date
|
||||
]
|
||||
settable_total = sum(e.amount for e in settable_entries)
|
||||
|
||||
if settable_total <= 0:
|
||||
raise HTTPException(400, "No entries to settle from the chosen date onwards")
|
||||
|
||||
settle_amount = data.amount if data.amount is not None else settable_total
|
||||
if settle_amount <= 0:
|
||||
raise HTTPException(400, "Settle amount must be greater than 0")
|
||||
if settle_amount > settable_total:
|
||||
raise HTTPException(400, f"Settle amount {settle_amount} exceeds settable entries total {settable_total}")
|
||||
|
||||
# Delete all entries from the settle date onwards
|
||||
for entry in settable_entries:
|
||||
await db.delete(entry)
|
||||
|
||||
# Create settle entry on the chosen date
|
||||
overpay_entry = CostDistributionEntry(
|
||||
distribution_id=distribution.id,
|
||||
kitchen_id=current_user.kitchen_id,
|
||||
entry_date=data.entry_date,
|
||||
amount=settle_amount,
|
||||
is_source_offset=False,
|
||||
is_overpay=True,
|
||||
)
|
||||
db.add(overpay_entry)
|
||||
|
||||
# Update remaining_balance: what's left unaccounted
|
||||
new_remaining = settable_total - settle_amount
|
||||
distribution.remaining_balance = new_remaining
|
||||
|
||||
# If fully settled, mark as completed
|
||||
if new_remaining <= 0:
|
||||
distribution.status = DistributionStatus.COMPLETED.value
|
||||
distribution.remaining_balance = Decimal("0")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(distribution)
|
||||
|
||||
return await _build_distribution_response(db, distribution)
|
||||
|
||||
|
||||
# ── Response builder helper ───────────────────────────────────────────────────
|
||||
|
||||
async def _build_distribution_response(
|
||||
db: AsyncSession, distribution: CostDistribution
|
||||
) -> CostDistributionOut:
|
||||
"""Build the full response object for a cost distribution."""
|
||||
# Get invoice info
|
||||
invoice = await db.execute(
|
||||
select(Invoice).where(Invoice.id == distribution.invoice_id)
|
||||
)
|
||||
invoice = invoice.scalar_one_or_none()
|
||||
|
||||
supplier_name = None
|
||||
if invoice and invoice.supplier_id:
|
||||
supplier = await db.execute(
|
||||
select(Supplier).where(Supplier.id == invoice.supplier_id)
|
||||
)
|
||||
supplier = supplier.scalar_one_or_none()
|
||||
if supplier:
|
||||
supplier_name = supplier.name
|
||||
if not supplier_name and invoice:
|
||||
supplier_name = invoice.vendor_name
|
||||
|
||||
# Get creator name
|
||||
creator = await db.execute(
|
||||
select(User).where(User.id == distribution.created_by)
|
||||
)
|
||||
creator = creator.scalar_one_or_none()
|
||||
|
||||
# Build line selections with line item details
|
||||
line_selections = []
|
||||
for sel in distribution.line_selections:
|
||||
li = sel.line_item if hasattr(sel, 'line_item') and sel.line_item else None
|
||||
if not li:
|
||||
li_result = await db.execute(select(LineItem).where(LineItem.id == sel.line_item_id))
|
||||
li = li_result.scalar_one_or_none()
|
||||
|
||||
line_selections.append(LineSelectionOut(
|
||||
id=sel.id,
|
||||
line_item_id=sel.line_item_id,
|
||||
description=li.description if li else None,
|
||||
original_quantity=li.quantity if li else None,
|
||||
selected_quantity=sel.selected_quantity,
|
||||
unit_price=sel.unit_price,
|
||||
distributed_value=sel.distributed_value,
|
||||
))
|
||||
|
||||
entries = [
|
||||
EntryOut(
|
||||
id=e.id,
|
||||
entry_date=e.entry_date,
|
||||
amount=e.amount,
|
||||
is_source_offset=e.is_source_offset,
|
||||
is_overpay=e.is_overpay,
|
||||
)
|
||||
for e in distribution.entries
|
||||
]
|
||||
|
||||
return CostDistributionOut(
|
||||
id=distribution.id,
|
||||
invoice_id=distribution.invoice_id,
|
||||
invoice_number=invoice.invoice_number if invoice else None,
|
||||
invoice_date=invoice.invoice_date if invoice else None,
|
||||
supplier_name=supplier_name,
|
||||
status=distribution.status,
|
||||
method=distribution.method,
|
||||
notes=distribution.notes,
|
||||
total_distributed_value=distribution.total_distributed_value,
|
||||
remaining_balance=distribution.remaining_balance,
|
||||
source_date=distribution.source_date,
|
||||
created_by_name=creator.name if creator else None,
|
||||
created_at=distribution.created_at.isoformat() if distribution.created_at else "",
|
||||
line_selections=line_selections,
|
||||
entries=entries,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue