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