Files
moneyfy/backend/app/services/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

122 lines
3.9 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 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}"