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:
@@ -12,6 +12,7 @@ from app.api.routes import (
|
||||
auth,
|
||||
budgets,
|
||||
categories,
|
||||
export,
|
||||
logos,
|
||||
me,
|
||||
merchants,
|
||||
@@ -43,5 +44,6 @@ protected.include_router(budgets.router)
|
||||
protected.include_router(budgets.templates)
|
||||
protected.include_router(savings_goals.router)
|
||||
protected.include_router(reports.router)
|
||||
protected.include_router(export.router)
|
||||
|
||||
api_router.include_router(protected)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Export von Buchungen, Posten und Monatsauswertung als CSV oder XLSX."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Query, Response, status
|
||||
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 Account, Merchant, Transaction
|
||||
from app.models.enums import EntryKind
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx
|
||||
from app.services.reports import (
|
||||
category_names,
|
||||
flow_rows,
|
||||
flows,
|
||||
month_report,
|
||||
recurrence_rows,
|
||||
reserve_total,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/export", tags=["export"])
|
||||
|
||||
ExportFormat = Literal["csv", "xlsx"]
|
||||
|
||||
TRANSACTION_COLUMNS = (
|
||||
"Datum",
|
||||
"Titel",
|
||||
"Richtung",
|
||||
"Betrag",
|
||||
"Kategorie",
|
||||
"Firma",
|
||||
"Konto",
|
||||
"Notiz",
|
||||
)
|
||||
|
||||
RECURRENCE_COLUMNS = (
|
||||
"Titel",
|
||||
"Richtung",
|
||||
"Betrag",
|
||||
"Wiederholung",
|
||||
"Beginn",
|
||||
"Ende",
|
||||
"Kategorie",
|
||||
"Firma",
|
||||
"Jahreskosten",
|
||||
"Raten",
|
||||
"Gekündigt zum",
|
||||
"Aktiv",
|
||||
)
|
||||
|
||||
FLOW_COLUMNS = ("Datum", "Titel", "Kategorie", "Richtung", "Betrag", "Herkunft", "Bestätigt")
|
||||
SUMMARY_COLUMNS = ("Kennzahl", "Betrag")
|
||||
|
||||
# Starlette benennt die 422-Konstante gerade um – fester Wert vermeidet die Abhängigkeit.
|
||||
BAD_REQUEST = {422: {"model": ErrorResponse}}
|
||||
|
||||
|
||||
def _download(content: bytes, name: str, fmt: ExportFormat) -> Response:
|
||||
"""Antwort mit passendem Typ und Dateinamen."""
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=CONTENT_TYPES[fmt],
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/transactions",
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"content": {"text/csv": {}, CONTENT_TYPES["xlsx"]: {}},
|
||||
"description": "Die Buchungen als Datei.",
|
||||
},
|
||||
**BAD_REQUEST,
|
||||
},
|
||||
summary="Buchungen exportieren",
|
||||
description="Ohne Zeitraum wird das laufende Jahr ausgegeben.",
|
||||
)
|
||||
async def export_transactions(
|
||||
session: DbSession,
|
||||
fmt: ExportFormat = Query(default="csv", alias="format"),
|
||||
date_from: date | None = Query(default=None, alias="from"),
|
||||
date_to: date | None = Query(default=None, alias="to"),
|
||||
) -> Response:
|
||||
heute = today()
|
||||
start = date_from or date(heute.year, 1, 1)
|
||||
ende = date_to or date(heute.year, 12, 31)
|
||||
if ende < start:
|
||||
raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range")
|
||||
|
||||
namen = await category_names(session)
|
||||
firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()}
|
||||
konten = await _account_names(session)
|
||||
|
||||
stmt = (
|
||||
select(Transaction)
|
||||
.where(Transaction.booking_date >= start, Transaction.booking_date <= ende)
|
||||
.order_by(Transaction.booking_date, Transaction.id)
|
||||
)
|
||||
zeilen = [
|
||||
{
|
||||
"Datum": buchung.booking_date.isoformat(),
|
||||
"Titel": buchung.title,
|
||||
"Richtung": "Einkunft" if buchung.kind is EntryKind.INCOME else "Ausgabe",
|
||||
"Betrag": buchung.amount,
|
||||
"Kategorie": namen.get(buchung.category_id, ""),
|
||||
"Firma": firmen.get(buchung.merchant_id or -1, ""),
|
||||
"Konto": konten.get(buchung.account_id, ""),
|
||||
"Notiz": buchung.note or "",
|
||||
}
|
||||
for buchung in (await session.execute(stmt)).scalars()
|
||||
]
|
||||
|
||||
zeitraum = f"{start.isoformat()}_{ende.isoformat()}"
|
||||
if fmt == "csv":
|
||||
return _download(
|
||||
to_csv(zeilen, TRANSACTION_COLUMNS), filename("buchungen", "csv", zeitraum), fmt
|
||||
)
|
||||
return _download(
|
||||
to_xlsx({"Buchungen": zeilen}, {"Buchungen": TRANSACTION_COLUMNS}),
|
||||
filename("buchungen", "xlsx", zeitraum),
|
||||
fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recurrences",
|
||||
responses={status.HTTP_200_OK: {"description": "Die Posten als Datei."}},
|
||||
summary="Wiederkehrende Posten exportieren",
|
||||
)
|
||||
async def export_recurrences(
|
||||
session: DbSession,
|
||||
fmt: ExportFormat = Query(default="csv", alias="format"),
|
||||
) -> Response:
|
||||
zeilen = await recurrence_rows(session)
|
||||
|
||||
if fmt == "csv":
|
||||
return _download(to_csv(zeilen, RECURRENCE_COLUMNS), filename("posten", "csv"), fmt)
|
||||
return _download(
|
||||
to_xlsx({"Posten": zeilen}, {"Posten": RECURRENCE_COLUMNS}),
|
||||
filename("posten", "xlsx"),
|
||||
fmt,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/month",
|
||||
responses={status.HTTP_200_OK: {"description": "Die Monatsauswertung als Datei."}},
|
||||
summary="Monatsauswertung exportieren",
|
||||
description="Enthält alle Bewegungen des Monats und eine Kennzahlenübersicht. "
|
||||
"Im XLSX-Format stehen beide auf getrennten Blättern.",
|
||||
)
|
||||
async def export_month(
|
||||
session: DbSession,
|
||||
fmt: ExportFormat = Query(default="xlsx", alias="format"),
|
||||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||||
) -> Response:
|
||||
monat = month_start(month or today())
|
||||
namen = await category_names(session)
|
||||
|
||||
bewegungen = await flows(session, monat, month_end(monat))
|
||||
zeilen = flow_rows(bewegungen, namen)
|
||||
bericht = await month_report(session, monat)
|
||||
|
||||
kennzahlen: list[dict[str, object]] = [
|
||||
{"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income},
|
||||
{"Kennzahl": "Ausgaben (Plan)", "Betrag": bericht.planned.expenses},
|
||||
{"Kennzahl": "Saldo (Plan)", "Betrag": bericht.planned.balance},
|
||||
{"Kennzahl": "Einnahmen (Ist)", "Betrag": bericht.actual.income},
|
||||
{"Kennzahl": "Ausgaben (Ist)", "Betrag": bericht.actual.expenses},
|
||||
{"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance},
|
||||
{"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs},
|
||||
{"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs},
|
||||
{"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, monat)},
|
||||
{"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed},
|
||||
]
|
||||
|
||||
kennung = monat.strftime("%Y-%m")
|
||||
if fmt == "csv":
|
||||
# CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile.
|
||||
inhalt = to_csv(zeilen, FLOW_COLUMNS)
|
||||
inhalt += b"\r\n" + to_csv(kennzahlen, SUMMARY_COLUMNS).removeprefix("".encode())
|
||||
return _download(inhalt, filename("monat", "csv", kennung), fmt)
|
||||
|
||||
return _download(
|
||||
to_xlsx(
|
||||
{"Bewegungen": zeilen, "Kennzahlen": kennzahlen},
|
||||
{"Bewegungen": FLOW_COLUMNS, "Kennzahlen": SUMMARY_COLUMNS},
|
||||
),
|
||||
filename("monat", "xlsx", kennung),
|
||||
fmt,
|
||||
)
|
||||
|
||||
|
||||
async def _account_names(session: DbSession) -> dict[int, str]:
|
||||
return {konto.id: konto.name for konto in (await session.execute(select(Account))).scalars()}
|
||||
@@ -1,35 +1,70 @@
|
||||
"""Auswertungen."""
|
||||
|
||||
from datetime import date
|
||||
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 today
|
||||
from app.schemas.report import MonthComparisonOut, MonthReportOut, TotalsOut
|
||||
from app.services.reports import Totals, month_report
|
||||
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)
|
||||
|
||||
|
||||
@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:
|
||||
report = await month_report(session, month or today())
|
||||
def _month(report: MonthReport) -> MonthReportOut:
|
||||
return MonthReportOut(
|
||||
month=report.month,
|
||||
planned=_totals(report.planned),
|
||||
@@ -49,3 +84,323 @@ async def read_month_report(
|
||||
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],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user