feat: API für Szenario-Zeitraum, Neuer Betrag und szenario-eigene wiederkehrende Zahlungen (A14-T3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 13:03:16 +02:00
co-authored by Claude Opus 5.5
parent aaf05b9209
commit 2aed685d7e
4 changed files with 259 additions and 22 deletions
+79 -7
View File
@@ -12,7 +12,8 @@ from app.config import get_settings
from app.db import get_session
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
Scenario, ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem)
ScenarioPlannedItem, ScenarioRecurringItem)
from app.routers.planning import RecurringIn, _check_category
from app.reserved import check_reserved_name
from app.services.projection_service import run_projection
@@ -47,16 +48,23 @@ class ScenarioPatch(BaseModel):
class ModifierIn(BaseModel):
target_type: Literal["category", "recurring"]
target_id: int
kind: Literal["percent", "absolute", "remove", "ende"]
kind: Literal["percent", "absolute", "set", "remove", "ende"]
value: Decimal = Decimal("0")
start_date: date | None = None
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
def _art_und_zeitraum(self):
# end_date = Enddatum bei "ende", sonst „Gültig bis" (A14).
if self.kind == "ende":
if self.end_date is None:
raise ValueError("Art »Ende« erfordert ein Datum")
self.start_date = None
if self.kind == "set" and self.target_type != "recurring":
raise ValueError("Art »Neuer Betrag« ist nur für Posten möglich")
if (self.start_date is not None and self.end_date is not None
and self.start_date > self.end_date):
raise ValueError("»Gültig ab« liegt nach »Gültig bis«")
return self
@@ -127,6 +135,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
scenario = _get_scenario(session, scenario_id)
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
# foreign-key violations (would surface as a 500 on Postgres).
session.execute(delete(ScenarioRecurringItem).where(
ScenarioRecurringItem.scenario_id == scenario_id))
session.execute(delete(ProjectionPoint).where(
ProjectionPoint.scenario_id == scenario_id))
session.execute(delete(ProjectionResult).where(
@@ -203,6 +213,7 @@ class ScenarioPlannedIn(BaseModel):
name: str
amount: Decimal
due: date
category_id: int | None = None
class ScenarioPlannedOut(ScenarioPlannedIn):
@@ -225,6 +236,7 @@ def list_scenario_planned(scenario_id: int, session: Session = Depends(get_sessi
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
_check_category(session, data.category_id)
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
@@ -248,6 +260,7 @@ def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlanned
session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_modifier.
_get_scenario(session, scenario_id)
_check_category(session, data.category_id)
item = session.get(ScenarioPlannedItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Einmalzahlung nicht gefunden")
@@ -278,3 +291,62 @@ def project_scenario(scenario_id: int, horizon_days: int | None = None,
below_threshold_date=result.below_threshold_date,
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
)
class ScenarioRecurringOut(RecurringIn):
model_config = ConfigDict(from_attributes=True)
id: int
scenario_id: int
def _get_scenario_recurring(session: Session, scenario_id: int,
item_id: int) -> ScenarioRecurringItem:
item = session.get(ScenarioRecurringItem, item_id)
if item is None or item.scenario_id != scenario_id:
raise HTTPException(404, "Wiederkehrende Zahlung nicht gefunden")
return item
@router.get("/{scenario_id}/recurring", response_model=list[ScenarioRecurringOut])
def list_scenario_recurring(scenario_id: int, session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
return [ScenarioRecurringOut.model_validate(r) for r in session.execute(
select(ScenarioRecurringItem)
.where(ScenarioRecurringItem.scenario_id == scenario_id)
.order_by(ScenarioRecurringItem.name, ScenarioRecurringItem.id)).scalars()]
@router.post("/{scenario_id}/recurring", response_model=ScenarioRecurringOut,
status_code=201)
def add_scenario_recurring(scenario_id: int, data: RecurringIn,
session: Session = Depends(get_session)):
# Bewusst ohne Doppelzählungs-Prüfung (gilt nur für globale Fixposten).
_get_scenario(session, scenario_id)
_check_category(session, data.category_id)
item = ScenarioRecurringItem(scenario_id=scenario_id, **data.model_dump())
session.add(item)
session.commit()
session.refresh(item)
return ScenarioRecurringOut.model_validate(item)
@router.patch("/{scenario_id}/recurring/{item_id}", response_model=ScenarioRecurringOut)
def patch_scenario_recurring(scenario_id: int, item_id: int, data: RecurringIn,
session: Session = Depends(get_session)):
# Full-Body-Update, Muster patch_scenario_planned.
_get_scenario(session, scenario_id)
item = _get_scenario_recurring(session, scenario_id, item_id)
_check_category(session, data.category_id)
for key, value in data.model_dump().items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return ScenarioRecurringOut.model_validate(item)
@router.delete("/{scenario_id}/recurring/{item_id}", status_code=204)
def delete_scenario_recurring(scenario_id: int, item_id: int,
session: Session = Depends(get_session)):
_get_scenario(session, scenario_id)
session.delete(_get_scenario_recurring(session, scenario_id, item_id))
session.commit()