feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,8 @@ from app.engine.recurrence import occurrences
|
|||||||
from app.formats import de_label, eur
|
from app.formats import de_label, eur
|
||||||
from app.models.tables import (Account, Category, PlannedItem,
|
from app.models.tables import (Account, Category, PlannedItem,
|
||||||
ProjectionResult, RecurringItem,
|
ProjectionResult, RecurringItem,
|
||||||
ScenarioLoan, ScenarioModifier, Transaction)
|
ScenarioLoan, ScenarioModifier,
|
||||||
|
ScenarioPlannedItem, Transaction)
|
||||||
from app.routers.imports import list_imports as _list_imports
|
from app.routers.imports import list_imports as _list_imports
|
||||||
from app.routers.imports import preview as _preview_import
|
from app.routers.imports import preview as _preview_import
|
||||||
from app.routers.planning import list_loans as _list_loans
|
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(
|
modifiers = session.execute(
|
||||||
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
planned_items = session.execute(
|
||||||
|
select(ScenarioPlannedItem)
|
||||||
|
.where(ScenarioPlannedItem.scenario_id == sc.id)
|
||||||
|
.order_by(ScenarioPlannedItem.due)).scalars().all()
|
||||||
rows.append({
|
rows.append({
|
||||||
"scenario": sc,
|
"scenario": sc,
|
||||||
"assigned_loan_ids": assigned_loan_ids,
|
"assigned_loan_ids": assigned_loan_ids,
|
||||||
"modifiers": modifiers,
|
"modifiers": modifiers,
|
||||||
|
"planned_items": planned_items,
|
||||||
"result": session.get(ProjectionResult, sc.id),
|
"result": session.get(ProjectionResult, sc.id),
|
||||||
})
|
})
|
||||||
return rows
|
return rows
|
||||||
@@ -296,5 +302,5 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
|||||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||||
"repayment_types": ["annuity", "bullet"],
|
"repayment_types": ["annuity", "bullet"],
|
||||||
"modifier_kinds": ["percent", "absolute", "remove"],
|
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -70,6 +70,11 @@
|
|||||||
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
von Krediten und Modifikatoren (Kategorie oder Posten prozentual/absolut
|
||||||
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
kürzen oder ganz streichen). "Durchrechnen" liefert: tiefster Kontostand
|
||||||
mit Datum, erstes Datum unter 0 und unter der Warnschwelle.
|
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>
|
||||||
<li>
|
<li>
|
||||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||||
|
|||||||
@@ -311,7 +311,10 @@
|
|||||||
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ m.kind|de_label }}</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>
|
<td>
|
||||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
<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()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
@@ -348,15 +351,49 @@
|
|||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
<label>Art
|
<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 %}
|
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Wert <input type="text" name="value" data-type="amount" value="0"></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>
|
<button type="submit">Modifikator hinzufügen</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</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"
|
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
<button type="submit">Durchrechnen</button>
|
<button type="submit">Durchrechnen</button>
|
||||||
@@ -413,6 +450,13 @@
|
|||||||
recSelect.disabled = isCategory;
|
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) {
|
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
||||||
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
||||||
.then(function (resp) {
|
.then(function (resp) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ from datetime import date, timedelta
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from app.formats import eur
|
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"}
|
H = {"Authorization": "Bearer test-key"}
|
||||||
|
|
||||||
@@ -336,3 +337,23 @@ def test_salden_shows_ohne_anker_footnote(client, db):
|
|||||||
r = client.get("/salden")
|
r = client.get("/salden")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "ohne Anker" in r.text and "relative Werte" in r.text
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user