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