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
This commit is contained in:
moneyfy
2026-09-09 16:50:22 +02:00
co-authored by Claude Opus 5
parent 8d6fcfeb58
commit 0adf154049
31 changed files with 5721 additions and 121 deletions
+177
View File
@@ -4,7 +4,9 @@ from datetime import date
from pydantic import Field
from app.models.enums import EntryKind, OccurrenceStatus
from app.schemas.common import ApiModel, Money
from app.schemas.recurrence import ContractTermOut
class TotalsOut(ApiModel):
@@ -43,3 +45,178 @@ class MonthReportOut(ApiModel):
confirmed_count: int
open_count: int
skipped_count: int
class ForecastMonthOut(ApiModel):
"""Ein Monat der Vorschau."""
month: date
income: Money
expenses: Money
balance: Money
cumulative_balance: Money = Field(
description="Prognostizierter Kontostand am Monatsende über alle Konten."
)
class ForecastOut(ApiModel):
months: list[ForecastMonthOut]
total_income: Money
total_expenses: Money
class CategorySliceOut(ApiModel):
"""Summe einer Kategorie; Oberkategorien tragen ihre Unterkategorien."""
category_id: int
name: str
color: str
icon: str
amount: Money
count: int
children: list["CategorySliceOut"] = Field(default_factory=list)
class CategoryReportOut(ApiModel):
date_from: date
date_to: date
kind: EntryKind
total: Money
categories: list[CategorySliceOut]
class SubscriptionOut(ApiModel):
"""Ein laufender Posten mit Jahreskosten."""
recurrence_id: int
title: str
merchant_id: int | None
merchant_name: str | None
category_id: int
amount: Money
annual_cost: Money
monthly_cost: Money
rrule: str
is_installment: bool = Field(
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
)
is_cancelled: bool
contract_term: ContractTermOut | None = None
days_until_notice: int | None = Field(
default=None, description="Tage bis zum letzten Kündigungstermin."
)
class SubscriptionReportOut(ApiModel):
entries: list[SubscriptionOut]
total_annual: Money = Field(description="Summe über alle Posten ohne Ratenzahlungen.")
total_monthly: Money
upcoming_deadlines: list[SubscriptionOut] = Field(
description="Kündigungsfristen, die in den nächsten 60 Tagen ablaufen."
)
class YearComparisonRowOut(ApiModel):
category_id: int
name: str
color: str
current: Money
previous: Money
delta: Money
class YearComparisonOut(ApiModel):
year: int
rows: list[YearComparisonRowOut]
current_total: Money
previous_total: Money
class CalendarEntryOut(ApiModel):
"""Ein fälliger Posten im Kalender."""
title: str
kind: EntryKind
amount: Money
category_id: int
merchant_id: int | None
account_id: int | None
source: str = Field(description="'recurrence' oder 'transaction'.")
recurrence_id: int | None = None
occurrence_date: date | None = Field(
default=None, description="Nominales Datum Schlüssel für Bestätigen und Auslassen."
)
status: OccurrenceStatus | None = None
is_variable: bool = False
class CalendarDayOut(ApiModel):
date: date
entries: list[CalendarEntryOut]
net: Money = Field(description="Saldo des Tages, Ausgaben negativ.")
running_balance: Money
is_business_day: bool
class CalendarMonthOut(ApiModel):
month: date
days: list[CalendarDayOut]
opening_balance: Money
closing_balance: Money
lowest_balance: Money
lowest_balance_on: date | None
class BudgetStatusOut(ApiModel):
"""Stand eines Budgets samt Ampel."""
category_id: int
category_name: str
color: str
period_month: date
limit_amount: Money
carried_over: Money = Field(description="Übertrag aus Vormonaten bei aktivem Rollover.")
available: Money = Field(description="Limit zuzüglich Übertrag.")
spent: Money
remaining: Money
ratio: float = Field(description="Verbrauchsanteil; 1.0 entspricht 100 %.")
state: str = Field(description="'ok' unter 80 %, 'warning' unter 100 %, sonst 'exceeded'.")
rollover: bool
is_template: bool
class SavingsGoalProgressOut(ApiModel):
"""Fortschritt eines Sparziels."""
goal_id: int
name: str
color: str
icon: str
target_amount: Money
current_amount: Money
remaining_amount: Money
ratio: float
target_date: date | None
months_left: int | None
required_monthly: Money | None = Field(
default=None, description="Nötige Rate bis zum Zieldatum."
)
monthly_contribution: Money | None
is_on_track: bool | None = Field(
default=None, description="Reicht die geplante Rate bis zum Zieldatum?"
)
class DashboardOut(ApiModel):
"""Alles, was das Dashboard in einem Aufruf braucht."""
month: MonthReportOut
total_balance: Money
forecast: list[ForecastMonthOut]
categories: list[CategorySliceOut]
budgets: list[BudgetStatusOut]
goals: list[SavingsGoalProgressOut]
upcoming: list[CalendarEntryOut] = Field(
description="Die nächsten Fälligkeiten der kommenden 14 Tage."
)
upcoming_deadlines: list[SubscriptionOut]