feat: Engine breakdown – Monatsbeträge je Posten, Summen, Kategorie-Filter (A13-T2)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:09:30 +02:00
co-authored by Claude Opus 5.5
parent 0f5bb45a55
commit 7555d8e6a4
2 changed files with 302 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
"""Aufteilung (Ausbaustufe 13): Monatsbeträge je Posten für einen Monat.
Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert
(quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll
im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren
wirken exakt wie in build_cashflows (gleiche Helfer).
"""
import calendar
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from app.engine.loans import Installment
from app.engine.recurrence import STEP
from app.engine.scenario import (CENT, PlainModifier, PlainPlanned,
PlainRecurring, effective_end, modified_amount)
FREI = "Frei verfügbar"
PCT = Decimal("0.1")
@dataclass(frozen=True)
class Filters:
recurring: bool = True
planned: bool = True
loans: bool = True
@dataclass(frozen=True)
class PlainLoan:
id: int
name: str
schedule: list[Installment]
@dataclass(frozen=True)
class BreakdownRow:
name: str
category: str
kind: str # "recurring" | "planned" | "loan"
rhythm: str | None
amount: Decimal # Monatsbetrag, Kosten negativ
recurring_id: int | None = None
loan_id: int | None = None
@dataclass(frozen=True)
class Summary:
income: Decimal
costs: Decimal # positiv
free: Decimal # income - costs
deficit: bool # costs > income
pie_on_income: bool # Tortenbasis = Einkünfte
category_costs: list[tuple[str, Decimal]] # positiv, absteigend
pie_rest: Decimal | None # „Frei verfügbar"-Segment
def breakdown_rows(month: date, recurring: list[PlainRecurring],
planned: list[PlainPlanned], loans: list[PlainLoan],
modifiers: list[PlainModifier], filters: Filters, *,
category_names: dict[int, str], loan_category: str,
uncategorized: str) -> list[BreakdownRow]:
first = month.replace(day=1)
last = first.replace(day=calendar.monthrange(first.year, first.month)[1])
def cat(category_id: int | None) -> str:
if category_id is None:
return uncategorized
return category_names.get(category_id, uncategorized)
out: list[BreakdownRow] = []
if filters.recurring:
for r in recurring:
if r.start_date is not None and r.start_date > last:
continue
end = effective_end(r, modifiers)
if end is not None and end < first:
continue
amount = modified_amount(r.amount, r.category_id, r.id, modifiers)
if amount is None:
continue
monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP)
out.append(BreakdownRow(r.name, cat(r.category_id), "recurring",
r.rhythm, monthly, recurring_id=r.id))
if filters.planned:
for p in planned:
if not first <= p.due <= last:
continue
amount = modified_amount(p.amount, p.category_id, None, modifiers)
if amount is None:
continue
out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None,
amount.quantize(CENT, ROUND_HALF_UP)))
if filters.loans:
for loan in loans:
total = sum((i.payment for i in loan.schedule if first <= i.due <= last),
Decimal("0"))
out.append(BreakdownRow(loan.name, loan_category, "loan", None,
total, loan_id=loan.id))
return sorted((r for r in out if r.amount != 0), key=lambda r: (r.amount, r.name))
def share(amount: Decimal, basis: Decimal) -> Decimal | None:
"""|amount| in Prozent von basis, eine Nachkommastelle; basis 0 -> None."""
if basis == 0:
return None
return (abs(amount) * 100 / basis).quantize(PCT, ROUND_HALF_UP)
def summarize(rows: list[BreakdownRow]) -> Summary:
income = sum((r.amount for r in rows if r.amount > 0), Decimal("0"))
costs = sum((-r.amount for r in rows if r.amount < 0), Decimal("0"))
free = income - costs
per_category: dict[str, Decimal] = defaultdict(Decimal)
for r in rows:
if r.amount < 0:
per_category[r.category] += -r.amount
category_costs = sorted(per_category.items(), key=lambda kv: (-kv[1], kv[0]))
deficit = costs > income
pie_on_income = income > 0 and not deficit
pie_rest = free if pie_on_income and free > 0 else None
return Summary(income, costs, free, deficit, pie_on_income, category_costs, pie_rest)
def category_rows(rows: list[BreakdownRow], categories: set[str]) -> list[BreakdownRow]:
"""Kostenzeilen der genannten Kategorien (Einnahmen erscheinen in keiner Torte)."""
return [r for r in rows if r.amount < 0 and r.category in categories]