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
|
Rein (kein DB-Zugriff). Fixposten werden auf den Monat normalisiert
|
||||||
(quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll
|
(quartalsweise /3, jährlich /12), Einmalposten und Kreditraten zählen voll
|
||||||
im Monat ihrer Fälligkeit; die Kredit-Auszahlung zählt nie. Modifikatoren
|
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
|
import calendar
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -12,7 +13,7 @@ from datetime import date
|
|||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
from app.engine.loans import Installment
|
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,
|
from app.engine.scenario import (CENT, PlainModifier, PlainPlanned,
|
||||||
PlainRecurring, effective_end, modified_amount)
|
PlainRecurring, effective_end, modified_amount)
|
||||||
|
|
||||||
@@ -77,7 +78,8 @@ def breakdown_rows(month: date, recurring: list[PlainRecurring],
|
|||||||
end = effective_end(r, modifiers)
|
end = effective_end(r, modifiers)
|
||||||
if end is not None and end < first:
|
if end is not None and end < first:
|
||||||
continue
|
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:
|
if amount is None:
|
||||||
continue
|
continue
|
||||||
monthly = (amount / STEP[r.rhythm]).quantize(CENT, ROUND_HALF_UP)
|
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:
|
for p in planned:
|
||||||
if not first <= p.due <= last:
|
if not first <= p.due <= last:
|
||||||
continue
|
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:
|
if amount is None:
|
||||||
continue
|
continue
|
||||||
out.append(BreakdownRow(p.name, cat(p.category_id), "planned", None,
|
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]))
|
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,
|
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]:
|
item_start: date | None = None, item_end: date | None = None) -> list[date]:
|
||||||
step = STEP[rhythm]
|
step = STEP[rhythm]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ CENT = Decimal("0.01")
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PlainRecurring:
|
class PlainRecurring:
|
||||||
id: int
|
id: int | None # None = szenario-eigene Zahlung (Posten-Einträge treffen sie nie)
|
||||||
name: str
|
name: str
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
rhythm: str
|
rhythm: str
|
||||||
@@ -34,19 +34,36 @@ class PlainModifier:
|
|||||||
target_id: int
|
target_id: int
|
||||||
kind: str
|
kind: str
|
||||||
value: Decimal
|
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,
|
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:
|
for m in modifiers:
|
||||||
hit = ((m.target_type == "category" and category_id == m.target_id)
|
if m.kind == "ende" or not _trifft(m, category_id, recurring_id) or not gilt_am(m, on):
|
||||||
or (m.target_type == "recurring" and recurring_id == m.target_id))
|
|
||||||
if not hit:
|
|
||||||
continue
|
continue
|
||||||
if m.kind == "remove":
|
if m.kind == "remove":
|
||||||
return None
|
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)
|
amount = (amount * (Decimal(100) - m.value) / Decimal(100)).quantize(CENT, ROUND_HALF_UP)
|
||||||
elif m.kind == "absolute":
|
elif m.kind == "absolute":
|
||||||
if amount < 0:
|
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:
|
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
|
end = r.end_date
|
||||||
for m in modifiers:
|
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
|
continue
|
||||||
hit = ((m.target_type == "category" and r.category_id == m.target_id)
|
end = m.end_date if end is None else min(end, m.end_date)
|
||||||
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)
|
|
||||||
return end
|
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]]:
|
window_start: date, window_end: date) -> list[tuple[date, Decimal]]:
|
||||||
flows: list[tuple[date, Decimal]] = []
|
flows: list[tuple[date, Decimal]] = []
|
||||||
for r in recurring:
|
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,
|
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||||||
r.start_date, effective_end(r, modifiers)):
|
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:
|
for p in planned:
|
||||||
amount = modified_amount(p.amount, p.category_id, None, modifiers)
|
if not window_start <= p.due <= window_end:
|
||||||
if amount is not None and 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))
|
flows.append((p.due, amount))
|
||||||
for d, payout in loan_payouts:
|
for d, payout in loan_payouts:
|
||||||
if window_start <= d <= window_end:
|
if window_start <= d <= window_end:
|
||||||
|
|||||||
@@ -172,3 +172,25 @@ def test_category_rows_nur_kosten_der_kategorien():
|
|||||||
assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"]
|
assert [r.name for r in category_rows(rs, {"Wohnen", "Versicherung"})] == ["Miete", "Police", "Strom"]
|
||||||
assert category_rows(rs, set()) == []
|
assert category_rows(rs, set()) == []
|
||||||
assert category_rows(rs, {FREI}) == []
|
assert category_rows(rs, {FREI}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stichtag_ist_faelligkeitstag_im_monat():
|
||||||
|
r = PlainRecurring(1, "R1", D("-200.00"), "monthly", 14, None, None, None)
|
||||||
|
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
|
||||||
|
assert by_name(rows(month=date(2030, 10, 1), recurring=[r], modifiers=mods)) == {"R1": D("-200.00")}
|
||||||
|
assert by_name(rows(month=date(2030, 11, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stichtag_monatsende_geklemmt():
|
||||||
|
# Fällig am 31.: im Februar ist der Stichtag der 28.02. (liegt im Zeitraum).
|
||||||
|
r = PlainRecurring(1, "R1", D("-100.00"), "monthly", 31, None, None, None)
|
||||||
|
mods = [PlainModifier("recurring", 1, "remove", D("0"), end_date=date(2030, 2, 28))]
|
||||||
|
assert rows(month=date(2030, 2, 1), recurring=[r], modifiers=mods) == []
|
||||||
|
assert by_name(rows(month=date(2030, 3, 1), recurring=[r], modifiers=mods)) == {"R1": D("-100.00")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenario_zahlung_ohne_recurring_id():
|
||||||
|
r = PlainRecurring(None, "S", D("-30.00"), "monthly", 1, None, None, 1)
|
||||||
|
rs = rows(recurring=[r])
|
||||||
|
assert by_name(rs) == {"S": D("-30.00")}
|
||||||
|
assert (rs[0].recurring_id, rs[0].category) == (None, "Wohnen")
|
||||||
|
|||||||
@@ -27,3 +27,10 @@ def test_monthly_leap_year_clamping():
|
|||||||
def test_quarterly_clamping():
|
def test_quarterly_clamping():
|
||||||
got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31))
|
got = occurrences("quarterly", 31, date(2026, 1, 1), date(2026, 12, 31))
|
||||||
assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)]
|
assert got == [date(2026, 1, 31), date(2026, 4, 30), date(2026, 7, 31), date(2026, 10, 31)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_in_month_klemmt_auf_monatsende():
|
||||||
|
from app.engine.recurrence import due_in_month
|
||||||
|
assert due_in_month(date(2030, 2, 1), 31) == date(2030, 2, 28)
|
||||||
|
assert due_in_month(date(2032, 2, 10), 30) == date(2032, 2, 29)
|
||||||
|
assert due_in_month(date(2030, 10, 1), 14) == date(2030, 10, 14)
|
||||||
|
|||||||
@@ -105,3 +105,70 @@ def test_ende_modifier_aendert_betraege_nicht():
|
|||||||
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
|
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
|
||||||
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
|
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
|
||||||
assert all(a == Decimal("-2000.00") for _, a in flows)
|
assert all(a == Decimal("-2000.00") for _, a in flows)
|
||||||
|
|
||||||
|
|
||||||
|
D = Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def _rec(amount="-100.00", due_day=14, id=1, cat=None):
|
||||||
|
return PlainRecurring(id, "R", D(amount), "monthly", due_day, None, None, cat)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeitraum_grenzen_inklusiv():
|
||||||
|
mods = [PlainModifier("recurring", 1, "percent", D("50"),
|
||||||
|
end_date=date(2030, 3, 14), start_date=date(2030, 2, 14))]
|
||||||
|
flows = build_cashflows([_rec()], [], [], [], mods, date(2030, 1, 1), date(2030, 4, 30))
|
||||||
|
assert flows == [(date(2030, 1, 14), D("-100.00")),
|
||||||
|
(date(2030, 2, 14), D("-50.00")),
|
||||||
|
(date(2030, 3, 14), D("-50.00")),
|
||||||
|
(date(2030, 4, 14), D("-100.00"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ab_stichtag_erst_folgemonat():
|
||||||
|
# Fällig am 14., gültig ab 16.10. -> Oktober voll, ab November halbiert.
|
||||||
|
mods = [PlainModifier("recurring", 1, "percent", D("50"), start_date=date(2030, 10, 16))]
|
||||||
|
flows = build_cashflows([_rec("-200.00")], [], [], [], mods,
|
||||||
|
date(2030, 10, 1), date(2030, 12, 31))
|
||||||
|
assert flows == [(date(2030, 10, 14), D("-200.00")),
|
||||||
|
(date(2030, 11, 14), D("-100.00")),
|
||||||
|
(date(2030, 12, 14), D("-100.00"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuer_betrag_dann_prozent():
|
||||||
|
mods = [PlainModifier("recurring", 1, "set", D("-80.00"), start_date=date(2030, 2, 1)),
|
||||||
|
PlainModifier("recurring", 1, "percent", D("25"))]
|
||||||
|
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||||||
|
date(2030, 1, 1), date(2030, 2, 28))
|
||||||
|
assert flows == [(date(2030, 1, 1), D("-75.00")), (date(2030, 2, 1), D("-60.00"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_entfaellt_nur_im_zeitraum():
|
||||||
|
mods = [PlainModifier("recurring", 1, "remove", D("0"),
|
||||||
|
end_date=date(2030, 2, 28), start_date=date(2030, 2, 1))]
|
||||||
|
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||||||
|
date(2030, 1, 1), date(2030, 3, 31))
|
||||||
|
assert [d for d, _ in flows] == [date(2030, 1, 1), date(2030, 3, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenario_zahlung_ohne_id_nur_von_kategorie_getroffen():
|
||||||
|
rec = [PlainRecurring(None, "S", D("-100.00"), "monthly", 1, None, None, 7)]
|
||||||
|
mods = [PlainModifier("recurring", 1, "remove", D("0")),
|
||||||
|
PlainModifier("category", 7, "percent", D("10"))]
|
||||||
|
flows = build_cashflows(rec, [], [], [], mods, date(2030, 1, 1), date(2030, 1, 31))
|
||||||
|
assert flows == [(date(2030, 1, 1), D("-90.00"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_einmalposten_stichtag_ist_faelligkeit():
|
||||||
|
planned = [PlainPlanned("E", D("-100.00"), date(2030, 1, 31), 7),
|
||||||
|
PlainPlanned("F", D("-100.00"), date(2030, 2, 1), 7)]
|
||||||
|
mods = [PlainModifier("category", 7, "percent", D("50"), start_date=date(2030, 2, 1))]
|
||||||
|
flows = build_cashflows([], planned, [], [], mods, date(2030, 1, 1), date(2030, 2, 28))
|
||||||
|
assert flows == [(date(2030, 1, 31), D("-100.00")), (date(2030, 2, 1), D("-50.00"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ende_unveraendert_mit_zeitraum_feldern():
|
||||||
|
# "ende" wirkt weiter nur über effective_end, nie als Betragsänderung.
|
||||||
|
mods = [PlainModifier("recurring", 1, "ende", D("0"), date(2030, 2, 15))]
|
||||||
|
flows = build_cashflows([_rec(due_day=1)], [], [], [], mods,
|
||||||
|
date(2030, 1, 1), date(2030, 4, 30))
|
||||||
|
assert flows == [(date(2030, 1, 1), D("-100.00")), (date(2030, 2, 1), D("-100.00"))]
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ from app.services.projection_service import load_scenario_inputs
|
|||||||
def test_modifikator_helfer_oeffentlich():
|
def test_modifikator_helfer_oeffentlich():
|
||||||
mods = [PlainModifier("recurring", 1, "percent", Decimal("10")),
|
mods = [PlainModifier("recurring", 1, "percent", Decimal("10")),
|
||||||
PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))]
|
PlainModifier("recurring", 1, "ende", Decimal("0"), date(2030, 6, 30))]
|
||||||
assert modified_amount(Decimal("-100.00"), None, 1, mods) == Decimal("-90.00")
|
assert modified_amount(Decimal("-100.00"), None, 1, mods, on=date(2030, 1, 1)) == Decimal("-90.00")
|
||||||
assert modified_amount(Decimal("-100.00"), None, 2, mods) == Decimal("-100.00")
|
assert modified_amount(Decimal("-100.00"), None, 2, mods, on=date(2030, 1, 1)) == Decimal("-100.00")
|
||||||
r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None,
|
r = PlainRecurring(1, "R", Decimal("-100.00"), "monthly", 1, None,
|
||||||
date(2030, 12, 31), None)
|
date(2030, 12, 31), None)
|
||||||
assert effective_end(r, mods) == date(2030, 6, 30)
|
assert effective_end(r, mods) == date(2030, 6, 30)
|
||||||
|
|||||||
Reference in New Issue
Block a user