Files
moneyfy/backend/app/api/routes/reports.py
T
moneyfyandClaude Opus 5 0adf154049 feat(reports): Auswertungen, Kalender, Budgets und Export
Backend:
- Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und
  Ist bleiben dabei getrennt
- Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich,
  Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard
- Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen
  Unterkategorien ein
- Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit
  typisierten Beträgen

Frontend:
- Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel-
  und Auswertungsseite
- Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit
  geprüft; Grün/Rot als Serienpaar verworfen
- Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf
  zwei Größenachsen
- Recharts in einen eigenen Chunk ausgelagert

27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 16:50:22 +02:00

407 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="Abo-Übersicht",
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
)
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],
)