Files
moneyfy/backend/app/api/routes/export.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

201 lines
6.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()}