Compare commits
6 Commits
2282aa3922
...
974ef5c208
| Author | SHA256 | Date | |
|---|---|---|---|
| 974ef5c208 | |||
| 421899b922 | |||
| cb44194daf | |||
| 4843f2e130 | |||
| 1708abeb91 | |||
| 39c6a65b1d |
449
docs/superpowers/plans/2026-07-20-ausbaustufe-6.md
Normal file
449
docs/superpowers/plans/2026-07-20-ausbaustufe-6.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# Ausbaustufe 6 Implementation Plan — Szenario-Einträge-Tabelle (v0.7.0)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Pro Szenario eine immer sichtbare, editierbare Tabelle „Einträge" (Modifikatoren + szenario-eigene Einmalzahlungen gemeinsam) mit Inline-Bearbeiten und einem gemeinsamen Anlege-Formular; Release v0.7.0.
|
||||||
|
|
||||||
|
**Architecture:** Datenmodell unverändert — zwei neue Full-Body-PATCH-Endpunkte in `routers/scenarios.py`; Template-Umbau in `planning.html` ersetzt die zwei `<details>`-Abschnitte durch eine Tabelle; Feld-Umschaltung per `disabled` (JS `onEntryArtChange` ersetzt `onModKindChange`), Endpunkt-Wechsel des Anlege-Formulars per `hx-post`-Attribut + `htmx.process`.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI + Pydantic v2, Jinja2, htmx + json-form-Extension, pytest.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Geldbeträge `Decimal`; JSON-API Punkt-Dezimal; HTML deutsch (`|eur`, `|de_label`, TT.MM.JJJJ).
|
||||||
|
- API-/DB-Werte englisch; neuer Formular-Artwert `einmal` ist NUR clientseitig (landet nie in der DB — an `/planned` gesendet wird er von Pydantic ignoriert).
|
||||||
|
- UX-Regel: Umschalten/Verstecken nur per `disabled`/`hidden`, nie DOM-Entfernen.
|
||||||
|
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
|
||||||
|
- DATENSCHUTZ: keine echten Namen/Beträge in Commits/Tests/Doku; Live-Demo-Daten nur DB/Chat.
|
||||||
|
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 178 passed, muss grün bleiben.
|
||||||
|
- Pfade unten relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: PATCH-Endpunkte für Modifikatoren und Szenario-Einmalzahlungen
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/routers/scenarios.py`
|
||||||
|
- Modify: `tests/test_crud_api.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ModifierIn`/`ModifierOut`, `ScenarioPlannedIn`/`ScenarioPlannedOut`, `_get_scenario` (alle vorhanden in `scenarios.py`).
|
||||||
|
- Produces: `PATCH /api/scenarios/{scenario_id}/modifiers/{mod_id}` (Body `ModifierIn`, Antwort `ModifierOut`, 404 „Modifikator nicht gefunden") und `PATCH /api/scenarios/{scenario_id}/planned/{item_id}` (Body `ScenarioPlannedIn`, Antwort `ScenarioPlannedOut`, 404 „Einmalzahlung nicht gefunden"). Task 2 (GUI) sendet auf genau diese URLs.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing Tests** — in `tests/test_crud_api.py` ergänzen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_modifier_patch(client, db):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
|
||||||
|
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent",
|
||||||
|
"value": "10.00"}).json()
|
||||||
|
# Wert aendern
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
|
||||||
|
assert r.status_code == 200 and r.json()["value"] == "25.00"
|
||||||
|
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||||
|
"end_date": "2026-08-31"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
|
||||||
|
# ende ohne Datum -> 422
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
# zurueck auf remove: end_date wird genullt
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||||
|
"end_date": "2026-08-31"})
|
||||||
|
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||||
|
# fremdes Szenario -> 404
|
||||||
|
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={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "remove"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_planned_patch(client, db):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
|
||||||
|
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||||
|
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
|
||||||
|
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["name"] == "Zufluss geaendert"
|
||||||
|
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
|
||||||
|
# fremdes Szenario -> 404
|
||||||
|
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
|
||||||
|
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||||
|
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → neue Tests FAIL (405 Method Not Allowed)
|
||||||
|
|
||||||
|
- [x] **Step 2: Endpunkte implementieren** — `app/routers/scenarios.py`, direkt nach `delete_modifier` bzw. nach `delete_scenario_planned`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
|
||||||
|
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
modifier = session.get(ScenarioModifier, mod_id)
|
||||||
|
if modifier is None or modifier.scenario_id != scenario_id:
|
||||||
|
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||||
|
for key, value in data.model_dump().items():
|
||||||
|
setattr(modifier, key, value)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(modifier)
|
||||||
|
return ModifierOut.model_validate(modifier)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
|
||||||
|
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
# Full-Body-Update, Muster patch_modifier.
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
item = session.get(ScenarioPlannedItem, item_id)
|
||||||
|
if item is None or item.scenario_id != scenario_id:
|
||||||
|
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||||
|
for key, value in data.model_dump().items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return ScenarioPlannedOut.model_validate(item)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Tests grün + Suite**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q && .venv/bin/python -m pytest -q`
|
||||||
|
Expected: alles PASS (178 + 2)
|
||||||
|
|
||||||
|
- [x] **Step 4: Fable-Testagent-Abnahme** (Faktencheck: Validator-Wirkung beim PATCH [ende↔andere Arten], 404-Pfade, keine Nebenwirkungen auf Projektion). Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wlfb/bin && git add finance/app/routers/scenarios.py finance/tests/test_crud_api.py
|
||||||
|
git commit -m "feat: PATCH-Endpunkte fuer Szenario-Modifikatoren und -Einmalzahlungen"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: GUI-Umbau — eine Einträge-Tabelle je Szenario
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/planning.html` (Szenario-Abschnitt: die beiden `<details>` „Modifikatoren" [Zeilen ~300-362] und „Einmalzahlungen in diesem Szenario" [~364-395] ersetzen; JS-Block: `onModKindChange` ersetzen)
|
||||||
|
- Modify: `app/routers/gui.py` (`_scenario_rows`: Modifikator-Query stabil sortieren)
|
||||||
|
- Modify: `app/templates/hilfe.html` (Szenarien-Absatz an neue Tabelle anpassen)
|
||||||
|
- Modify: `tests/test_gui.py` (neuer Test + Anpassung `test_szenario_gui_ende_und_einmalzahlungen`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: PATCH-Endpunkte aus Task 1; `toggleEdit`, `onModTargetTypeChange`, Filter `|eur`/`|de_label`, Kontext `row.modifiers`/`row.planned_items`/`modifier_kinds`/`recurring_names`/`category_names` (alle vorhanden).
|
||||||
|
- Produces: Element-IDs `mod-row-{id}`/`mod-edit-{id}` und `spi-row-{id}`/`spi-edit-{id}`; JS `onEntryArtChange(select)` (ersetzt `onModKindChange` vollständig — auch in den Art-Selects); Anlege-Formular mit `data-modifiers-url`/`data-planned-url` und Eintragsart-Option `value="einmal"`.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` ergänzen (Imports `Scenario, ScenarioModifier, ScenarioPlannedItem` existieren dort seit A5):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_szenario_eintraege_tabelle(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
sc = Scenario(name="Eintraege-GUI", 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(ScenarioModifier(scenario_id=sc.id, target_type="category",
|
||||||
|
target_id=1, kind="percent", value=Decimal("25.00")))
|
||||||
|
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
|
||||||
|
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/planung").text
|
||||||
|
assert "Einträge" in r
|
||||||
|
# alte details-Abschnitte sind ersetzt
|
||||||
|
assert "<summary>Modifikatoren</summary>" not in r
|
||||||
|
assert "Einmalzahlungen in diesem Szenario" not in r
|
||||||
|
# gemischte Zeilen in EINER Tabelle
|
||||||
|
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
|
||||||
|
assert ">Einmalzahlung<" in r
|
||||||
|
# Inline-Edit je Typ
|
||||||
|
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
|
||||||
|
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
|
||||||
|
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
|
||||||
|
assert 'value="einmal"' in r
|
||||||
|
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||||
|
assert "onEntryArtChange" in r and "onModKindChange" not in r
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: → FAIL
|
||||||
|
|
||||||
|
- [x] **Step 2: `_scenario_rows` sortieren** — `app/routers/gui.py`, Modifikator-Query um `.order_by(ScenarioModifier.id)` ergänzen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
modifiers = session.execute(
|
||||||
|
select(ScenarioModifier).where(ScenarioModifier.scenario_id == sc.id)
|
||||||
|
.order_by(ScenarioModifier.id)
|
||||||
|
).scalars().all()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Einträge-Tabelle** — in `planning.html` die BEIDEN `<details>`-Blöcke „Modifikatoren" und „Einmalzahlungen in diesem Szenario" komplett ersetzen durch:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h3>Einträge</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in row.modifiers %}
|
||||||
|
<tr id="mod-row-{{ m.id }}">
|
||||||
|
<td>
|
||||||
|
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||||||
|
{% else %}Posten: {{ recurring_names.get(m.target_id, m.target_id) }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ m.kind|de_label }}</td>
|
||||||
|
<td>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||||||
|
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||||||
|
<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()}">
|
||||||
|
<button type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="mod-edit-{{ m.id }}" hidden>
|
||||||
|
<td colspan="5">
|
||||||
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Ziel-Typ
|
||||||
|
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||||
|
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
|
||||||
|
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
|
||||||
|
<label>Kategorie
|
||||||
|
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
|
||||||
|
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
|
||||||
|
<label>Posten
|
||||||
|
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
|
||||||
|
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<label>Art
|
||||||
|
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||||
|
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
|
||||||
|
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% for sp in row.planned_items %}
|
||||||
|
<tr id="spi-row-{{ sp.id }}">
|
||||||
|
<td>{{ sp.name }}</td>
|
||||||
|
<td>Einmalzahlung</td>
|
||||||
|
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||||||
|
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
|
||||||
|
<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>
|
||||||
|
<tr id="spi-edit-{{ sp.id }}" hidden>
|
||||||
|
<td colspan="5">
|
||||||
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
|
||||||
|
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
|
||||||
|
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not row.modifiers and not row.planned_items %}
|
||||||
|
<tr><td colspan="5">Keine Einträge.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Anlege-Formular „Neuer Eintrag"** — direkt nach der Tabelle (Default-Art ist die erste Option `percent` → Wert aktiv, Datumsfelder/Einmal-Felder disabled):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||||
|
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
|
||||||
|
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<strong>Neuer Eintrag:</strong>
|
||||||
|
<label>Eintragsart
|
||||||
|
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||||
|
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||||
|
<option value="einmal">Einmalzahlung</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="mod-target-category">
|
||||||
|
<label>Kategorie
|
||||||
|
<select name="target_id" data-type="int">
|
||||||
|
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<span class="mod-target-recurring" style="display:none">
|
||||||
|
<label>Posten
|
||||||
|
<select name="target_id" data-type="int" disabled>
|
||||||
|
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<label>Ziel-Typ
|
||||||
|
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||||
|
<option value="category">Kategorie</option>
|
||||||
|
<option value="recurring">Wiederkehrender Posten</option>
|
||||||
|
</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>
|
||||||
|
<label>Name <input type="text" name="name" disabled></label>
|
||||||
|
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled></label>
|
||||||
|
<label>Fällig am <input type="date" name="due" disabled></label>
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
WICHTIG: Der Ziel-Typ-Select muss VOR den beiden Ziel-Spans stehen wie im bisherigen Formular? Nein — Reihenfolge im bisherigen Formular war Ziel-Typ, dann Spans. Diese Reihenfolge beibehalten (Ziel-Typ zuerst, dann die Spans), damit das Formular lesbar bleibt — den obigen Block entsprechend anordnen: Eintragsart, Ziel-Typ, beide Spans, Wert, Endet am, Name, Betrag, Fällig am, Button.
|
||||||
|
|
||||||
|
- [x] **Step 5: JS** — im `<script>`-Block von `planning.html` die Funktion `onModKindChange` ERSETZEN durch:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function onEntryArtChange(select) {
|
||||||
|
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
|
||||||
|
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
|
||||||
|
// kein name/amount/due), wird es uebersprungen.
|
||||||
|
var form = select.closest('form');
|
||||||
|
var art = select.value;
|
||||||
|
var isMod = art !== 'einmal';
|
||||||
|
function setDisabled(name, disabled) {
|
||||||
|
var el = form.querySelector('[name="' + name + '"]');
|
||||||
|
if (el) { el.disabled = disabled; }
|
||||||
|
}
|
||||||
|
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||||
|
setDisabled('end_date', art !== 'ende');
|
||||||
|
setDisabled('name', isMod);
|
||||||
|
setDisabled('amount', isMod);
|
||||||
|
setDisabled('due', isMod);
|
||||||
|
var typeSelect = form.querySelector('[name="target_type"]');
|
||||||
|
if (typeSelect) {
|
||||||
|
typeSelect.disabled = !isMod;
|
||||||
|
var isCategory = typeSelect.value === 'category';
|
||||||
|
var catSelect = form.querySelector('.mod-target-category select');
|
||||||
|
var recSelect = form.querySelector('.mod-target-recurring select');
|
||||||
|
if (catSelect) { catSelect.disabled = !isMod || !isCategory; }
|
||||||
|
if (recSelect) { recSelect.disabled = !isMod || isCategory; }
|
||||||
|
}
|
||||||
|
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
||||||
|
if (form.hasAttribute('data-planned-url')) {
|
||||||
|
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
|
||||||
|
: form.getAttribute('data-planned-url'));
|
||||||
|
htmx.process(form);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`onModTargetTypeChange` und `toggleEdit` bleiben unverändert.
|
||||||
|
|
||||||
|
- [x] **Step 6: Bestehenden Test anpassen** — `tests/test_gui.py::test_szenario_gui_ende_und_einmalzahlungen` (aus A5 Task 7): die Assertions `"Einmalzahlungen in diesem Szenario" in r` und `f'hx-post="/api/scenarios/{sc.id}/planned"' in r` ersetzen durch:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert "Einträge" in r
|
||||||
|
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||||
|
```
|
||||||
|
|
||||||
|
Die übrigen Assertions (`value="ende"`, `name="end_date"`, `31.08.2026`, `Zufluss-GUI`, `5.000,00`, `30.07.2026`) bleiben gültig. Danach: `grep -n "Einmalzahlungen in diesem Szenario" tests/` → 0 Treffer.
|
||||||
|
|
||||||
|
- [x] **Step 7: Hilfe anpassen** — `app/templates/hilfe.html`, im Szenarien-Absatz (aus A5) den Satzteil zu Modifikatoren/Einmalzahlungen ersetzen durch (generisch, keine echten Daten):
|
||||||
|
|
||||||
|
```html
|
||||||
|
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||||
|
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||||
|
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||||
|
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||||
|
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||||
|
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||||
|
änderbar.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 8: Tests + Suite grün**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_gui.py -q && .venv/bin/python -m pytest -q`
|
||||||
|
Expected: alles PASS
|
||||||
|
|
||||||
|
- [x] **Step 9: Fable-Testagent-Abnahme** — Live-Anteil (TestClient-Approximation): gemischte Tabelle rendert beide Typen in korrekter Reihenfolge; Edit-Roundtrips für Modifikator (percent→ende inkl. Datumspflicht) und Einmalzahlung mit deutscher Betragseingabe; Umschaltmatrix-Handtrace für alle 5 Eintragsarten (inkl. disabled-Zustand von `kind`-Fremdfeld beim jeweils anderen Endpunkt); keine doppelten IDs. Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wlfb/bin && git add finance/app/templates/ finance/app/routers/gui.py finance/tests/test_gui.py
|
||||||
|
git commit -m "feat: Szenario-Eintraege als eine editierbare Tabelle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Release v0.7.0 — Redeploy, Live-Check, Ledger
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `finance/VERSION` (`0.6.0` → `0.7.0`)
|
||||||
|
- Modify: `.superpowers/sdd/progress.md` (Ledger), Plan-Häkchen in dieser Datei
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 1-2 committet, Suite grün, bestehendes Live-Szenario „Best Case" (id 4) mit 3 Einträgen.
|
||||||
|
|
||||||
|
- [x] **Step 1: Suite final** — `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` → alle grün.
|
||||||
|
|
||||||
|
- [x] **Step 2: Version + Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "0.7.0" > /home/wlfb/bin/finance/VERSION
|
||||||
|
cd /home/wlfb/bin && git add finance/VERSION && git commit -m "chore: Version 0.7.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Redeploy** — `cd /home/wlfb/bin && ./create_pod_finance.sh` (keine Migration nötig; Entrypoint-`alembic upgrade head` ist idempotent). Expected: Service aktiv, Readiness 200.
|
||||||
|
|
||||||
|
- [x] **Step 4: Live-Smoke** — `/api/version` == 0.7.0 (Bearer-Key aus `~/.local/share/finance_pod/.env`); GUI-Login; `/planung` zeigt im „Best Case"-Kasten die Einträge-Tabelle mit 3 Zeilen (2× Ende, 1× Einmalzahlung) OHNE aufklappen zu müssen; kein „Modifikatoren"-Summary mehr.
|
||||||
|
|
||||||
|
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: zusätzlich zu Step 4 ein kompletter Roundtrip in einem WEGWERF-Szenario „SMOKE-A6" (anlegen → Eintrag percent anlegen → per PATCH auf ende mit Datum ändern → Einmalzahlung anlegen → Betrag per PATCH ändern → Szenario löschen [räumt alles ab]); Bestandsdaten unverändert (confirmed-Tx-Count, „Best Case"-Einträge unangetastet). Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 6: Ledger + Plan-Häkchen + Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wlfb/bin && git add docs/superpowers/plans/2026-07-20-ausbaustufe-6.md && git commit -m "docs: Ledger/Plan Ausbaustufe 6" && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (beim Planschreiben)
|
||||||
|
|
||||||
|
- **Spec-Abdeckung:** API-PATCH → Task 1; Tabelle/Anlege-Formular/Inline-Edit/JS/Sortierung/Hilfe → Task 2; Release/Live-Check → Task 3. Vollständig.
|
||||||
|
- **Platzhalter:** keine.
|
||||||
|
- **Typ-Konsistenz:** URLs/IDs (`mod-`/`spi-`), `data-modifiers-url`/`data-planned-url`, `onEntryArtChange` konsistent zwischen Tasks 1/2; Test-Anpassung des A5-Tests explizit benannt (Step 6), damit keine Suite-Regression entsteht.
|
||||||
103
docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md
Normal file
103
docs/superpowers/specs/2026-07-20-szenario-eintraege-design.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Design — Ausbaustufe 6: Szenario-Einträge-Tabelle (v0.7.0)
|
||||||
|
|
||||||
|
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Freigabe zum
|
||||||
|
> Direktdurchlauf Spec → Plan → Umsetzung). Anlass: Nutzerfeedback nach
|
||||||
|
> v0.6.0 — die Szenario-Änderungen (Modifikatoren, Einmalzahlungen) waren
|
||||||
|
> auf zwei zugeklappte `<details>`-Tabellen verteilt und nicht editierbar;
|
||||||
|
> erwartet wurde EINE editierbare Tabelle aller Einträge (Zielbild: 15–20
|
||||||
|
> Änderungen pro Szenario bequem pflegen).
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Pro Szenario eine **immer sichtbare Tabelle „Einträge"**, die Modifikatoren
|
||||||
|
und szenario-eigene Einmalzahlungen gemeinsam zeigt, mit Inline-Bearbeiten
|
||||||
|
und Löschen je Zeile und einem gemeinsamen Anlege-Formular mit
|
||||||
|
Eintragsart-Umschaltung.
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
- **Datenmodell unverändert.** „Eintrag" ist eine reine Darstellungssicht
|
||||||
|
auf `ScenarioModifier` und `ScenarioPlannedItem`. Keine Migration.
|
||||||
|
- **Typ bleibt beim Bearbeiten erhalten:** ein Modifikator kann Art
|
||||||
|
(percent/absolute/remove/ende), Ziel und Wert/Datum ändern; eine
|
||||||
|
Einmalzahlung bleibt Einmalzahlung (Name/Betrag/Datum änderbar).
|
||||||
|
- „Kredite zuordnen" bleibt eigener Abschnitt (Zuordnung, kein Eintrag).
|
||||||
|
- Beschreibung des Szenarios bleibt Freitext ohne Funktion.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Zwei neue Endpunkte in `app/routers/scenarios.py` (Full-Body-Update — die
|
||||||
|
Edit-Formulare senden immer alle Felder des Typs):
|
||||||
|
|
||||||
|
- `PATCH /api/scenarios/{id}/modifiers/{mod_id}` — Body = `ModifierIn`
|
||||||
|
(inkl. bestehender Validierung: `ende` erfordert `end_date`, andere Arten
|
||||||
|
nullen es). 404 „Modifikator nicht gefunden" bei fremder/fehlender ID
|
||||||
|
(Muster `delete_modifier`). Antwort `ModifierOut`.
|
||||||
|
- `PATCH /api/scenarios/{id}/planned/{item_id}` — Body =
|
||||||
|
`ScenarioPlannedIn`. 404 „Einmalzahlung nicht gefunden" (Muster
|
||||||
|
`delete_scenario_planned`). Antwort `ScenarioPlannedOut`.
|
||||||
|
|
||||||
|
Sortierung für stabile Anzeige: Modifikator-Query in `_scenario_rows`
|
||||||
|
bekommt `.order_by(ScenarioModifier.id)`; Einmalzahlungen sind bereits nach
|
||||||
|
`due` sortiert.
|
||||||
|
|
||||||
|
## GUI (`app/templates/planning.html`)
|
||||||
|
|
||||||
|
Die zwei `<details>`-Abschnitte „Modifikatoren" und „Einmalzahlungen in
|
||||||
|
diesem Szenario" werden ersetzt durch eine offene Tabelle **„Einträge"**:
|
||||||
|
|
||||||
|
- Spalten: **Was** | **Art** | **Wert/Betrag** | **Datum** | Aktionen.
|
||||||
|
- Modifikator: Was = „Posten: X" / „Kategorie: Y"; Art = `|de_label`;
|
||||||
|
Wert = `value|eur` bei percent/absolute, sonst „–"; Datum =
|
||||||
|
`end_date` TT.MM.JJJJ bei ende, sonst „–".
|
||||||
|
- Einmalzahlung: Was = Name; Art = „Einmalzahlung"; Wert = `amount|eur €`
|
||||||
|
(neg-Klasse wie bisher); Datum = `due` TT.MM.JJJJ.
|
||||||
|
- Zeilen-Reihenfolge: erst Modifikatoren (nach id), dann Einmalzahlungen
|
||||||
|
(nach Fälligkeit).
|
||||||
|
- **Inline-Bearbeiten** je Zeile nach dem `toggleEdit`-Muster aus v0.6.0:
|
||||||
|
IDs `mod-row-{id}`/`mod-edit-{id}` bzw. `spi-row-{id}`/`spi-edit-{id}`.
|
||||||
|
Modifikator-Edit-Formular = Felder des Anlege-Formulars (Ziel-Typ, Ziel,
|
||||||
|
Art, Wert, Datum, vorbelegt, mit denselben Umschaltern), sendet
|
||||||
|
`hx-patch` auf den neuen Endpunkt. Einmalzahlungs-Edit: Name/Betrag/Datum.
|
||||||
|
- **Ein Anlege-Formular „Neuer Eintrag"** mit Dropdown **Eintragsart**
|
||||||
|
(Ende, Prozent, Absolut, Entfällt, Einmalzahlung; Werte
|
||||||
|
`ende|percent|absolute|remove|einmal`):
|
||||||
|
- Feld-Umschaltung ausschließlich per `disabled` (UX-Regel):
|
||||||
|
Ziel-Typ/Ziel aktiv bei den vier Modifikator-Arten; Wert aktiv bei
|
||||||
|
Prozent/Absolut; Datum „Endet am" (`end_date`) aktiv bei Ende;
|
||||||
|
Name/Betrag/„Fällig am" (`due`) aktiv bei Einmalzahlung.
|
||||||
|
- Der Eintragsart-Select trägt `name="kind"`; beim Absenden an den
|
||||||
|
Einmalzahlungs-Endpunkt wird das überzählige `kind` von Pydantic
|
||||||
|
ignoriert (Default `extra=ignore`), disabled-Felder fehlen in der
|
||||||
|
Serialisierung.
|
||||||
|
- Ziel-Endpunkt-Wechsel: JS setzt das `hx-post`-Attribut des Formulars um
|
||||||
|
(`…/modifiers` ↔ `…/planned`) und ruft `htmx.process(form)` auf.
|
||||||
|
- Bestehende Helfer (`onModTargetTypeChange`, `toggleEdit`) werden
|
||||||
|
weiterverwendet; neue Funktion `onEntryArtChange(select)` ersetzt
|
||||||
|
`onModKindChange` (Umschaltmatrix oben). Scoping wie bisher über
|
||||||
|
`closest('form')`, damit Anlege- und Edit-Formulare sich nicht stören.
|
||||||
|
|
||||||
|
`hilfe.html`: der Szenarien-Absatz wird auf die neue Einträge-Tabelle
|
||||||
|
angepasst (weiterhin generisch, keine echten Daten).
|
||||||
|
|
||||||
|
## Tests (TDD, Suite bleibt grün — Basis 178)
|
||||||
|
|
||||||
|
- API: PATCH Modifikator (Wert ändern; Art auf ende wechseln mit Datum;
|
||||||
|
ende ohne Datum → 422; fremde scenario_id → 404), PATCH Einmalzahlung
|
||||||
|
(Betrag/Datum ändern; fremde scenario_id → 404).
|
||||||
|
- GUI: eine Einträge-Tabelle statt der zwei details-Abschnitte
|
||||||
|
(Summary-Texte „Modifikatoren"/„Einmalzahlungen in diesem Szenario"
|
||||||
|
verschwinden), gemischte Zeilen inkl. „Einmalzahlung"-Art, Edit-Formulare
|
||||||
|
mit `hx-patch`-URLs, Anlege-Formular mit Eintragsart-Optionen.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
`finance/VERSION` → `0.7.0`, Redeploy via `./create_pod_finance.sh` (keine
|
||||||
|
Migration nötig), Live-Check am bestehenden Szenario „Best Case" (drei
|
||||||
|
Einträge sichtbar in einer Tabelle, ein Bearbeiten-Roundtrip live).
|
||||||
|
Fable-Testagent-Gate je Task vor Commit (CLAUDE.md). Datenschutz-Regel
|
||||||
|
unverändert: echte Namen/Beträge nur Live-DB/Chat.
|
||||||
|
|
||||||
|
**Außerhalb des Scopes:** Typwechsel Modifikator↔Einmalzahlung beim
|
||||||
|
Bearbeiten, Mehrfach-Bearbeitung/Bulk-Aktionen, Kredit-Zuordnung als
|
||||||
|
Eintrag, Änderungen an Engine/Projektion.
|
||||||
@@ -1 +1 @@
|
|||||||
0.6.0
|
0.7.0
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ 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)
|
||||||
|
.order_by(ScenarioModifier.id)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
planned_items = session.execute(
|
planned_items = session.execute(
|
||||||
select(ScenarioPlannedItem)
|
select(ScenarioPlannedItem)
|
||||||
|
|||||||
@@ -181,6 +181,21 @@ def delete_modifier(scenario_id: int, mod_id: int,
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{scenario_id}/modifiers/{mod_id}", response_model=ModifierOut)
|
||||||
|
def patch_modifier(scenario_id: int, mod_id: int, data: ModifierIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
# Full-Body-Update: das Edit-Formular sendet immer alle Felder des Typs.
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
modifier = session.get(ScenarioModifier, mod_id)
|
||||||
|
if modifier is None or modifier.scenario_id != scenario_id:
|
||||||
|
raise HTTPException(404, "Modifikator nicht gefunden")
|
||||||
|
for key, value in data.model_dump().items():
|
||||||
|
setattr(modifier, key, value)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(modifier)
|
||||||
|
return ModifierOut.model_validate(modifier)
|
||||||
|
|
||||||
|
|
||||||
class ScenarioPlannedIn(BaseModel):
|
class ScenarioPlannedIn(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
@@ -225,6 +240,21 @@ def delete_scenario_planned(scenario_id: int, item_id: int,
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{scenario_id}/planned/{item_id}", response_model=ScenarioPlannedOut)
|
||||||
|
def patch_scenario_planned(scenario_id: int, item_id: int, data: ScenarioPlannedIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
# Full-Body-Update, Muster patch_modifier.
|
||||||
|
_get_scenario(session, scenario_id)
|
||||||
|
item = session.get(ScenarioPlannedItem, item_id)
|
||||||
|
if item is None or item.scenario_id != scenario_id:
|
||||||
|
raise HTTPException(404, "Einmalzahlung nicht gefunden")
|
||||||
|
for key, value in data.model_dump().items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(item)
|
||||||
|
return ScenarioPlannedOut.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
|
@router.post("/{scenario_id}/project", response_model=ProjectionOut)
|
||||||
def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
def project_scenario(scenario_id: int, horizon_days: int | None = None,
|
||||||
start_date: date | None = None,
|
start_date: date | None = None,
|
||||||
|
|||||||
@@ -70,11 +70,13 @@
|
|||||||
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
|
Varianten wie „Best Case“ pflegst du in der Tabelle „Einträge“ des
|
||||||
einen wiederkehrenden Posten (z. B. eine auslaufende Miete) innerhalb des
|
Szenarios: Art „Ende“ lässt einen wiederkehrenden Posten (z. B. eine
|
||||||
Szenarios zu einem Datum enden, ohne den Posten selbst zu ändern; „Prozent“/
|
auslaufende Miete) zu einem Datum enden, ohne den Posten selbst zu
|
||||||
„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz aus. Szenario-eigene
|
ändern; „Prozent“/„Absolut“ kürzen Beträge; „Entfällt“ blendet sie ganz
|
||||||
Einmalzahlungen (z. B. ein erwarteter Zufluss) zählen nur in ihrem Szenario.
|
aus; „Einmalzahlung“ ergänzt einen einmaligen Zu- oder Abfluss, der nur
|
||||||
|
in diesem Szenario zählt. Jeder Eintrag ist über „Bearbeiten“ direkt
|
||||||
|
änderbar.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
<strong>Grafana:</strong> Kontostand-Verläufe, Monatsausgaben nach
|
||||||
|
|||||||
@@ -297,102 +297,138 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<h3>Einträge</h3>
|
||||||
<summary>Modifikatoren</summary>
|
<table>
|
||||||
<table>
|
<thead>
|
||||||
<thead>
|
<tr><th>Was</th><th>Art</th><th>Wert/Betrag</th><th>Datum</th><th></th></tr>
|
||||||
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
{% for m in row.modifiers %}
|
||||||
{% for m in row.modifiers %}
|
<tr id="mod-row-{{ m.id }}">
|
||||||
<tr>
|
<td>
|
||||||
<td>
|
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
||||||
{% if m.target_type == "category" %}Kategorie: {{ category_names.get(m.target_id, m.target_id) }}
|
{% 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>{% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %}</td>
|
||||||
<td>
|
<td>{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}</td>
|
||||||
{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}
|
<td>
|
||||||
{% else %}{{ m.value|eur }}{% endif %}
|
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, true)">Bearbeiten</button>
|
||||||
</td>
|
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||||
<td>
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
<button type="submit">Löschen</button>
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
</form>
|
||||||
<button type="submit">Löschen</button>
|
</td>
|
||||||
</form>
|
</tr>
|
||||||
</td>
|
<tr id="mod-edit-{{ m.id }}" hidden>
|
||||||
</tr>
|
<td colspan="5">
|
||||||
{% else %}
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/modifiers/{{ m.id }}" hx-swap="none"
|
||||||
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
{% endfor %}
|
<label>Ziel-Typ
|
||||||
</tbody>
|
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||||
</table>
|
<option value="category" {% if m.target_type == 'category' %}selected{% endif %}>Kategorie</option>
|
||||||
|
<option value="recurring" {% if m.target_type == 'recurring' %}selected{% endif %}>Wiederkehrender Posten</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="mod-target-category" {% if m.target_type != 'category' %}style="display:none"{% endif %}>
|
||||||
|
<label>Kategorie
|
||||||
|
<select name="target_id" data-type="int" {% if m.target_type != 'category' %}disabled{% endif %}>
|
||||||
|
{% for c in categories %}<option value="{{ c.id }}" {% if m.target_type == 'category' and c.id == m.target_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<span class="mod-target-recurring" {% if m.target_type != 'recurring' %}style="display:none"{% endif %}>
|
||||||
|
<label>Posten
|
||||||
|
<select name="target_id" data-type="int" {% if m.target_type != 'recurring' %}disabled{% endif %}>
|
||||||
|
{% for r in recurring %}<option value="{{ r.id }}" {% if m.target_type == 'recurring' and r.id == m.target_id %}selected{% endif %}>{{ r.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<label>Art
|
||||||
|
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||||
|
{% for k in modifier_kinds %}<option value="{{ k }}" {% if k == m.kind %}selected{% endif %}>{{ k|de_label }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Wert <input type="text" name="value" data-type="amount" value="{{ m.value|eur }}" {% if m.kind not in ('percent', 'absolute') %}disabled{% endif %}></label>
|
||||||
|
<label>Endet am <input type="date" name="end_date" value="{{ m.end_date.isoformat() if m.end_date else '' }}" {% if m.kind != 'ende' %}disabled{% endif %}></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('mod', {{ m.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% for sp in row.planned_items %}
|
||||||
|
<tr id="spi-row-{{ sp.id }}">
|
||||||
|
<td>{{ sp.name }}</td>
|
||||||
|
<td>Einmalzahlung</td>
|
||||||
|
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
||||||
|
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, true)">Bearbeiten</button>
|
||||||
|
<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>
|
||||||
|
<tr id="spi-edit-{{ sp.id }}" hidden>
|
||||||
|
<td colspan="5">
|
||||||
|
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{ sp.name }}" required></label>
|
||||||
|
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ sp.amount|eur }}" required></label>
|
||||||
|
<label>Fällig am <input type="date" name="due" value="{{ sp.due.isoformat() }}" required></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('spi', {{ sp.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not row.modifiers and not row.planned_items %}
|
||||||
|
<tr><td colspan="5">Keine Einträge.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/modifiers" hx-swap="none"
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
data-modifiers-url="/api/scenarios/{{ sc.id }}/modifiers"
|
||||||
<label>Ziel-Typ
|
data-planned-url="/api/scenarios/{{ sc.id }}/planned"
|
||||||
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
<option value="category">Kategorie</option>
|
<strong>Neuer Eintrag:</strong>
|
||||||
<option value="recurring">Wiederkehrender Posten</option>
|
<label>Eintragsart
|
||||||
|
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
|
||||||
|
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
|
||||||
|
<option value="einmal">Einmalzahlung</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Ziel-Typ
|
||||||
|
<select name="target_type" data-type="str" onchange="onModTargetTypeChange(this)">
|
||||||
|
<option value="category">Kategorie</option>
|
||||||
|
<option value="recurring">Wiederkehrender Posten</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="mod-target-category">
|
||||||
|
<label>Kategorie
|
||||||
|
<select name="target_id" data-type="int">
|
||||||
|
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span class="mod-target-category">
|
</span>
|
||||||
<label>Kategorie
|
<span class="mod-target-recurring" style="display:none">
|
||||||
<select name="target_id" data-type="int">
|
<label>Posten
|
||||||
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
|
<select name="target_id" data-type="int" disabled>
|
||||||
</select>
|
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||||
</label>
|
|
||||||
</span>
|
|
||||||
<span class="mod-target-recurring" style="display:none">
|
|
||||||
<label>Posten
|
|
||||||
<select name="target_id" data-type="int" disabled>
|
|
||||||
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</span>
|
|
||||||
<label>Art
|
|
||||||
<select name="kind" data-type="str" onchange="onModKindChange(this)">
|
|
||||||
{% 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>
|
</span>
|
||||||
<label>Endet am <input type="date" name="end_date" disabled></label>
|
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
||||||
<button type="submit">Modifikator hinzufügen</button>
|
<label>Endet am <input type="date" name="end_date" disabled></label>
|
||||||
</form>
|
<label>Name <input type="text" name="name" disabled></label>
|
||||||
</details>
|
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled></label>
|
||||||
|
<label>Fällig am <input type="date" name="due" disabled></label>
|
||||||
<details>
|
<button type="submit">Hinzufügen</button>
|
||||||
<summary>Einmalzahlungen in diesem Szenario</summary>
|
</form>
|
||||||
<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()}">
|
||||||
@@ -450,11 +486,37 @@
|
|||||||
recSelect.disabled = isCategory;
|
recSelect.disabled = isCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onModKindChange(select) {
|
function onEntryArtChange(select) {
|
||||||
|
// Umschaltmatrix "Neuer Eintrag"/Modifikator-Edit: Felder nur per
|
||||||
|
// disabled schalten (UX-Regel). Fehlt ein Feld (Edit-Formulare haben
|
||||||
|
// kein name/amount/due), wird es uebersprungen.
|
||||||
var form = select.closest('form');
|
var form = select.closest('form');
|
||||||
var isEnde = select.value === 'ende';
|
var art = select.value;
|
||||||
form.querySelector('[name="end_date"]').disabled = !isEnde;
|
var isMod = art !== 'einmal';
|
||||||
form.querySelector('[name="value"]').disabled = isEnde;
|
function setDisabled(name, disabled) {
|
||||||
|
var el = form.querySelector('[name="' + name + '"]');
|
||||||
|
if (el) { el.disabled = disabled; }
|
||||||
|
}
|
||||||
|
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||||
|
setDisabled('end_date', art !== 'ende');
|
||||||
|
setDisabled('name', isMod);
|
||||||
|
setDisabled('amount', isMod);
|
||||||
|
setDisabled('due', isMod);
|
||||||
|
var typeSelect = form.querySelector('[name="target_type"]');
|
||||||
|
if (typeSelect) {
|
||||||
|
typeSelect.disabled = !isMod;
|
||||||
|
var isCategory = typeSelect.value === 'category';
|
||||||
|
var catSelect = form.querySelector('.mod-target-category select');
|
||||||
|
var recSelect = form.querySelector('.mod-target-recurring select');
|
||||||
|
if (catSelect) { catSelect.disabled = !isMod || !isCategory; }
|
||||||
|
if (recSelect) { recSelect.disabled = !isMod || isCategory; }
|
||||||
|
}
|
||||||
|
// POST-Ziel nur beim Anlege-Formular wechseln (Edit-Formulare patchen fix).
|
||||||
|
if (form.hasAttribute('data-planned-url')) {
|
||||||
|
form.setAttribute('hx-post', isMod ? form.getAttribute('data-modifiers-url')
|
||||||
|
: form.getAttribute('data-planned-url'));
|
||||||
|
htmx.process(form);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
||||||
|
|||||||
@@ -229,3 +229,51 @@ def test_scenario_planned_crud_und_cleanup(client, db):
|
|||||||
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||||||
from app.models.tables import ScenarioPlannedItem
|
from app.models.tables import ScenarioPlannedItem
|
||||||
assert db.query(ScenarioPlannedItem).count() == 0
|
assert db.query(ScenarioPlannedItem).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_modifier_patch(client, db):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "ModPatch"}).json()
|
||||||
|
m = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent",
|
||||||
|
"value": "10.00"}).json()
|
||||||
|
# Wert aendern
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "percent", "value": "25.00"})
|
||||||
|
assert r.status_code == 200 and r.json()["value"] == "25.00"
|
||||||
|
# Art auf ende wechseln (mit Datum); value wird vom Validator genullt
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||||||
|
"end_date": "2026-08-31"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["kind"] == "ende" and r.json()["end_date"] == "2026-08-31"
|
||||||
|
# ende ohne Datum -> 422
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
# zurueck auf remove: end_date wird genullt
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/modifiers/{m['id']}", headers=H, json={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||||||
|
"end_date": "2026-08-31"})
|
||||||
|
assert r.status_code == 200 and r.json()["end_date"] is None
|
||||||
|
# fremdes Szenario -> 404
|
||||||
|
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={
|
||||||
|
"target_type": "recurring", "target_id": 1, "kind": "remove"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_planned_patch(client, db):
|
||||||
|
sc = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch"}).json()
|
||||||
|
p = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||||||
|
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"}).json()
|
||||||
|
r = client.patch(f"/api/scenarios/{sc['id']}/planned/{p['id']}", headers=H, json={
|
||||||
|
"name": "Zufluss geaendert", "amount": "6000.00", "due": "2026-08-15"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["name"] == "Zufluss geaendert"
|
||||||
|
assert body["amount"] == "6000.00" and body["due"] == "2026-08-15"
|
||||||
|
# fremdes Szenario -> 404
|
||||||
|
other = client.post("/api/scenarios", headers=H, json={"name": "SpiPatch2"}).json()
|
||||||
|
r = client.patch(f"/api/scenarios/{other['id']}/planned/{p['id']}", headers=H, json={
|
||||||
|
"name": "x", "amount": "1.00", "due": "2026-08-15"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|||||||
@@ -354,6 +354,36 @@ def test_szenario_gui_ende_und_einmalzahlungen(client, db):
|
|||||||
assert 'value="ende"' in r # neue Art im Dropdown
|
assert 'value="ende"' in r # neue Art im Dropdown
|
||||||
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
|
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
|
||||||
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
|
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
|
||||||
assert "Einmalzahlungen in diesem Szenario" in r
|
assert "Einträge" in r
|
||||||
|
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||||
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" 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
|
|
||||||
|
|
||||||
|
def test_szenario_eintraege_tabelle(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
sc = Scenario(name="Eintraege-GUI", 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(ScenarioModifier(scenario_id=sc.id, target_type="category",
|
||||||
|
target_id=1, kind="percent", value=Decimal("25.00")))
|
||||||
|
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
|
||||||
|
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/planung").text
|
||||||
|
assert "Einträge" in r
|
||||||
|
# alte details-Abschnitte sind ersetzt
|
||||||
|
assert "<summary>Modifikatoren</summary>" not in r
|
||||||
|
assert "Einmalzahlungen in diesem Szenario" not in r
|
||||||
|
# gemischte Zeilen in EINER Tabelle
|
||||||
|
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
|
||||||
|
assert ">Einmalzahlung<" in r
|
||||||
|
# Inline-Edit je Typ
|
||||||
|
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
|
||||||
|
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
|
||||||
|
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
|
||||||
|
assert 'value="einmal"' in r
|
||||||
|
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
|
||||||
|
assert "onEntryArtChange" in r and "onModKindChange" not in r
|
||||||
|
|||||||
Reference in New Issue
Block a user