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:
moneyfy
2026-09-09 16:50:22 +02:00
co-authored by Claude Opus 5
parent 8d6fcfeb58
commit 0adf154049
31 changed files with 5721 additions and 121 deletions
+121
View File
@@ -0,0 +1,121 @@
"""Export als CSV und XLSX.
CSV verwendet Semikolon als Trennzeichen, Komma als Dezimaltrennzeichen und
UTF-8 mit BOM so öffnet Excel die Datei in deutscher Umgebung ohne Nachfrage.
"""
import csv
import io
from collections.abc import Sequence
from datetime import date
from decimal import Decimal
from typing import Any
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
# Excel erkennt UTF-8 in CSV nur zuverlässig anhand der Byte Order Mark.
BOM = ""
CSV_DELIMITER = ";"
# Deutsches Zahlenformat mit Tausenderpunkt und zwei Nachkommastellen.
XLSX_MONEY_FORMAT = '#,##0.00\\ ""'
XLSX_HEADER_FILL = PatternFill("solid", fgColor="1F2937")
def _format_value(value: Any) -> str:
"""Werte für CSV aufbereiten; Beträge in deutscher Schreibweise."""
if value is None:
return ""
if isinstance(value, Decimal):
return f"{value:.2f}".replace(".", ",")
if isinstance(value, date):
return value.isoformat()
return str(value)
def to_csv(rows: Sequence[dict[str, Any]], columns: Sequence[str] | None = None) -> bytes:
"""Erzeugt eine CSV-Datei aus Zeilen gleicher Struktur."""
spalten = list(columns or (rows[0].keys() if rows else []))
puffer = io.StringIO()
puffer.write(BOM)
schreiber = csv.writer(puffer, delimiter=CSV_DELIMITER, lineterminator="\r\n")
schreiber.writerow(spalten)
for zeile in rows:
schreiber.writerow([_format_value(zeile.get(spalte)) for spalte in spalten])
return puffer.getvalue().encode("utf-8")
def to_xlsx(
sheets: dict[str, Sequence[dict[str, Any]]],
columns: dict[str, Sequence[str]] | None = None,
) -> bytes:
"""Erzeugt eine Arbeitsmappe mit einem Blatt je Eintrag."""
mappe = Workbook()
mappe.remove(mappe.active)
for name, zeilen in sheets.items():
blatt = mappe.create_sheet(title=name[:31])
spalten = list((columns or {}).get(name) or (zeilen[0].keys() if zeilen else []))
if not spalten:
blatt["A1"] = "Keine Daten"
continue
blatt.append(spalten)
for zelle in blatt[1]:
zelle.font = Font(bold=True, color="FFFFFF")
zelle.fill = XLSX_HEADER_FILL
zelle.alignment = Alignment(vertical="center")
for zeile in zeilen:
blatt.append([_xlsx_value(zeile.get(spalte)) for spalte in spalten])
_style_columns(blatt, spalten, zeilen)
blatt.freeze_panes = "A2"
puffer = io.BytesIO()
mappe.save(puffer)
return puffer.getvalue()
def _xlsx_value(value: Any) -> Any:
"""Beträge und Daten bleiben typisiert, damit Excel damit rechnen kann."""
if isinstance(value, Decimal):
return float(value)
if isinstance(value, date):
return value
return value
def _style_columns(blatt, spalten: Sequence[str], zeilen: Sequence[dict[str, Any]]) -> None:
"""Spaltenbreite nach Inhalt und Zahlenformat für Geldspalten."""
for index, spalte in enumerate(spalten, start=1):
buchstabe = get_column_letter(index)
breite = max(
len(str(spalte)),
*(len(_format_value(zeile.get(spalte))) for zeile in zeilen[:200] or [{}]),
)
blatt.column_dimensions[buchstabe].width = min(max(breite + 3, 10), 42)
ist_geld = any(isinstance(zeile.get(spalte), Decimal) for zeile in zeilen[:50])
if not ist_geld:
continue
for zelle in blatt[buchstabe][1:]:
zelle.number_format = XLSX_MONEY_FORMAT
CONTENT_TYPES = {
"csv": "text/csv; charset=utf-8",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
def filename(base: str, fmt: str, suffix: str | None = None) -> str:
"""Dateiname für den Download, etwa `moneyfy-buchungen-2026-03.xlsx`."""
teile = ["moneyfy", base]
if suffix:
teile.append(suffix)
return f"{'-'.join(teile)}.{fmt}"