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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user