Wer nach dem Gehaltseingang plant, stellt unter Einstellungen den Tag ein, ab dem ein neuer Monat zählt. Der Zeitraum läuft dann vom Gehaltstag bis zum Vortag des nächsten und trägt den Namen des Monats, in dem er beginnt: Mit dem 25. umfasst „September 2026“ den 25.09. bis zum 24.10. Ein Starttag jenseits der Monatslänge rutscht auf den Monatsletzten, sodass 31 verlässlich den letzten Tag des Monats meint. Dashboard, Cashflow-Kalender, Budgets, Zwölf-Monats-Vorschau, die Kategorienauswertung, der Monatsexport und die Benachrichtigung über überschrittene Budgets rechnen mit diesem Zeitraum. Budgets bleiben je Monat gepflegt; der Bezeichner ist weiterhin der Monatserste, nur der Schnitt verschiebt sich. Bestandsinstallationen bleiben beim Ersten. Die Einstellung liegt in einer einzeiligen Tabelle hinter GET/PUT /api/settings; die Monatsauswertungen liefern zusätzlich period_start und period_end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fj7mB1PGA1aDHyfdSGHgzD
432 lines
14 KiB
Python
432 lines
14 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, MonthStartDay
|
||
from app.core.clock import 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,
|
||
current_period,
|
||
flows,
|
||
forecast,
|
||
month_report,
|
||
months_between,
|
||
period_of,
|
||
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,
|
||
period_start=report.period.start,
|
||
period_end=report.period.end,
|
||
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,
|
||
period_start=entry.period_start,
|
||
period_end=entry.period_end,
|
||
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,
|
||
start_day: MonthStartDay,
|
||
month: date | None = Query(
|
||
default=None,
|
||
description="Beliebiger Tag im gewünschten Monat; ausschlaggebend ist dessen "
|
||
"Monatserster. Vorgabe ist der laufende Abrechnungsmonat.",
|
||
),
|
||
) -> MonthReportOut:
|
||
monat = month or current_period(start_day).key
|
||
return _month(await month_report(session, monat, start_day))
|
||
|
||
|
||
@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,
|
||
start_day: MonthStartDay,
|
||
months: int = Query(default=12, ge=1, le=MAX_FORECAST_MONTHS),
|
||
start: date | None = Query(
|
||
default=None, description="Erster Monat; Vorgabe ist der laufende Abrechnungsmonat."
|
||
),
|
||
) -> ForecastOut:
|
||
monate = await forecast(session, months, start, start_day)
|
||
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,
|
||
start_day: MonthStartDay,
|
||
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:
|
||
laufend = current_period(start_day)
|
||
start = date_from or laufend.start
|
||
ende = date_to or laufend.end
|
||
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,
|
||
start_day: MonthStartDay,
|
||
month: date | None = Query(default=None, description="Beliebiger Tag im Monat."),
|
||
) -> CalendarMonthOut:
|
||
monat = month or current_period(start_day).key
|
||
raster = await calendar_month(session, monat, start_day=start_day)
|
||
return CalendarMonthOut(
|
||
month=raster.month,
|
||
period_start=raster.period_start,
|
||
period_end=raster.period_end,
|
||
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,
|
||
start_day: MonthStartDay,
|
||
month: date | None = Query(default=None),
|
||
) -> list[BudgetStatusOut]:
|
||
monat = month or current_period(start_day).key
|
||
return [_budget(eintrag) for eintrag in await budget_status(session, monat, start_day)]
|
||
|
||
|
||
@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,
|
||
start_day: MonthStartDay,
|
||
month: date | None = Query(default=None),
|
||
) -> DashboardOut:
|
||
heute = today()
|
||
zeitraum = period_of(month, start_day) if month else current_period(start_day, heute)
|
||
|
||
bericht = await month_report(session, zeitraum.key, start_day)
|
||
vorschau = await forecast(session, 12, zeitraum.key, start_day)
|
||
gruppen = await category_breakdown(session, zeitraum.start, zeitraum.end)
|
||
abos = await subscriptions(session)
|
||
|
||
# Die nächsten zwei Wochen ab heute, unabhängig vom betrachteten Zeitraum.
|
||
naechste = await flows(session, heute, heute + timedelta(days=14))
|
||
|
||
return DashboardOut(
|
||
month_start_day=start_day,
|
||
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, zeitraum.key, start_day)
|
||
],
|
||
goals=await _goals(session, heute),
|
||
upcoming=[_entry(eintrag) for eintrag in naechste[:20]],
|
||
upcoming_deadlines=[_subscription(eintrag) for eintrag in abos.upcoming_deadlines],
|
||
)
|