feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 11:52:15 +02:00
parent 5519037688
commit 13795fce25
4 changed files with 139 additions and 5 deletions

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import delete, select from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,7 +11,8 @@ from app.auth import require_auth
from app.config import get_settings from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult, from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier) Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.projection_service import run_projection from app.services.projection_service import run_projection
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"], router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
@@ -36,12 +37,26 @@ class ScenarioPatch(BaseModel):
include_recurring: bool | None = None include_recurring: bool | None = None
include_planned: bool | None = None include_planned: bool | None = None
@field_validator("description")
@classmethod
def _leere_beschreibung(cls, v):
return "" if v is None else v
class ModifierIn(BaseModel): class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"] target_type: Literal["category", "recurring"]
target_id: int target_id: int
kind: Literal["percent", "absolute", "remove"] kind: Literal["percent", "absolute", "remove", "ende"]
value: Decimal = Decimal("0") value: Decimal = Decimal("0")
end_date: date | None = None
@model_validator(mode="after")
def _ende_braucht_datum(self):
if self.kind == "ende" and self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum")
if self.kind != "ende":
self.end_date = None
return self
class ModifierOut(ModifierIn): class ModifierOut(ModifierIn):
@@ -117,6 +132,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
ScenarioLoan.scenario_id == scenario_id)) ScenarioLoan.scenario_id == scenario_id))
session.execute(delete(ScenarioModifier).where( session.execute(delete(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario_id)) ScenarioModifier.scenario_id == scenario_id))
session.execute(delete(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario_id))
session.delete(scenario) session.delete(scenario)
session.commit() session.commit()
@@ -164,6 +181,50 @@ def delete_modifier(scenario_id: int, mod_id: int,
session.commit() session.commit()
class ScenarioPlannedIn(BaseModel):
name: str
amount: Decimal
due: date
class ScenarioPlannedOut(ScenarioPlannedIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut])
def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
return [ScenarioPlannedOut.model_validate(p) for p in session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == scenario_id)
.order_by(ScenarioPlannedItem.due)).scalars()]
@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut,
status_code=201)
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return ScenarioPlannedOut.model_validate(item)
@router.delete("/{scenario_id}/planned/{item_id}", status_code=204)
def delete_scenario_planned(scenario_id: int, item_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
session.delete(item)
session.commit()
@router.post("/{scenario_id}/project", response_model=ProjectionOut) @router.post("/{scenario_id}/project", response_model=ProjectionOut)
def project_scenario(scenario_id: int, horizon_days: int | None = None, def project_scenario(scenario_id: int, horizon_days: int | None = None,
start_date: date | None = None, start_date: date | None = None,

View File

@@ -12,7 +12,8 @@ from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
build_cashflows) build_cashflows)
from app.models.tables import (Loan, PlannedItem, ProjectionPoint, from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
ProjectionResult, RecurringItem, Scenario, ProjectionResult, RecurringItem, Scenario,
ScenarioLoan, ScenarioModifier) ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
from app.services.balances import total_balance from app.services.balances import total_balance
@@ -28,6 +29,11 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
if scenario.include_planned: if scenario.include_planned:
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id) planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
for p in session.execute(select(PlannedItem)).scalars()] for p in session.execute(select(PlannedItem)).scalars()]
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
# include_planned steuert nur die globalen PlannedItems.
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
for p in session.execute(select(ScenarioPlannedItem).where(
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
loans = session.execute( loans = session.execute(
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id) select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
.where(ScenarioLoan.scenario_id == scenario.id)).scalars().all() .where(ScenarioLoan.scenario_id == scenario.id)).scalars().all()
@@ -35,7 +41,8 @@ def run_projection(session: Session, scenario: Scenario, horizon_days: int,
l.term_months, l.payout_date, l.repayment_type) l.term_months, l.payout_date, l.repayment_type)
for l in loans] for l in loans]
payouts = [(l.payout_date, Decimal(l.principal)) for l in loans] payouts = [(l.payout_date, Decimal(l.principal)) for l in loans]
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind, Decimal(m.value)) modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
Decimal(m.value), m.end_date)
for m in session.execute(select(ScenarioModifier).where( for m in session.execute(select(ScenarioModifier).where(
ScenarioModifier.scenario_id == scenario.id)).scalars()] ScenarioModifier.scenario_id == scenario.id)).scalars()]
flows = build_cashflows(recurring, planned, schedules, payouts, modifiers, flows = build_cashflows(recurring, planned, schedules, payouts, modifiers,

View File

@@ -186,3 +186,46 @@ def test_patch_transaction_category(client):
r = client.patch(f"/api/transactions/{tx_id}", headers=H, r = client.patch(f"/api/transactions/{tx_id}", headers=H,
json={"category_id": 9999}) json={"category_id": 9999})
assert r.status_code == 404 assert r.status_code == 404
def test_scenario_patch_beschreibung_leeren(client):
sc = client.post("/api/scenarios", headers=H, json={"name": "DescNull", "description": "x"}).json()
r = client.patch(f"/api/scenarios/{sc['id']}", headers=H, json={"description": None})
assert r.status_code == 200 and r.json()["description"] == ""
def test_modifier_ende_validierung(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json()
# ende ohne Datum -> 422
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende"})
assert r.status_code == 422
# ende mit Datum -> 201, end_date in der Antwort
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "ende",
"end_date": "2026-08-31"})
assert r.status_code == 201
assert r.json()["end_date"] == "2026-08-31"
# andere Arten nullen end_date
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": 1, "kind": "remove",
"end_date": "2026-08-31"})
assert r.status_code == 201 and r.json()["end_date"] is None
def test_scenario_planned_crud_und_cleanup(client, db):
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json()
r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"})
assert r.status_code == 201
item = r.json()
assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00"
assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung"
# DELETE mit fremder scenario_id -> 404
other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json()
assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}",
headers=H).status_code == 404
# Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler):
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
from app.models.tables import ScenarioPlannedItem
assert db.query(ScenarioPlannedItem).count() == 0

View File

@@ -34,6 +34,29 @@ def test_scenario_projection(client, db):
assert len(body["series"]) == 92 assert len(body["series"]) == 92
def test_projektion_mit_ende_modifier_und_szenario_einmalzahlung(client, db):
_seed_balance(db)
rec = client.post("/api/recurring", headers=H, json={
"name": "Miete-Proj", "amount": "-1000.00", "rhythm": "monthly",
"due_day": 1}).json()
sc = client.post("/api/scenarios", headers=H,
json={"name": "BestCase-Test", "include_planned": False}).json()
client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
"target_type": "recurring", "target_id": rec["id"], "kind": "ende",
"end_date": "2026-08-31"})
client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"})
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
params={"horizon_days": 150, "start_date": "2026-07-15"})
assert r.status_code == 200
series = {p["day"]: Decimal(p["balance"]) for p in r.json()["series"]}
# Start 1000; +5000 am 30.07.; Miete nur noch am 01.08. (ende 31.08.);
# include_planned=False, aber die SZENARIO-Zahlung zaehlt trotzdem.
assert series["2026-07-30"] == Decimal("6000.00")
assert series["2026-08-01"] == Decimal("5000.00")
assert series["2026-12-01"] == Decimal("5000.00") # keine Miete mehr ab Sept.
def test_loan_in_scenario_keeps_balance_positive(client, db): def test_loan_in_scenario_keeps_balance_positive(client, db):
_seed_balance(db) _seed_balance(db)
client.post("/api/recurring", headers=H, json={ client.post("/api/recurring", headers=H, json={