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.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)
|
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||||
|
from app.routers.planning import RecurringIn, _check_category
|
||||||
from app.reserved import check_reserved_name
|
from app.reserved import check_reserved_name
|
||||||
from app.services.projection_service import run_projection
|
from app.services.projection_service import run_projection
|
||||||
|
|
||||||
@@ -47,16 +48,23 @@ class ScenarioPatch(BaseModel):
|
|||||||
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", "ende"]
|
kind: Literal["percent", "absolute", "set", "remove", "ende"]
|
||||||
value: Decimal = Decimal("0")
|
value: Decimal = Decimal("0")
|
||||||
|
start_date: date | None = None
|
||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _ende_braucht_datum(self):
|
def _art_und_zeitraum(self):
|
||||||
if self.kind == "ende" and self.end_date is None:
|
# end_date = Enddatum bei "ende", sonst „Gültig bis" (A14).
|
||||||
raise ValueError("Art »Ende« erfordert ein Datum")
|
if self.kind == "ende":
|
||||||
if self.kind != "ende":
|
if self.end_date is None:
|
||||||
self.end_date = 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
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -127,6 +135,8 @@ def delete_scenario(scenario_id: int, session: Session = Depends(get_session)):
|
|||||||
scenario = _get_scenario(session, scenario_id)
|
scenario = _get_scenario(session, scenario_id)
|
||||||
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
# No DB-level cascades exist, so remove dependent rows explicitly to avoid
|
||||||
# foreign-key violations (would surface as a 500 on Postgres).
|
# 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(
|
session.execute(delete(ProjectionPoint).where(
|
||||||
ProjectionPoint.scenario_id == scenario_id))
|
ProjectionPoint.scenario_id == scenario_id))
|
||||||
session.execute(delete(ProjectionResult).where(
|
session.execute(delete(ProjectionResult).where(
|
||||||
@@ -203,6 +213,7 @@ class ScenarioPlannedIn(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
due: date
|
due: date
|
||||||
|
category_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScenarioPlannedOut(ScenarioPlannedIn):
|
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,
|
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
|
||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
_get_scenario(session, scenario_id)
|
_get_scenario(session, scenario_id)
|
||||||
|
_check_category(session, data.category_id)
|
||||||
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
||||||
session.add(item)
|
session.add(item)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -248,6 +260,7 @@ def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlanned
|
|||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
# Full-Body-Update, Muster patch_modifier.
|
# Full-Body-Update, Muster patch_modifier.
|
||||||
_get_scenario(session, scenario_id)
|
_get_scenario(session, scenario_id)
|
||||||
|
_check_category(session, data.category_id)
|
||||||
item = session.get(ScenarioPlannedItem, item_id)
|
item = session.get(ScenarioPlannedItem, item_id)
|
||||||
if item is None or item.scenario_id != scenario_id:
|
if item is None or item.scenario_id != scenario_id:
|
||||||
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
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,
|
below_threshold_date=result.below_threshold_date,
|
||||||
series=[SeriesPointOut(day=p.day, balance=p.balance) for p in points],
|
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()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.engine.scenario import (PlainModifier, PlainPlanned, PlainRecurring,
|
|||||||
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)
|
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||||
from app.services.balances import total_balance
|
from app.services.balances import total_balance
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +31,8 @@ class ScenarioInputs:
|
|||||||
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
|
def load_scenario_inputs(session: Session, scenario: Scenario | None) -> ScenarioInputs:
|
||||||
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine
|
"""Lädt die Eingaben eines Szenarios. scenario=None ist die reine
|
||||||
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
|
Planung („System-Plan"): alle Fixposten, alle globalen Einmalposten,
|
||||||
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten."""
|
alle Kredite, keine Modifikatoren, keine Szenario-Einmalposten und keine
|
||||||
|
szenario-eigenen wiederkehrenden Zahlungen."""
|
||||||
recurring = []
|
recurring = []
|
||||||
if scenario is None or scenario.include_recurring:
|
if scenario is None or scenario.include_recurring:
|
||||||
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
recurring = [PlainRecurring(r.id, r.name, Decimal(r.amount), r.rhythm,
|
||||||
@@ -46,9 +47,16 @@ def load_scenario_inputs(session: Session, scenario: Scenario | None) -> Scenari
|
|||||||
if scenario is None:
|
if scenario is None:
|
||||||
loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars())
|
loans = list(session.execute(select(Loan).order_by(Loan.id)).scalars())
|
||||||
return ScenarioInputs(recurring, planned, loans, [])
|
return ScenarioInputs(recurring, planned, loans, [])
|
||||||
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
# Szenario-eigene Zahlungen zählen IMMER für dieses Szenario -
|
||||||
# include_planned steuert nur die globalen PlannedItems.
|
# include_recurring/include_planned steuern nur die globalen Posten.
|
||||||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
# id=None: Posten-Einträge (target_type="recurring") treffen sie nie,
|
||||||
|
# die IDs zweier Tabellen könnten sonst kollidieren.
|
||||||
|
recurring += [PlainRecurring(None, r.name, Decimal(r.amount), r.rhythm, r.due_day,
|
||||||
|
r.start_date, r.end_date, r.category_id)
|
||||||
|
for r in session.execute(select(ScenarioRecurringItem).where(
|
||||||
|
ScenarioRecurringItem.scenario_id == scenario.id)
|
||||||
|
.order_by(ScenarioRecurringItem.id)).scalars()]
|
||||||
|
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, p.category_id)
|
||||||
for p in session.execute(select(ScenarioPlannedItem).where(
|
for p in session.execute(select(ScenarioPlannedItem).where(
|
||||||
ScenarioPlannedItem.scenario_id == scenario.id)
|
ScenarioPlannedItem.scenario_id == scenario.id)
|
||||||
.order_by(ScenarioPlannedItem.id)).scalars()]
|
.order_by(ScenarioPlannedItem.id)).scalars()]
|
||||||
@@ -57,7 +65,7 @@ def load_scenario_inputs(session: Session, scenario: Scenario | None) -> Scenari
|
|||||||
.where(ScenarioLoan.scenario_id == scenario.id)
|
.where(ScenarioLoan.scenario_id == scenario.id)
|
||||||
.order_by(Loan.id)).scalars())
|
.order_by(Loan.id)).scalars())
|
||||||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||||||
Decimal(m.value), m.end_date)
|
Decimal(m.value), m.end_date, m.start_date)
|
||||||
for m in session.execute(select(ScenarioModifier).where(
|
for m in session.execute(select(ScenarioModifier).where(
|
||||||
ScenarioModifier.scenario_id == scenario.id)
|
ScenarioModifier.scenario_id == scenario.id)
|
||||||
.order_by(ScenarioModifier.id)).scalars()]
|
.order_by(ScenarioModifier.id)).scalars()]
|
||||||
|
|||||||
@@ -225,11 +225,11 @@ def test_modifier_ende_validierung(client, db):
|
|||||||
"end_date": "2026-08-31"})
|
"end_date": "2026-08-31"})
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
assert r.json()["end_date"] == "2026-08-31"
|
assert r.json()["end_date"] == "2026-08-31"
|
||||||
# andere Arten nullen end_date
|
# andere Arten behalten end_date als „Gültig bis"
|
||||||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||||
"end_date": "2026-08-31"})
|
"end_date": "2026-08-31"})
|
||||||
assert r.status_code == 201 and r.json()["end_date"] is None
|
assert r.status_code == 201 and r.json()["end_date"] == "2026-08-31"
|
||||||
|
|
||||||
|
|
||||||
def test_scenario_planned_crud_und_cleanup(client, db):
|
def test_scenario_planned_crud_und_cleanup(client, db):
|
||||||
@@ -269,11 +269,11 @@ def test_modifier_patch(client, db):
|
|||||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
# zurueck auf remove: end_date wird genullt
|
# zurueck auf remove: end_date bleibt als „Gültig bis" erhalten
|
||||||
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||||
"end_date": "2026-08-31"})
|
"end_date": "2026-08-31"})
|
||||||
assert r.status_code == 200 and r.json()["end_date"] is None
|
assert r.status_code == 200 and r.json()["end_date"] == "2026-08-31"
|
||||||
# fremdes Szenario -> 404
|
# fremdes Szenario -> 404
|
||||||
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
other = client.post("/api/scenarios", headers=H, json={"name": "ModPatch2"}).json()
|
||||||
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
r = client.patch(f"/api/scenarios/{other['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
@@ -318,3 +318,117 @@ def test_szenario_name_system_ist_ist_reserviert_patch(client):
|
|||||||
def test_szenario_normale_namen_weiter_erlaubt(client):
|
def test_szenario_normale_namen_weiter_erlaubt(client):
|
||||||
assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"},
|
assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"},
|
||||||
headers=H).status_code == 201
|
headers=H).status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def _msgs(r):
|
||||||
|
return " ".join(e["msg"] for e in r.json()["detail"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifier_zeitraum_und_neuer_betrag(client):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "Zeitraum"}).json()
|
||||||
|
url = f"/api/scenarios/{sc['id']}/modifiers"
|
||||||
|
r = client.post(url, headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||||||
|
"start_date": "2026-10-16", "end_date": "2027-03-31"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert (r.json()["start_date"], r.json()["end_date"]) == ("2026-10-16", "2027-03-31")
|
||||||
|
r = client.post(url, headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||||||
|
"start_date": "2027-04-01", "end_date": "2027-03-31"})
|
||||||
|
assert r.status_code == 422 and "»Gültig ab« liegt nach »Gültig bis«" in _msgs(r)
|
||||||
|
r = client.post(url, headers=H, json={
|
||||||
|
"target_type": "category", "target_id": 1, "kind": "set", "value": "-80.00"})
|
||||||
|
assert r.status_code == 422 and "Art »Neuer Betrag« ist nur für Posten möglich" in _msgs(r)
|
||||||
|
r = client.post(url, headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "set", "value": "-137.50",
|
||||||
|
"start_date": "2026-10-16"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert (r.json()["kind"], r.json()["value"], r.json()["end_date"]) == ("set", "-137.50", None)
|
||||||
|
r = client.post(url, headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||||
|
"start_date": "2026-10-01", "end_date": "2026-10-15"})
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-10-15")
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifier_patch_artwechsel_zeitraum(client):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "Artwechsel"}).json()
|
||||||
|
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "50",
|
||||||
|
"start_date": "2026-10-16", "end_date": "2027-03-31"}).json()
|
||||||
|
url = f"/api/scenarios/{sc['id']}/modifiers/{m['id']}"
|
||||||
|
r = client.patch(url, headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||||
|
"start_date": "2026-10-16", "end_date": "2026-12-31"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert (r.json()["start_date"], r.json()["end_date"]) == (None, "2026-12-31")
|
||||||
|
r = client.patch(url, headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "10",
|
||||||
|
"end_date": "2026-12-31"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert (r.json()["kind"], r.json()["start_date"], r.json()["end_date"]) == \
|
||||||
|
("percent", None, "2026-12-31")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_recurring_crud_und_cleanup(client, db):
|
||||||
|
from app.models.tables import Category, ScenarioRecurringItem
|
||||||
|
cat = Category(name="Wohnen-SR")
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-CRUD"}).json()
|
||||||
|
url = f"/api/scenarios/{sc['id']}/recurring"
|
||||||
|
body = {"name": "Miete neu", "amount": "-137.50", "rhythm": "monthly",
|
||||||
|
"due_day": 14, "start_date": "2026-10-16", "category_id": cat.id}
|
||||||
|
r = client.post(url, headers=H, json=body)
|
||||||
|
assert r.status_code == 201
|
||||||
|
item = r.json()
|
||||||
|
assert (item["scenario_id"], item["amount"], item["end_date"], item["category_id"]) == \
|
||||||
|
(sc["id"], "-137.50", None, cat.id)
|
||||||
|
assert [i["name"] for i in client.get(url, headers=H).json()] == ["Miete neu"]
|
||||||
|
r = client.patch(f"{url}/{item['id']}", headers=H, json={
|
||||||
|
"name": "Miete neu", "amount": "-250.00", "rhythm": "quarterly", "due_day": 1,
|
||||||
|
"start_date": None, "end_date": "2027-12-31", "category_id": None})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert (r.json()["amount"], r.json()["rhythm"], r.json()["category_id"]) == \
|
||||||
|
("-250.00", "quarterly", None)
|
||||||
|
# Validierung wie globale Fixposten
|
||||||
|
assert client.post(url, headers=H, json={**body, "due_day": 0}).status_code == 422
|
||||||
|
assert client.post(url, headers=H, json={**body, "rhythm": "weekly"}).status_code == 422
|
||||||
|
assert client.post(url, headers=H, json={**body, "end_date": "2026-10-01"}).status_code == 422
|
||||||
|
r = client.post(url, headers=H, json={**body, "category_id": 9999})
|
||||||
|
assert r.status_code == 404 and r.json()["detail"] == "Kategorie nicht gefunden"
|
||||||
|
# fremdes/fehlendes Szenario
|
||||||
|
other = client.post("/api/scenarios", headers=H, json={"name": "SR-Other"}).json()
|
||||||
|
r = client.delete(f"/api/scenarios/{other['id']}/recurring/{item['id']}", headers=H)
|
||||||
|
assert r.status_code == 404 and r.json()["detail"] == "Wiederkehrende Zahlung nicht gefunden"
|
||||||
|
assert client.get("/api/scenarios/99999/recurring", headers=H).status_code == 404
|
||||||
|
# Szenario löschen räumt mit ab (kein FK-Fehler)
|
||||||
|
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||||||
|
assert db.query(ScenarioRecurringItem).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_recurring_delete(client):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "SR-Del"}).json()
|
||||||
|
url = f"/api/scenarios/{sc['id']}/recurring"
|
||||||
|
item = client.post(url, headers=H, json={
|
||||||
|
"name": "X", "amount": "-1.00", "rhythm": "monthly", "due_day": 1}).json()
|
||||||
|
assert client.delete(f"{url}/{item['id']}", headers=H).status_code == 204
|
||||||
|
assert client.get(url, headers=H).json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_planned_mit_kategorie(client, db):
|
||||||
|
from app.models.tables import Category
|
||||||
|
cat = Category(name="Kat-SPI")
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-Kat"}).json()
|
||||||
|
url = f"/api/scenarios/{sc['id']}/planned"
|
||||||
|
r = client.post(url, headers=H, json={
|
||||||
|
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": cat.id})
|
||||||
|
assert r.status_code == 201 and r.json()["category_id"] == cat.id
|
||||||
|
assert client.post(url, headers=H, json={
|
||||||
|
"name": "E", "amount": "-5.00", "due": "2026-11-01",
|
||||||
|
"category_id": 9999}).status_code == 404
|
||||||
|
r = client.patch(f"{url}/{r.json()['id']}", headers=H, json={
|
||||||
|
"name": "E", "amount": "-5.00", "due": "2026-11-01", "category_id": None})
|
||||||
|
assert r.status_code == 200 and r.json()["category_id"] is None
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.engine.scenario import (PlainModifier, PlainRecurring, effective_end,
|
from app.engine.scenario import (PlainModifier, PlainRecurring, build_cashflows,
|
||||||
modified_amount)
|
effective_end, modified_amount)
|
||||||
from app.models.tables import (Loan, PlannedItem, RecurringItem, Scenario,
|
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||||
ScenarioLoan, ScenarioModifier,
|
Scenario, ScenarioLoan, ScenarioModifier,
|
||||||
ScenarioPlannedItem)
|
ScenarioPlannedItem, ScenarioRecurringItem)
|
||||||
from app.services.projection_service import load_scenario_inputs
|
from app.services.projection_service import load_scenario_inputs
|
||||||
|
|
||||||
|
|
||||||
@@ -66,3 +66,46 @@ def test_load_scenario_inputs_system_plan(db):
|
|||||||
assert [p.name for p in inp.planned] == ["P"]
|
assert [p.name for p in inp.planned] == ["P"]
|
||||||
assert [l.name for l in inp.loans] == ["K1", "K2"]
|
assert [l.name for l in inp.loans] == ["K1", "K2"]
|
||||||
assert inp.modifiers == []
|
assert inp.modifiers == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenario_zahlung_zaehlt_immer_und_id_kollision(db):
|
||||||
|
rec = RecurringItem(name="R", amount=Decimal("-10.00"), rhythm="monthly", due_day=1)
|
||||||
|
sc = Scenario(name="S-A14", description="", include_recurring=False,
|
||||||
|
include_planned=False)
|
||||||
|
db.add_all([rec, sc])
|
||||||
|
db.flush()
|
||||||
|
sri = ScenarioRecurringItem(scenario_id=sc.id, name="SR", amount=Decimal("-100.00"),
|
||||||
|
rhythm="monthly", due_day=1)
|
||||||
|
db.add(sri)
|
||||||
|
db.flush()
|
||||||
|
# Posten-Eintrag, dessen target_id zufällig der ID der Szenario-Zahlung entspricht:
|
||||||
|
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
|
||||||
|
target_id=sri.id, kind="remove", value=Decimal("0")))
|
||||||
|
db.commit()
|
||||||
|
inp = load_scenario_inputs(db, sc)
|
||||||
|
# include_recurring=False: globaler Posten fehlt, Szenario-Zahlung zählt, id=None.
|
||||||
|
assert [(r.id, r.name) for r in inp.recurring] == [(None, "SR")]
|
||||||
|
flows = build_cashflows(inp.recurring, inp.planned, [], [], inp.modifiers,
|
||||||
|
date(2030, 1, 1), date(2030, 1, 31))
|
||||||
|
assert flows == [(date(2030, 1, 1), Decimal("-100.00"))]
|
||||||
|
# System-Plan kennt keine Szenario-Zahlungen.
|
||||||
|
assert [r.name for r in load_scenario_inputs(db, None).recurring] == ["R"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zeitraum_und_kategorie_werden_geladen(db):
|
||||||
|
cat = Category(name="K-A14")
|
||||||
|
sc = Scenario(name="S-A14b", description="")
|
||||||
|
db.add_all([cat, sc])
|
||||||
|
db.flush()
|
||||||
|
db.add_all([
|
||||||
|
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
|
||||||
|
kind="percent", value=Decimal("50"),
|
||||||
|
start_date=date(2030, 2, 1), end_date=date(2030, 3, 31)),
|
||||||
|
ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-5.00"),
|
||||||
|
due=date(2030, 2, 1), category_id=cat.id),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
inp = load_scenario_inputs(db, sc)
|
||||||
|
m = inp.modifiers[0]
|
||||||
|
assert (m.start_date, m.end_date) == (date(2030, 2, 1), date(2030, 3, 31))
|
||||||
|
assert [(p.name, p.category_id) for p in inp.planned] == [("E", cat.id)]
|
||||||
|
|||||||
Reference in New Issue
Block a user