"""Export von Buchungen, Posten und Monatsauswertung als CSV oder XLSX.""" from datetime import date from typing import Literal from fastapi import APIRouter, Query, Response, status from sqlalchemy import select from app.api.deps import DbSession, MonthStartDay from app.core.clock import today from app.core.errors import ValidationError from app.models import Account, Merchant, Transaction from app.models.enums import EntryKind from app.schemas.common import ErrorResponse from app.services.export import CONTENT_TYPES, filename, to_csv, to_xlsx from app.services.reports import ( category_names, current_period, flow_rows, flows, month_report, period_of, recurrence_rows, reserve_total, ) router = APIRouter(prefix="/export", tags=["export"]) ExportFormat = Literal["csv", "xlsx"] TRANSACTION_COLUMNS = ( "Datum", "Titel", "Richtung", "Betrag", "Kategorie", "Firma", "Konto", "Notiz", ) RECURRENCE_COLUMNS = ( "Titel", "Richtung", "Betrag", "Wiederholung", "Beginn", "Ende", "Kategorie", "Firma", "Jahreskosten", "Raten", "Gekündigt zum", "Aktiv", ) FLOW_COLUMNS = ("Datum", "Titel", "Kategorie", "Richtung", "Betrag", "Herkunft", "Bestätigt") SUMMARY_COLUMNS = ("Kennzahl", "Betrag") # Starlette benennt die 422-Konstante gerade um – fester Wert vermeidet die Abhängigkeit. BAD_REQUEST = {422: {"model": ErrorResponse}} def _download(content: bytes, name: str, fmt: ExportFormat) -> Response: """Antwort mit passendem Typ und Dateinamen.""" return Response( content=content, media_type=CONTENT_TYPES[fmt], headers={"Content-Disposition": f'attachment; filename="{name}"'}, ) @router.get( "/transactions", responses={ status.HTTP_200_OK: { "content": {"text/csv": {}, CONTENT_TYPES["xlsx"]: {}}, "description": "Die Buchungen als Datei.", }, **BAD_REQUEST, }, summary="Buchungen exportieren", description="Ohne Zeitraum wird das laufende Jahr ausgegeben.", ) async def export_transactions( session: DbSession, fmt: ExportFormat = Query(default="csv", alias="format"), date_from: date | None = Query(default=None, alias="from"), date_to: date | None = Query(default=None, alias="to"), ) -> Response: heute = today() start = date_from or date(heute.year, 1, 1) ende = date_to or date(heute.year, 12, 31) if ende < start: raise ValidationError("'to' darf nicht vor 'from' liegen.", code="invalid_date_range") namen = await category_names(session) firmen = {firma.id: firma.name for firma in (await session.execute(select(Merchant))).scalars()} konten = await _account_names(session) stmt = ( select(Transaction) .where(Transaction.booking_date >= start, Transaction.booking_date <= ende) .order_by(Transaction.booking_date, Transaction.id) ) zeilen = [ { "Datum": buchung.booking_date.isoformat(), "Titel": buchung.title, "Richtung": "Einkunft" if buchung.kind is EntryKind.INCOME else "Ausgabe", "Betrag": buchung.amount, "Kategorie": namen.get(buchung.category_id, ""), "Firma": firmen.get(buchung.merchant_id or -1, ""), "Konto": konten.get(buchung.account_id, ""), "Notiz": buchung.note or "", } for buchung in (await session.execute(stmt)).scalars() ] zeitraum = f"{start.isoformat()}_{ende.isoformat()}" if fmt == "csv": return _download( to_csv(zeilen, TRANSACTION_COLUMNS), filename("buchungen", "csv", zeitraum), fmt ) return _download( to_xlsx({"Buchungen": zeilen}, {"Buchungen": TRANSACTION_COLUMNS}), filename("buchungen", "xlsx", zeitraum), fmt, ) @router.get( "/recurrences", responses={status.HTTP_200_OK: {"description": "Die Posten als Datei."}}, summary="Wiederkehrende Posten exportieren", ) async def export_recurrences( session: DbSession, fmt: ExportFormat = Query(default="csv", alias="format"), ) -> Response: zeilen = await recurrence_rows(session) if fmt == "csv": return _download(to_csv(zeilen, RECURRENCE_COLUMNS), filename("posten", "csv"), fmt) return _download( to_xlsx({"Posten": zeilen}, {"Posten": RECURRENCE_COLUMNS}), filename("posten", "xlsx"), fmt, ) @router.get( "/month", responses={status.HTTP_200_OK: {"description": "Die Monatsauswertung als Datei."}}, summary="Monatsauswertung exportieren", description="Enthält alle Bewegungen des Monats und eine Kennzahlenübersicht. " "Im XLSX-Format stehen beide auf getrennten Blättern.", ) async def export_month( session: DbSession, start_day: MonthStartDay, fmt: ExportFormat = Query(default="xlsx", alias="format"), month: date | None = Query(default=None, description="Beliebiger Tag im Monat."), ) -> Response: zeitraum = period_of(month, start_day) if month else current_period(start_day) namen = await category_names(session) bewegungen = await flows(session, zeitraum.start, zeitraum.end) zeilen = flow_rows(bewegungen, namen) bericht = await month_report(session, zeitraum.key, start_day) kennzahlen: list[dict[str, object]] = [ {"Kennzahl": "Einnahmen (Plan)", "Betrag": bericht.planned.income}, {"Kennzahl": "Ausgaben (Plan)", "Betrag": bericht.planned.expenses}, {"Kennzahl": "Saldo (Plan)", "Betrag": bericht.planned.balance}, {"Kennzahl": "Einnahmen (Ist)", "Betrag": bericht.actual.income}, {"Kennzahl": "Ausgaben (Ist)", "Betrag": bericht.actual.expenses}, {"Kennzahl": "Saldo (Ist)", "Betrag": bericht.actual.balance}, {"Kennzahl": "Fixkosten", "Betrag": bericht.fixed_costs}, {"Kennzahl": "Variable Kosten", "Betrag": bericht.variable_costs}, {"Kennzahl": "Rücklagen", "Betrag": await reserve_total(session, zeitraum)}, {"Kennzahl": "Verfügbar nach Fixkosten", "Betrag": bericht.available_after_fixed}, ] kennung = zeitraum.key.strftime("%Y-%m") if fmt == "csv": # CSV kennt keine Blätter – die Kennzahlen folgen nach einer Leerzeile. inhalt = to_csv(zeilen, FLOW_COLUMNS) inhalt += b"\r\n" + to_csv(kennzahlen, SUMMARY_COLUMNS).removeprefix("".encode()) return _download(inhalt, filename("monat", "csv", kennung), fmt) return _download( to_xlsx( {"Bewegungen": zeilen, "Kennzahlen": kennzahlen}, {"Bewegungen": FLOW_COLUMNS, "Kennzahlen": SUMMARY_COLUMNS}, ), filename("monat", "xlsx", kennung), fmt, ) async def _account_names(session: DbSession) -> dict[int, str]: return {konto.id: konto.name for konto in (await session.execute(select(Account))).scalars()}