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
201 lines
6.6 KiB
Python
201 lines
6.6 KiB
Python
"""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()}
|