feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 11:59:42 +02:00
parent 13795fce25
commit 033c41f449
4 changed files with 81 additions and 5 deletions

View File

@@ -13,7 +13,8 @@ from app.engine.recurrence import occurrences
from app.formats import de_label, eur
from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem,
ScenarioLoan, ScenarioModifier, Transaction)
ScenarioLoan, ScenarioModifier,
ScenarioPlannedItem, Transaction)
from app.routers.imports import list_imports as _list_imports
from app.routers.imports import preview as _preview_import
from app.routers.planning import list_loans as _list_loans
@@ -264,10 +265,15 @@ def _scenario_rows(session: Session, scenarios: list, loans: list) -> list[dict]
modifiers = session.execute(
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
).scalars().all()
planned_items = session.execute(
select(ScenarioPlannedItem)
.where(ScenarioPlannedItem.scenario_id == sc.id)
.order_by(ScenarioPlannedItem.due)).scalars().all()
rows.append({
"scenario": sc,
"assigned_loan_ids": assigned_loan_ids,
"modifiers": modifiers,
"planned_items": planned_items,
"result": session.get(ProjectionResult, sc.id),
})
return rows
@@ -296,5 +302,5 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
"scenario_rows": _scenario_rows(session, scenarios, loans),
"rhythms": ["monthly", "quarterly", "yearly"],
"repayment_types": ["annuity", "bullet"],
"modifier_kinds": ["percent", "absolute", "remove"],
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})

View File

@@ -70,6 +70,11 @@
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
Varianten wie „Best Case“ entstehen über Modifikatoren: Art „Ende“ lässt
einen wiederkehrenden Posten (z. B. eine auslaufende Miete) innerhalb des
Szenarios zu einem Datum enden, ohne den Posten selbst zu ändern; „Prozent“/
„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz aus. Szenario-eigene
Einmalzahlungen (z. B. ein erwarteter Zufluss) zählen nur in ihrem Szenario.
</li>
<li>
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach

View File

@@ -311,7 +311,10 @@
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
</td>
<td>{{ m.kind|de_label }}</td>
<td>{{ m.value|eur }}</td>
<td>
{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '' }}
{% else %}{{ m.value|eur }}{% endif %}
</td>
<td>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
@@ -348,15 +351,49 @@
</label>
</span>
<label>Art
<select name="kind" data-type="str">
<select name="kind" data-type="str" onchange="onModKindChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
</select>
</label>
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
<label>Endet am <input type="date" name="end_date" disabled></label>
<button type="submit">Modifikator hinzufügen</button>
</form>
</details>
<details>
<summary>Einmalzahlungen in diesem Szenario</summary>
<table>
<thead><tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th></th></tr></thead>
<tbody>
{% for sp in row.planned_items %}
<tr>
<td>{{ sp.name }}</td>
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
<td>
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Löschen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4">Keine szenario-eigenen Einmalzahlungen.</td></tr>
{% endfor %}
</tbody>
</table>
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required></label>
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" required></label>
<label>Fällig am <input type="date" name="due" required></label>
<button type="submit">Einmalzahlung hinzufügen</button>
</form>
<p class="hint">Zählt nur in diesem Szenario — unabhängig von „Einmalposten einschließen“.</p>
</details>
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
@@ -413,6 +450,13 @@
recSelect.disabled = isCategory;
}
function onModKindChange(select) {
var form = select.closest('form');
var isEnde = select.value === 'ende';
form.querySelector('[name="end_date"]').disabled = !isEnde;
form.querySelector('[name="value"]').disabled = isEnde;
}
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
.then(function (resp) {

View File

@@ -2,7 +2,8 @@ from datetime import date, timedelta
from decimal import Decimal
from app.formats import eur
from app.models.tables import Account, Loan, PlannedItem, RecurringItem, Scenario
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem)
H = {"Authorization": "Bearer test-key"}
@@ -336,3 +337,23 @@ def test_salden_shows_ohne_anker_footnote(client, db):
r = client.get("/salden")
assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text
def test_szenario_gui_ende_und_einmalzahlungen(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="GUI-Ende", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/planung").text
assert 'value="ende"' in r # neue Art im Dropdown
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
assert "Einmalzahlungen in diesem Szenario" in r
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" in r
assert f'hx-post="/api/scenarios/{sc.id}/planned"' in r