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 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.orm import Session
|
||||
|
||||
@@ -11,7 +11,8 @@ from app.auth import require_auth
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier)
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
@@ -36,12 +37,26 @@ class ScenarioPatch(BaseModel):
|
||||
include_recurring: 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):
|
||||
target_type: Literal["category", "recurring"]
|
||||
target_id: int
|
||||
kind: Literal["percent", "absolute", "remove"]
|
||||
kind: Literal["percent", "absolute", "remove", "ende"]
|
||||
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):
|
||||
@@ -117,6 +132,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
||||
ScenarioLoan.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioModifier).where(
|
||||
ScenarioModifier.scenario_id == scenario_id))
|
||||
session.execute(delete(ScenarioPlannedItem).where(
|
||||
ScenarioPlannedItem.scenario_id == scenario_id))
|
||||
session.delete(scenario)
|
||||
session.commit()
|
||||
|
||||
@@ -164,6 +181,50 @@ def delete_modifier(scenario_id: int, mod_id: int,
|
||||
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)
|
||||
def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
||||
start_date: date | None = None,
|
||||
|
||||
@@ -12,7 +12,8 @@ from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
||||
build_cashflows)
|
||||
from app.models.tables import (Loan, PlannedItem, ProjectionPoint,
|
||||
ProjectionResult, RecurringItem, Scenario,
|
||||
ScenarioLoan, ScenarioModifier)
|
||||
ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
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:
|
||||
planned = [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||
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(
|
||||
select(Loan).join(ScenarioLoan, ScenarioLoan.loan_id == Loan.id)
|
||||
.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)
|
||||
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(
|
||||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
||||
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,
|
||||
json={"category_id": 9999})
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
_seed_balance(db)
|
||||
client.post("/api/recurring", headers=H, json={
|
||||
|
||||
Reference in New Issue
Block a user