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:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View 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,
)