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:
@@ -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()}
|
||||
Reference in New Issue
Block a user