"""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}"