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,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}"
|
||||
Reference in New Issue
Block a user