feat: Szenario-Einträge je Stichtag mit Zeitraum und Art Neuer Betrag (A14-T2)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
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).
|
||||
wirken wie in build_cashflows (gleiche Helfer); Stichtag je Fixposten ist sein
|
||||
Fälligkeitstag im Monat, je Einmalposten das Fälligkeitsdatum.
|
||||
"""
|
||||
import calendar
|
||||
from collections import defaultdict
|
||||
@@ -12,7 +13,7 @@ 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.recurrence import STEP, due_in_month
|
||||
from app.engine.scenario import (CENT, PlainModifier, PlainPlanned,
|
||||
PlainRecurring, effective_end, modified_amount)
|
||||
|
||||
@@ -77,7 +78,8 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring],
|
||||
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)
|
||||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers,
|
||||
on=due_in_month(first, r.due_day))
|
||||
if amount is None:
|
||||
continue
|
||||
monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP)
|
||||
@@ -87,7 +89,7 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring],
|
||||
for p in planned:
|
||||
if not first <= p.due <= last:
|
||||
continue
|
||||
amount = modified_amount(p.amount, p.category_id, None, modifiers)
|
||||
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
|
||||
if amount is None:
|
||||
continue
|
||||
out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None,
|
||||
|
||||
@@ -8,6 +8,12 @@ def _clamp(y: int, m: int, day: int) -> date:
|
||||
return date(y, m, min(day, calendar.monthrange(y, m)[1]))
|
||||
|
||||
|
||||
def due_in_month(month: date, due_day: int) -> date:
|
||||
"""Fälligkeitstag `due_day` im Monat von `month`, auf die Monatslänge
|
||||
geklemmt (gleiche Regel wie occurrences)."""
|
||||
return _clamp(month.year, month.month, due_day)
|
||||
|
||||
|
||||
def occurrences(rhythm: str, due_day: int, window_start: date, window_end: date,
|
||||
item_start: date | None = None, item_end: date | None = None) -> list[date]:
|
||||
step = STEP[rhythm]
|
||||
|
||||
@@ -10,7 +10,7 @@ CENT = Decimal("0.01")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlainRecurring:
|
||||
id: int
|
||||
id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie)
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
@@ -34,19 +34,36 @@ class PlainModifier:
|
||||
target_id: int
|
||||
kind: str
|
||||
value: Decimal
|
||||
end_date: date | None = None
|
||||
end_date: date | None = None # ende: Enddatum; sonst „Gültig bis" (inklusiv)
|
||||
start_date: date | None = None # „Gültig ab" (inklusiv); bei ende immer None
|
||||
|
||||
|
||||
def _trifft(m: PlainModifier, category_id: int | None, recurring_id: int | None) -> bool:
|
||||
return ((m.target_type == "category" and category_id is not None
|
||||
and category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and recurring_id is not None
|
||||
and recurring_id == m.target_id))
|
||||
|
||||
|
||||
def gilt_am(m: PlainModifier, on: date) -> bool:
|
||||
"""Liegt der Stichtag im Zeitraum des Eintrags (Grenzen inklusiv)?"""
|
||||
return ((m.start_date is None or on >= m.start_date)
|
||||
and (m.end_date is None or on <= m.end_date))
|
||||
|
||||
|
||||
def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||
modifiers: list[PlainModifier]) -> Decimal | None:
|
||||
modifiers: list[PlainModifier], *, on: date) -> Decimal | None:
|
||||
"""Betrag einer Fälligkeit am Stichtag `on` nach allen treffenden, dort
|
||||
gültigen Einträgen in Anlage-Reihenfolge. None = entfällt. Art "ende"
|
||||
wirkt nur über effective_end."""
|
||||
for m in modifiers:
|
||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and recurring_id == m.target_id))
|
||||
if not hit:
|
||||
if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on):
|
||||
continue
|
||||
if m.kind == "remove":
|
||||
return None
|
||||
if m.kind == "percent":
|
||||
if m.kind == "set":
|
||||
amount = m.value
|
||||
elif m.kind == "percent":
|
||||
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||||
elif m.kind == "absolute":
|
||||
if amount < 0:
|
||||
@@ -57,15 +74,12 @@ def modified_amount(amount: Decimal, category_id: int | None, recurring_id: int
|
||||
|
||||
|
||||
def effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||
"""Frühestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||||
end = r.end_date
|
||||
for m in modifiers:
|
||||
if m.kind != "ende" or m.end_date is None:
|
||||
if m.kind != "ende" or m.end_date is None or not _trifft(m, r.category_id, r.id):
|
||||
continue
|
||||
hit = ((m.target_type == "category" and r.category_id == m.target_id)
|
||||
or (m.target_type == "recurring" and r.id == m.target_id))
|
||||
if hit:
|
||||
end = m.end_date if end is None else min(end, m.end_date)
|
||||
end = m.end_date if end is None else min(end, m.end_date)
|
||||
return end
|
||||
|
||||
|
||||
@@ -76,15 +90,16 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
|
||||
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||
flows: list[tuple[date, Decimal]] = []
|
||||
for r in recurring:
|
||||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers)
|
||||
if amount is None:
|
||||
continue
|
||||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||
r.start_date, effective_end(r, modifiers)):
|
||||
flows.append((d, amount))
|
||||
amount = modified_amount(r.amount, r.category_id, r.id, modifiers, on=d)
|
||||
if amount is not None:
|
||||
flows.append((d, amount))
|
||||
for p in planned:
|
||||
amount = modified_amount(p.amount, p.category_id, None, modifiers)
|
||||
if amount is not None and window_start <= p.due <= window_end:
|
||||
if not window_start <= p.due <= window_end:
|
||||
continue
|
||||
amount = modified_amount(p.amount, p.category_id, None, modifiers, on=p.due)
|
||||
if amount is not None:
|
||||
flows.append((p.due, amount))
|
||||
for d, payout in loan_payouts:
|
||||
if window_start <= d <= window_end:
|
||||
|
||||
Reference in New Issue
Block a user