feat: Engine+Schema fuer Szenario-Ende und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
37
finance/alembic/versions/c4d7e2a91b53_szenario_ende.py
Normal file
37
finance/alembic/versions/c4d7e2a91b53_szenario_ende.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""szenario ende + scenario_planned_items
|
||||||
|
|
||||||
|
Revision ID: c4d7e2a91b53
|
||||||
|
Revises: 1ef6a356f028
|
||||||
|
Create Date: 2026-07-20
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'c4d7e2a91b53'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '1ef6a356f028'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('scenario_modifiers', sa.Column('end_date', sa.Date(), nullable=True))
|
||||||
|
op.create_table(
|
||||||
|
'scenario_planned_items',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||||||
|
sa.Column('due', sa.Date(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index('ix_scenario_planned_items_scenario_id',
|
||||||
|
'scenario_planned_items', ['scenario_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_scenario_planned_items_scenario_id', 'scenario_planned_items')
|
||||||
|
op.drop_table('scenario_planned_items')
|
||||||
|
op.drop_column('scenario_modifiers', 'end_date')
|
||||||
@@ -34,6 +34,7 @@ class PlainModifier:
|
|||||||
target_id: int
|
target_id: int
|
||||||
kind: str
|
kind: str
|
||||||
value: Decimal
|
value: Decimal
|
||||||
|
end_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None,
|
||||||
@@ -55,6 +56,19 @@ def _modified(amount: Decimal, category_id: int | None, recurring_id: int | None
|
|||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||||||
|
"""Fruehestes 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:
|
||||||
|
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)
|
||||||
|
return end
|
||||||
|
|
||||||
|
|
||||||
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
|
def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned],
|
||||||
loan_schedules: list[list[Installment]],
|
loan_schedules: list[list[Installment]],
|
||||||
loan_payouts: list[tuple[date, Decimal]],
|
loan_payouts: list[tuple[date, Decimal]],
|
||||||
@@ -66,7 +80,7 @@ def build_cashflows(recurring: list[PlainRecurring], planned: list[PlainPlanned]
|
|||||||
if amount is None:
|
if amount is None:
|
||||||
continue
|
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, r.end_date):
|
r.start_date, _effective_end(r, modifiers)):
|
||||||
flows.append((d, amount))
|
flows.append((d, amount))
|
||||||
for p in planned:
|
for p in planned:
|
||||||
amount = _modified(p.amount, p.category_id, None, modifiers)
|
amount = _modified(p.amount, p.category_id, None, modifiers)
|
||||||
|
|||||||
@@ -123,6 +123,21 @@ class ScenarioModifier(Base):
|
|||||||
target_id: Mapped[int] = mapped_column(Integer)
|
target_id: Mapped[int] = mapped_column(Integer)
|
||||||
kind: Mapped[str] = mapped_column(String(20))
|
kind: Mapped[str] = mapped_column(String(20))
|
||||||
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
value: Mapped[Decimal] = mapped_column(MONEY, default=Decimal("0"))
|
||||||
|
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
|
||||||
|
# dieses Szenarios endet (Ausbaustufe 5).
|
||||||
|
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioPlannedItem(Base):
|
||||||
|
"""Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt -
|
||||||
|
unabhaengig von Scenario.include_planned (das steuert nur die globalen
|
||||||
|
PlannedItems). Ausbaustufe 5."""
|
||||||
|
__tablename__ = "scenario_planned_items"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||||||
|
due: Mapped[date] = mapped_column(Date)
|
||||||
|
|
||||||
|
|
||||||
class ProjectionPoint(Base):
|
class ProjectionPoint(Base):
|
||||||
|
|||||||
@@ -22,3 +22,15 @@ def test_account_transaction_roundtrip(db):
|
|||||||
db.add(tx)
|
db.add(tx)
|
||||||
db.commit()
|
db.commit()
|
||||||
assert db.query(Transaction).one().amount == Decimal("-50.00")
|
assert db.query(Transaction).one().amount == Decimal("-50.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_planned_item_roundtrip(db):
|
||||||
|
from app.models.tables import Scenario, ScenarioPlannedItem
|
||||||
|
sc = Scenario(name="SPI-Test", description="")
|
||||||
|
db.add(sc)
|
||||||
|
db.flush()
|
||||||
|
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Einmalzahlung",
|
||||||
|
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
||||||
|
db.commit()
|
||||||
|
item = db.query(ScenarioPlannedItem).one()
|
||||||
|
assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id
|
||||||
|
|||||||
@@ -57,3 +57,51 @@ def test_absolute_modifier_clamps_to_zero():
|
|||||||
kind="absolute", value=Decimal("300"))]
|
kind="absolute", value=Decimal("300"))]
|
||||||
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
|
flows = build_cashflows(rec, [], [], [], mods, date(2026, 8, 1), date(2026, 8, 31))
|
||||||
assert flows == [(date(2026, 8, 10), Decimal("0.00"))]
|
assert flows == [(date(2026, 8, 10), Decimal("0.00"))]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ende_modifier_beendet_posten_im_szenario():
|
||||||
|
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
|
||||||
|
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
|
||||||
|
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
|
||||||
|
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
|
||||||
|
assert all(a == Decimal("-2000.00") for _, a in flows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ende_modifier_minimum_mit_eigenem_ende():
|
||||||
|
# Posten endet selbst am 15.10.; Szenario-Ende 31.08. ist frueher -> gewinnt.
|
||||||
|
r = PlainRecurring(1, "Miete", Decimal("-100.00"), "monthly", 1,
|
||||||
|
None, date(2026, 10, 15), None)
|
||||||
|
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
|
||||||
|
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
|
||||||
|
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
|
||||||
|
# Umgekehrt: Szenario-Ende NACH eigenem Ende aendert nichts.
|
||||||
|
m_spaet = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 12, 31))
|
||||||
|
flows2 = build_cashflows([r], [], [], [], [m_spaet], date(2026, 7, 1), date(2026, 12, 31))
|
||||||
|
assert [d for d, _ in flows2][-1] == date(2026, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ende_modifier_auf_kategorie_trifft_alle_posten_der_kategorie():
|
||||||
|
r1 = PlainRecurring(1, "Miete A", Decimal("-10.00"), "monthly", 1, None, None, 7)
|
||||||
|
r2 = PlainRecurring(2, "Miete B", Decimal("-20.00"), "monthly", 1, None, None, 7)
|
||||||
|
r3 = PlainRecurring(3, "Strom", Decimal("-5.00"), "monthly", 1, None, None, 9)
|
||||||
|
m = PlainModifier("category", 7, "ende", Decimal("0"), date(2026, 7, 31))
|
||||||
|
flows = build_cashflows([r1, r2, r3], [], [], [], [m],
|
||||||
|
date(2026, 7, 1), date(2026, 9, 30))
|
||||||
|
betraege = sorted(a for _, a in flows)
|
||||||
|
# Miete A/B nur je 1x (Juli), Strom 3x (Juli-Sept).
|
||||||
|
assert betraege == [Decimal("-20.00"), Decimal("-10.00"),
|
||||||
|
Decimal("-5.00"), Decimal("-5.00"), Decimal("-5.00")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ende_modifier_ohne_datum_wirkt_nicht():
|
||||||
|
r = PlainRecurring(1, "Miete", Decimal("-10.00"), "monthly", 1, None, None, None)
|
||||||
|
m = PlainModifier("recurring", 1, "ende", Decimal("0"), None)
|
||||||
|
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 9, 30))
|
||||||
|
assert len(flows) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_ende_modifier_aendert_betraege_nicht():
|
||||||
|
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
|
||||||
|
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
|
||||||
|
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
|
||||||
|
assert all(a == Decimal("-2000.00") for _, a in flows)
|
||||||
|
|||||||
Reference in New Issue
Block a user