"""Auswertungen. In dieser Phase die Monatsübersicht.""" from dataclasses import dataclass, field from datetime import date from decimal import Decimal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.clock import add_months, month_end, month_start from app.models import Category, Transaction from app.models.enums import EntryKind, OccurrenceStatus from app.services.occurrences import due_items, load_recurrences from app.services.recurrence import monthly_reserve ZERO = Decimal("0.00") @dataclass(frozen=True, slots=True) class Totals: """Einnahmen, Ausgaben und Saldo einer Sicht.""" income: Decimal = ZERO expenses: Decimal = ZERO @property def balance(self) -> Decimal: return self.income - self.expenses @dataclass(slots=True) class MonthReport: """Kennzahlen eines Monats.""" month: date planned: Totals actual: Totals previous_planned: Totals previous_actual: Totals fixed_costs: Decimal = ZERO variable_costs: Decimal = ZERO reserves: Decimal = ZERO confirmed_count: int = 0 open_count: int = 0 skipped_count: int = 0 categories: dict[int, Decimal] = field(default_factory=dict) @property def available_after_fixed(self) -> Decimal: """Einkünfte abzüglich Fixkosten und Rücklagen – die große Kennzahl im Dashboard.""" return self.planned.income - self.fixed_costs - self.reserves @property def income_delta(self) -> Decimal: return self.planned.income - self.previous_planned.income @property def expenses_delta(self) -> Decimal: return self.planned.expenses - self.previous_planned.expenses @property def balance_delta(self) -> Decimal: return self.planned.balance - self.previous_planned.balance async def _fixed_cost_map(session: AsyncSession) -> dict[int, bool]: """Kategorie-ID -> ist Fixkostenkategorie.""" rows = await session.execute(select(Category.id, Category.is_fixed_cost)) return dict(rows.all()) async def _month_totals( session: AsyncSession, month: date, fixed_costs: dict[int, bool] ) -> tuple[Totals, Totals, Decimal, Decimal, dict[int, int], dict[int, Decimal]]: """Rechnet einen Monat aus Fälligkeiten und Buchungen zusammen.""" start = month_start(month) end = month_end(month) planned_income = planned_expenses = ZERO actual_income = actual_expenses = ZERO fixed = variable = ZERO counts = {"confirmed": 0, "open": 0, "skipped": 0} per_category: dict[int, Decimal] = {} for item in await due_items(session, start, end): planned = item.planned if planned.status is OccurrenceStatus.SKIPPED: counts["skipped"] += 1 continue if planned.status is OccurrenceStatus.CONFIRMED: counts["confirmed"] += 1 else: counts["open"] += 1 if planned.kind is EntryKind.INCOME: planned_income += planned.amount if planned.status is OccurrenceStatus.CONFIRMED: actual_income += planned.effective_amount continue planned_expenses += planned.amount if planned.status is OccurrenceStatus.CONFIRMED: actual_expenses += planned.effective_amount # Für die Fix/Variabel-Aufteilung zählt der beste bekannte Wert. betrag = planned.effective_amount if fixed_costs.get(item.recurrence.category_id, False): fixed += betrag else: variable += betrag per_category[item.recurrence.category_id] = ( per_category.get(item.recurrence.category_id, ZERO) + betrag ) # Einmalige Buchungen sind immer Ist und zugleich Teil des Plans. stmt = select(Transaction).where( Transaction.booking_date >= start, Transaction.booking_date <= end ) for transaction in (await session.execute(stmt)).scalars(): if transaction.kind is EntryKind.INCOME: planned_income += transaction.amount actual_income += transaction.amount continue planned_expenses += transaction.amount actual_expenses += transaction.amount if fixed_costs.get(transaction.category_id, False): fixed += transaction.amount else: variable += transaction.amount per_category[transaction.category_id] = ( per_category.get(transaction.category_id, ZERO) + transaction.amount ) return ( Totals(income=planned_income, expenses=planned_expenses), Totals(income=actual_income, expenses=actual_expenses), fixed, variable, counts, per_category, ) async def _reserve_total(session: AsyncSession, month: date) -> Decimal: """Summe der monatlichen Rücklagen aller Posten mit aktivierter Rücklagenbildung.""" total = ZERO for recurrence in await load_recurrences(session): if recurrence.reserve_enabled: total += monthly_reserve( recurrence, month_start(month), amount_versions=recurrence.amount_versions ) return total async def month_report(session: AsyncSession, month: date) -> MonthReport: """Monatsübersicht inklusive Vergleich zum Vormonat.""" fixed_costs = await _fixed_cost_map(session) current = month_start(month) previous = add_months(current, -1) planned, actual, fixed, variable, counts, per_category = await _month_totals( session, current, fixed_costs ) previous_planned, previous_actual, *_ = await _month_totals(session, previous, fixed_costs) return MonthReport( month=current, planned=planned, actual=actual, previous_planned=previous_planned, previous_actual=previous_actual, fixed_costs=fixed, variable_costs=variable, reserves=await _reserve_total(session, current), confirmed_count=counts["confirmed"], open_count=counts["open"], skipped_count=counts["skipped"], categories=per_category, )