# 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 (`- [ ]`) 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 `
`-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. - [ ] **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) - [ ] **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) ``` - [ ] **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) - [ ] **Step 4: Fable-Testagent-Abnahme** (Faktencheck: Validator-Wirkung beim PATCH [ende↔andere Arten], 404-Pfade, keine Nebenwirkungen auf Projektion). Erst nach VERIFIED weiter. - [ ] **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 `
` „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"`. - [ ] **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 "Modifikatoren" 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 - [ ] **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() ``` - [ ] **Step 3: Einträge-Tabelle** — in `planning.html` die BEIDEN `
`-Blöcke „Modifikatoren" und „Einmalzahlungen in diesem Szenario" komplett ersetzen durch: ```html

Einträge

{% for m in row.modifiers %} {% endfor %} {% for sp in row.planned_items %} {% endfor %} {% if not row.modifiers and not row.planned_items %} {% endif %}
WasArtWert/BetragDatum
{% 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 %} {{ m.kind|de_label }} {% if m.kind in ('percent', 'absolute') %}{{ m.value|eur }}{% else %}–{% endif %} {% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}{% else %}–{% endif %}
{{ sp.name }} Einmalzahlung {{ sp.amount|eur }} € {{ sp.due.strftime('%d.%m.%Y') }}
Keine Einträge.
``` - [ ] **Step 4: Anlege-Formular „Neuer Eintrag"** — direkt nach der Tabelle (Default-Art ist die erste Option `percent` → Wert aktiv, Datumsfelder/Einmal-Felder disabled): ```html
Neuer Eintrag:
``` 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. - [ ] **Step 5: JS** — im `