- Demo-Seed mit realistischem deutschem Haushalt (make seed-demo): drei Konten, zehn Firmen samt Logos, zwölf Posten, bestätigte Historie, Buchungen, Budgets und Sparziele; alle Termine relativ zum heutigen Monat - Tastaturkürzel n / r / / / ? samt Übersicht; greifen nur außerhalb von Eingabefeldern - Fehlergrenze für Renderfehler und eigene 404-Seite - Farbtoken beider Modi gegen WCAG geprüft und nachgezogen: Hilfstext lag hell bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1 – jetzt überall >= 4,5:1 - Abo-Übersicht heißt „Laufende Kosten“, weil sie auch Miete und Sparplan enthält - build.yml fällt auf GITEA_TOKEN zurück, wenn REGISTRY_TOKEN fehlt, und erklärt im Fehlerfall, wie das Secret anzulegen ist - README mit Screenshot-Abschnitt, Kürzeln und Barrierefreiheit Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
408 lines
13 KiB
Python
408 lines
13 KiB
Python
"""Auswertungen."""
|
||
|
||
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
|
||
from fastapi import APIRouter, Query
|
||
from sqlalchemy import select
|
||
|
||
from app.api.deps import DbSession
|
||
from app.core.clock import month_end, month_start, today
|
||
from app.core.errors import ValidationError
|
||
from app.models import SavingsGoal
|
||
from app.models.enums import EntryKind
|
||
from app.schemas.recurrence import ContractTermOut
|
||
from app.schemas.report import (
|
||
BudgetStatusOut,
|
||
CalendarDayOut,
|
||
CalendarEntryOut,
|
||
CalendarMonthOut,
|
||
CategoryReportOut,
|
||
CategorySliceOut,
|
||
DashboardOut,
|
||
ForecastMonthOut,
|
||
ForecastOut,
|
||
MonthComparisonOut,
|
||
MonthReportOut,
|
||
SavingsGoalProgressOut,
|
||
SubscriptionOut,
|
||
SubscriptionReportOut,
|
||
TotalsOut,
|
||
YearComparisonOut,
|
||
YearComparisonRowOut,
|
||
)
|
||
from app.services.balances import total_balance
|
||
from app.services.reports import (
|
||
BudgetStatus,
|
||
CategorySlice,
|
||
FlowEntry,
|
||
ForecastMonth,
|
||
MonthReport,
|
||
SubscriptionEntry,
|
||
Totals,
|
||
budget_status,
|
||
calendar_month,
|
||
category_breakdown,
|
||
flows,
|
||
forecast,
|
||
month_report,
|
||
months_between,
|
||
required_monthly_rate,
|
||
subscriptions,
|
||
year_comparison,
|
||
)
|
||
|
||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||
|
||
MAX_FORECAST_MONTHS = 60
|
||
|
||
|
||
# --- Umsetzung in Schemata -----------------------------------------------------
|
||
|
||
|
||
def _totals(value: Totals) -> TotalsOut:
|
||
return TotalsOut(income=value.income, expenses=value.expenses, balance=value.balance)
|
||
|
||
|
||
def _month(report: MonthReport) -> MonthReportOut:
|
||
return MonthReportOut(
|
||
month=report.month,
|
||
planned=_totals(report.planned),
|
||
actual=_totals(report.actual),
|
||
previous_planned=_totals(report.previous_planned),
|
||
previous_actual=_totals(report.previous_actual),
|
||
delta_to_previous=MonthComparisonOut(
|
||
income=report.income_delta,
|
||
expenses=report.expenses_delta,
|
||
balance=report.balance_delta,
|
||
),
|
||
fixed_costs=report.fixed_costs,
|
||
variable_costs=report.variable_costs,
|
||
reserves=report.reserves,
|
||
available_after_fixed=report.available_after_fixed,
|
||
confirmed_count=report.confirmed_count,
|
||
open_count=report.open_count,
|
||
skipped_count=report.skipped_count,
|
||
)
|
||
|
||
|
||
def _forecast_month(entry: ForecastMonth) -> ForecastMonthOut:
|
||
return ForecastMonthOut(
|
||
month=entry.month,
|
||
income=entry.income,
|
||
expenses=entry.expenses,
|
||
balance=entry.balance,
|
||
cumulative_balance=entry.cumulative_balance,
|
||
)
|
||
|
||
|
||
def _slice(value: CategorySlice) -> CategorySliceOut:
|
||
return CategorySliceOut(
|
||
category_id=value.category_id,
|
||
name=value.name,
|
||
color=value.color,
|
||
icon=value.icon,
|
||
amount=value.amount,
|
||
count=value.count,
|
||
children=[_slice(kind) for kind in value.children],
|
||
)
|
||
|
||
|
||
def _subscription(entry: SubscriptionEntry) -> SubscriptionOut:
|
||
return SubscriptionOut(
|
||
recurrence_id=entry.recurrence_id,
|
||
title=entry.title,
|
||
merchant_id=entry.merchant_id,
|
||
merchant_name=entry.merchant_name,
|
||
category_id=entry.category_id,
|
||
amount=entry.amount,
|
||
annual_cost=entry.annual_cost,
|
||
monthly_cost=entry.monthly_cost,
|
||
rrule=entry.rrule,
|
||
is_installment=entry.is_installment,
|
||
is_cancelled=entry.is_cancelled,
|
||
contract_term=ContractTermOut.model_validate(entry.term) if entry.term else None,
|
||
days_until_notice=entry.days_until_notice,
|
||
)
|
||
|
||
|
||
def _budget(entry: BudgetStatus) -> BudgetStatusOut:
|
||
return BudgetStatusOut(
|
||
category_id=entry.category_id,
|
||
category_name=entry.category_name,
|
||
color=entry.color,
|
||
period_month=entry.period_month,
|
||
limit_amount=entry.limit_amount,
|
||
carried_over=entry.carried_over,
|
||
available=entry.available,
|
||
spent=entry.spent,
|
||
remaining=entry.remaining,
|
||
ratio=float(entry.ratio),
|
||
state=entry.state,
|
||
rollover=entry.rollover,
|
||
is_template=entry.is_template,
|
||
)
|
||
|
||
|
||
def _entry(flow: FlowEntry) -> CalendarEntryOut:
|
||
return CalendarEntryOut(
|
||
title=flow.title,
|
||
kind=flow.kind,
|
||
amount=flow.amount,
|
||
category_id=flow.category_id,
|
||
merchant_id=flow.merchant_id,
|
||
account_id=flow.account_id,
|
||
source=flow.source,
|
||
recurrence_id=flow.recurrence_id,
|
||
occurrence_date=flow.occurrence_date,
|
||
status=flow.status,
|
||
is_variable=flow.is_variable,
|
||
)
|
||
|
||
|
||
async def _goals(session: DbSession, as_of: date) -> list[SavingsGoalProgressOut]:
|
||
"""Fortschritt aller nicht archivierten Sparziele."""
|
||
stmt = (
|
||
select(SavingsGoal)
|
||
.where(SavingsGoal.is_archived.is_(False))
|
||
.order_by(SavingsGoal.target_date.nulls_last(), SavingsGoal.name)
|
||
)
|
||
|
||
ergebnis: list[SavingsGoalProgressOut] = []
|
||
for ziel in (await session.execute(stmt)).scalars():
|
||
offen = max(ziel.target_amount - ziel.current_amount, 0)
|
||
noetig = required_monthly_rate(
|
||
ziel.target_amount, ziel.current_amount, ziel.target_date, as_of
|
||
)
|
||
monate = months_between(as_of, ziel.target_date) if ziel.target_date else None
|
||
|
||
ergebnis.append(
|
||
SavingsGoalProgressOut(
|
||
goal_id=ziel.id,
|
||
name=ziel.name,
|
||
color=ziel.color,
|
||
icon=ziel.icon,
|
||
target_amount=ziel.target_amount,
|
||
current_amount=ziel.current_amount,
|
||
remaining_amount=offen,
|
||
ratio=(
|
||
float(ziel.current_amount / ziel.target_amount)
|
||
if ziel.target_amount > 0
|
||
else 0.0
|
||
),
|
||
target_date=ziel.target_date,
|
||
months_left=monate,
|
||
required_monthly=noetig,
|
||
monthly_contribution=ziel.monthly_contribution,
|
||
is_on_track=(
|
||
None
|
||
if noetig is None or ziel.monthly_contribution is None
|
||
else ziel.monthly_contribution >= noetig
|
||
),
|
||
)
|
||
)
|
||
return ergebnis
|
||
|
||
|
||
# --- Endpunkte -----------------------------------------------------------------
|
||
|
||
|
||
@router.get(
|
||
"/month",
|
||
response_model=MonthReportOut,
|
||
summary="Monatsübersicht",
|
||
description="Einnahmen, Ausgaben, Saldo, Plan-Ist-Vergleich, Aufteilung in fixe "
|
||
"und variable Kosten sowie die Veränderung gegenüber dem Vormonat.",
|
||
)
|
||
async def read_month_report(
|
||
session: DbSession,
|
||
month: date | None = Query(
|
||
default=None, description="Beliebiger Tag im gewünschten Monat; Vorgabe ist heute."
|
||
),
|
||
) -> MonthReportOut:
|
||
return _month(await month_report(session, month or today()))
|
||
|
||
|
||
@router.get(
|
||
"/forecast",
|
||
response_model=ForecastOut,
|
||
summary="Vorschau über mehrere Monate",
|
||
description="Jährliche Posten erscheinen in ihrem echten Fälligkeitsmonat, weil "
|
||
"die Reihe aus der tatsächlichen Expansion entsteht.",
|
||
)
|
||
async def read_forecast(
|
||
session: DbSession,
|
||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
||
start: date | None = Query(default=None, description="Erster Monat; Vorgabe ist heute."),
|
||
) -> ForecastOut:
|
||
monate = await forecast(session, months, start)
|
||
return ForecastOut(
|
||
months=[_forecast_month(monat) for monat in monate],
|
||
total_income=sum((monat.income for monat in monate), Decimal("0.00")),
|
||
total_expenses=sum((monat.expenses for monat in monate), Decimal("0.00")),
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/categories",
|
||
response_model=CategoryReportOut,
|
||
summary="Aufteilung nach Kategorien",
|
||
description="Summen je Oberkategorie mit Drilldown auf die Unterkategorien.",
|
||
)
|
||
async def read_categories(
|
||
session: DbSession,
|
||
date_from: date | None = Query(default=None, alias="from"),
|
||
date_to: date | None = Query(default=None, alias="to"),
|
||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||
) -> CategoryReportOut:
|
||
heute = today()
|
||
start = date_from or month_start(heute)
|
||
ende = date_to or month_end(heute)
|
||
if ende < start:
|
||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||
|
||
gruppen = await category_breakdown(session, start, ende, kind)
|
||
return CategoryReportOut(
|
||
date_from=start,
|
||
date_to=ende,
|
||
kind=kind,
|
||
total=sum((gruppe.amount for gruppe in gruppen), Decimal("0.00")),
|
||
categories=[_slice(gruppe) for gruppe in gruppen],
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/subscriptions",
|
||
response_model=SubscriptionReportOut,
|
||
summary="Laufende Kosten",
|
||
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten – von der "
|
||
"Miete bis zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht "
|
||
"in `total_annual`, weil sie ein Ende haben.",
|
||
)
|
||
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
|
||
bericht = await subscriptions(session)
|
||
return SubscriptionReportOut(
|
||
entries=[_subscription(eintrag) for eintrag in bericht.entries],
|
||
total_annual=bericht.total_annual,
|
||
total_monthly=bericht.total_monthly,
|
||
upcoming_deadlines=[_subscription(eintrag) for eintrag in bericht.upcoming_deadlines],
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/year-comparison",
|
||
response_model=YearComparisonOut,
|
||
summary="Jahresvergleich",
|
||
description="Aktuelles Jahr gegen Vorjahr, aufgeschlüsselt nach Oberkategorie.",
|
||
)
|
||
async def read_year_comparison(
|
||
session: DbSession,
|
||
year: int | None = Query(default=None, ge=1970, le=2200),
|
||
kind: EntryKind = Query(default=EntryKind.EXPENSE),
|
||
) -> YearComparisonOut:
|
||
vergleich = await year_comparison(session, year or today().year, kind)
|
||
return YearComparisonOut(
|
||
year=vergleich.year,
|
||
rows=[
|
||
YearComparisonRowOut(
|
||
category_id=zeile.category_id,
|
||
name=zeile.name,
|
||
color=zeile.color,
|
||
current=zeile.current,
|
||
previous=zeile.previous,
|
||
delta=zeile.delta,
|
||
)
|
||
for zeile in vergleich.rows
|
||
],
|
||
current_total=vergleich.current_total,
|
||
previous_total=vergleich.previous_total,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/calendar",
|
||
response_model=CalendarMonthOut,
|
||
summary="Cashflow-Kalender",
|
||
description="Monatsraster mit den Fälligkeiten je Tag und dem laufenden Kontostand.",
|
||
)
|
||
async def read_calendar(
|
||
session: DbSession,
|
||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||
) -> CalendarMonthOut:
|
||
raster = await calendar_month(session, month or today())
|
||
return CalendarMonthOut(
|
||
month=raster.month,
|
||
days=[
|
||
CalendarDayOut(
|
||
date=tag.on,
|
||
entries=[_entry(eintrag) for eintrag in tag.entries],
|
||
net=tag.net,
|
||
running_balance=tag.running_balance,
|
||
is_business_day=tag.is_business_day,
|
||
)
|
||
for tag in raster.days
|
||
],
|
||
opening_balance=raster.opening_balance,
|
||
closing_balance=raster.closing_balance,
|
||
lowest_balance=raster.lowest_balance,
|
||
lowest_balance_on=raster.lowest_balance_on,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/budgets",
|
||
response_model=list[BudgetStatusOut],
|
||
summary="Budget-Ampel",
|
||
description="Verbrauch je Budget im Monat. Grün unter 80 %, gelb unter 100 %, "
|
||
"darüber rot. Budgets auf einer Oberkategorie umfassen deren Unterkategorien.",
|
||
)
|
||
async def read_budget_status(
|
||
session: DbSession,
|
||
month: date | None = Query(default=None),
|
||
) -> list[BudgetStatusOut]:
|
||
return [_budget(eintrag) for eintrag in await budget_status(session, month or today())]
|
||
|
||
|
||
@router.get(
|
||
"/savings-goals",
|
||
response_model=list[SavingsGoalProgressOut],
|
||
summary="Fortschritt der Sparziele",
|
||
description="Fortschritt und die bis zum Zieldatum nötige Monatsrate.",
|
||
)
|
||
async def read_goal_progress(session: DbSession) -> list[SavingsGoalProgressOut]:
|
||
return await _goals(session, today())
|
||
|
||
|
||
@router.get(
|
||
"/dashboard",
|
||
response_model=DashboardOut,
|
||
summary="Dashboard",
|
||
description="Bündelt Monatsübersicht, Vorschau, Kategorien, Budgets, Sparziele "
|
||
"und die nächsten Fälligkeiten in einem Aufruf.",
|
||
)
|
||
async def read_dashboard(
|
||
session: DbSession,
|
||
month: date | None = Query(default=None),
|
||
) -> DashboardOut:
|
||
heute = today()
|
||
monat = month_start(month or heute)
|
||
|
||
bericht = await month_report(session, monat)
|
||
vorschau = await forecast(session, 12, monat)
|
||
gruppen = await category_breakdown(session, monat, month_end(monat))
|
||
abos = await subscriptions(session)
|
||
|
||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Monat.
|
||
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||
|
||
return DashboardOut(
|
||
month=_month(bericht),
|
||
total_balance=await total_balance(session, heute),
|
||
forecast=[_forecast_month(eintrag) for eintrag in vorschau],
|
||
categories=[_slice(gruppe) for gruppe in gruppen],
|
||
budgets=[_budget(eintrag) for eintrag in await budget_status(session, monat)],
|
||
goals=await _goals(session, heute),
|
||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||
)
|