feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
Reference in New Issue
Block a user