From 1708abeb911ac7b0aa87d52ad7b62925bed7195689929cbce4c66741b1567ce1 Mon Sep 17 00:00:00 2001 From: wlfb Date: Mon, 20 Jul 2026 12:39:37 +0200 Subject: [PATCH] docs: Implementierungsplan Ausbaustufe 6 Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-20-ausbaustufe-6.md | 449 ++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-20-ausbaustufe-6.md diff --git a/docs/superpowers/plans/2026-07-20-ausbaustufe-6.md b/docs/superpowers/plans/2026-07-20-ausbaustufe-6.md new file mode 100644 index 0000000..75bc60a --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-ausbaustufe-6.md @@ -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 (`- [ ]`) 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 `