1212 lines
57 KiB
Markdown
1212 lines
57 KiB
Markdown
# Ausbaustufe 5 Implementation Plan — Deutsche Formate, Posten-Bearbeitung, Szenario-Ende (v0.6.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:** Deutsche Zahlenformate in der gesamten Web-GUI, Inline-Bearbeitung aller Planungs-Objekte inkl. Start/Ende bei Fixposten, Szenario-Modifikator-Art »Ende« plus szenario-eigene Einmalzahlungen; Release v0.6.0 mit Live-Demo-Szenario »Best Case«.
|
||
|
||
**Architecture:** Anzeige-Formatierung als reine Funktionen in neuem Modul `app/formats.py` (Jinja-Filter `eur`/`de_label`, kein `locale`-Modul); Eingabe-Konvertierung clientseitig in der bestehenden json-form-htmx-Extension (`data-type="amount"`), API bleibt strikt Punkt-Dezimal. Szenario-Ende als neue Modifikator-Art `ende` (nullable `end_date`-Spalte) in der reinen Engine; szenario-eigene Einmalzahlungen als neue Tabelle `scenario_planned_items`, die `services/projection_service.py` unabhängig von `include_planned` einspeist. Eine Alembic-Migration.
|
||
|
||
**Tech Stack:** FastAPI + Pydantic v2, SQLAlchemy 2 (Mapped), Alembic, Jinja2, htmx + eigene json-form-Extension, pytest (SQLite in-memory via `conftest.py`), Podman-Pod-Deployment via `create_pod_finance.sh`.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-20-ausbaustufe-5-design.md`
|
||
|
||
## Global Constraints
|
||
|
||
- **Geldbeträge immer `decimal.Decimal`** (DB-Typ `MONEY = Numeric(12,2)`), niemals float-Arithmetik (CLAUDE.md).
|
||
- **GUI-Texte, Fehlermeldungen, Commit-Messages auf Deutsch**; Datumsformat TT.MM.JJJJ.
|
||
- **UX-Regel:** Bedienelemente sichtbar lassen (`hidden`/`disabled`, nie aus dem DOM entfernen); Fehler inline bzw. über den bestehenden `htmx:responseError`-Handler.
|
||
- **API-Werte/DB-Werte bleiben englisch** (`monthly`, `annuity`, `percent`, `ende`, …) — nur Anzeige-Labels sind deutsch.
|
||
- **Fable-Testagent-Gate:** Jeder Task braucht VOR dem Commit eine Abnahme durch den Fable-Testagenten (Faktencheck/Live-Test je nach Task). Kein Task ist ohne dieses Gate abgeschlossen.
|
||
- **Ledger:** Nach jedem abgeschlossenen Task ein Eintrag in `.superpowers/sdd/progress.md` (Commit-Range + Fable-Befund).
|
||
- **DATENSCHUTZ:** `tests/fixtures/*` = echte Kontodaten, nie committen/zitieren. Die konkreten Demo-Daten (Empfängernamen, Beträge, Termine des »Best Case«) stammen aus dem Nutzer-Chat und dürfen NUR in die Live-DB und den Chat — nicht in Commits, Ledger, Tests oder Doku.
|
||
- **Testlauf immer:** `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis-Erwartung: alle grün (aktuell 154 passed).
|
||
- **Arbeitsverzeichnis:** alle Pfade unten relativ zu `/home/wlfb/bin/finance` (Ausnahme: Ledger/Plan unter `/home/wlfb/bin`).
|
||
|
||
---
|
||
|
||
### Task 1: Anzeige-Formatierung — `app/formats.py` + Template-Umstellung
|
||
|
||
**Files:**
|
||
- Create: `app/formats.py`
|
||
- Create: `tests/test_formats.py`
|
||
- Modify: `app/routers/gui.py` (Filter-Registrierung, nach Zeile 30 `templates.env.globals[...]`)
|
||
- Modify: `app/templates/index.html`, `app/templates/salden.html`, `app/templates/transactions.html`, `app/templates/planning.html`, `app/templates/_preview_table.html`
|
||
- Modify: `tests/test_gui.py` (bestehende Punktformat-Assertions)
|
||
|
||
**Interfaces:**
|
||
- Produces: `app.formats.eur(value) -> str` (Decimal/str/int → `"−"`-lose deutsche Darstellung, z.B. `Decimal("-2474.5")` → `"-2.474,50"`, ohne €-Zeichen), `app.formats.de_label(value: str) -> str` (englischer API-Wert → deutsches Label, unbekannt → Rohwert), `app.formats.DE_LABELS: dict[str, str]`. Jinja-Filter `|eur` und `|de_label` in allen Templates verfügbar. Spätere Tasks (2, 4, 7) nutzen beide Filter.
|
||
|
||
- [ ] **Step 1: Failing Tests schreiben** — `tests/test_formats.py`:
|
||
|
||
```python
|
||
from decimal import Decimal
|
||
|
||
from app.formats import DE_LABELS, de_label, eur
|
||
|
||
|
||
def test_eur_basisformat():
|
||
assert eur(Decimal("2474.00")) == "2.474,00"
|
||
|
||
|
||
def test_eur_negativ_mit_mehreren_tausenderpunkten():
|
||
assert eur(Decimal("-1234567.5")) == "-1.234.567,50"
|
||
|
||
|
||
def test_eur_null_und_kleinbetraege():
|
||
assert eur(Decimal("0")) == "0,00"
|
||
assert eur(Decimal("-0.5")) == "-0,50"
|
||
assert eur(Decimal("999.99")) == "999,99"
|
||
|
||
|
||
def test_eur_rundung_half_up():
|
||
assert eur(Decimal("1.005")) == "1,01"
|
||
assert eur(Decimal("-1.005")) == "-1,01"
|
||
|
||
|
||
def test_eur_akzeptiert_strings_und_int():
|
||
# Templates reichen teils DB-Werte als str/int durch (z.B. Suggestions).
|
||
assert eur("1234.5") == "1.234,50"
|
||
assert eur(7) == "7,00"
|
||
|
||
|
||
def test_de_label_bekannte_und_unbekannte_werte():
|
||
assert de_label("monthly") == "monatlich"
|
||
assert de_label("quarterly") == "vierteljährlich"
|
||
assert de_label("yearly") == "jährlich"
|
||
assert de_label("annuity") == "Annuität"
|
||
assert de_label("bullet") == "endfällig"
|
||
assert de_label("percent") == "Prozent"
|
||
assert de_label("absolute") == "Absolut"
|
||
assert de_label("remove") == "Entfällt"
|
||
assert de_label("ende") == "Ende"
|
||
assert de_label("weirdvalue") == "weirdvalue"
|
||
assert set(DE_LABELS) == {"monthly", "quarterly", "yearly", "annuity",
|
||
"bullet", "percent", "absolute", "remove", "ende"}
|
||
```
|
||
|
||
- [ ] **Step 2: Fehlschlag verifizieren**
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_formats.py -q`
|
||
Expected: FAIL/ERROR mit `ModuleNotFoundError: No module named 'app.formats'`
|
||
|
||
- [ ] **Step 3: `app/formats.py` implementieren**
|
||
|
||
```python
|
||
"""Deutsche Anzeige-Formate fuer die GUI (Ausbaustufe 5).
|
||
|
||
Bewusst ohne locale-Modul: Container-Locales sind nicht garantiert
|
||
installiert, und die Ausgabe hinge sonst vom Host ab. Reine
|
||
Decimal-Formatierung; die JSON-API bleibt Punkt-Dezimal.
|
||
"""
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
|
||
DE_LABELS = {
|
||
"monthly": "monatlich",
|
||
"quarterly": "vierteljährlich",
|
||
"yearly": "jährlich",
|
||
"annuity": "Annuität",
|
||
"bullet": "endfällig",
|
||
"percent": "Prozent",
|
||
"absolute": "Absolut",
|
||
"remove": "Entfällt",
|
||
"ende": "Ende",
|
||
}
|
||
|
||
|
||
def eur(value) -> str:
|
||
"""Decimal("-2474.5") -> "-2.474,50" (ohne Euro-Zeichen, das setzt das Template)."""
|
||
d = Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
s = f"{d:,.2f}" # US-Trenner: "-2,474.50"
|
||
return s.replace(",", "\x00").replace(".", ",").replace("\x00", ".")
|
||
|
||
|
||
def de_label(value: str) -> str:
|
||
return DE_LABELS.get(value, value)
|
||
```
|
||
|
||
- [ ] **Step 4: Tests grün verifizieren**
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_formats.py -q`
|
||
Expected: alle Tests PASS
|
||
|
||
- [ ] **Step 5: Filter registrieren** — `app/routers/gui.py`, direkt nach `templates.env.globals["app_version"] = get_version()`:
|
||
|
||
```python
|
||
from app.formats import de_label, eur # (oben bei den Imports einsortieren)
|
||
|
||
templates.env.filters["eur"] = eur
|
||
templates.env.filters["de_label"] = de_label
|
||
```
|
||
|
||
- [ ] **Step 6: Failing GUI-Test für deutsche Anzeige** — in `tests/test_gui.py` ergänzen:
|
||
|
||
```python
|
||
def test_gui_zeigt_deutsche_betragsformate(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
acc = Account(bank="dkb", iban="DE-FMT-1", name="Formatkonto", type="giro")
|
||
db.add(acc)
|
||
db.flush()
|
||
acc.anchor_date = date(2026, 6, 30)
|
||
acc.anchor_balance = Decimal("12345.60")
|
||
db.commit()
|
||
r = client.get("/")
|
||
assert "12.345,60" in r.text
|
||
assert "12345.60" not in r.text
|
||
|
||
|
||
def test_planung_zeigt_deutsche_labels(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
db.add(RecurringItem(name="Miete-Label-Test", amount=Decimal("-600.00"),
|
||
rhythm="monthly", due_day=1))
|
||
db.commit()
|
||
r = client.get("/planung")
|
||
assert "monatlich" in r.text
|
||
assert "-600,00" in r.text
|
||
```
|
||
|
||
`RecurringItem` zum Import in `tests/test_gui.py` ergänzen: `from app.models.tables import Account, RecurringItem`.
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → die zwei neuen Tests FAIL (Punktformat/englische Labels im HTML).
|
||
|
||
- [ ] **Step 7: Templates umstellen** — alle `{{ '%.2f'|format(X) }}`-Vorkommen ersetzen durch `{{ X|eur }}` (Fundstellen: `index.html` Zeilen 14/29/32/71, `salden.html` 20/27/51/53, `transactions.html` 56, `_preview_table.html` 11/28/29, `planning.html` 16/67/101/146/147/231/284). In `planning.html` zusätzlich Labels:
|
||
- Zeile 17 `{{ r.rhythm }}` → `{{ r.rhythm|de_label }}`; Zeile 68 (Vorschläge) `{{ s.rhythm }}` → `{{ s.rhythm|de_label }}`; Zeile 148 `{{ l.repayment_type }}` → `{{ l.repayment_type|de_label }}`; Zeile 230 `{{ m.kind }}` → `{{ m.kind|de_label }}`.
|
||
- Dropdown-Optionen: `<option value="{{ rh }}">{{ rh|de_label }}</option>`, `<option value="{{ rt }}">{{ rt|de_label }}</option>`, `<option value="{{ k }}">{{ k|de_label }}</option>`.
|
||
- Kredit-Zins Zeile 147: `{{ l.annual_rate_pct|eur }} %`.
|
||
- Tilgungsplan-JS (`loadLoanSchedule`): Hilfsfunktion ergänzen und alle vier `Number(r.X).toFixed(2)` ersetzen:
|
||
|
||
```javascript
|
||
function fmtEur(n) {
|
||
return Number(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||
}
|
||
// in loadLoanSchedule:
|
||
+ '<td>' + fmtEur(r.payment) + ' €</td>'
|
||
+ '<td>' + fmtEur(r.interest) + ' €</td>'
|
||
+ '<td>' + fmtEur(r.principal) + ' €</td>'
|
||
+ '<td>' + fmtEur(r.remaining) + ' €</td></tr>';
|
||
```
|
||
|
||
- [ ] **Step 8: Bestehende Punktformat-Assertions anpassen**
|
||
|
||
Run: `grep -rn '\.00\|\.50\|\.99' tests/test_gui.py` und jede Assertion prüfen, die gerendertes HTML gegen Punktformat testet. Bekannt: `test_index_shows_account_anchor` erwartet `"Anker: 321.00"` → ändern in `"Anker: 321,00"`. Weitere Treffer analog auf Komma-Format umstellen (NUR Assertions gegen `r.text`/HTML — JSON-API-Assertions bleiben Punktformat!).
|
||
|
||
- [ ] **Step 9: Gesamte Suite grün**
|
||
|
||
Run: `.venv/bin/python -m pytest -q`
|
||
Expected: alle Tests passed (154 alt + 8 neu)
|
||
|
||
- [ ] **Step 10: Fable-Testagent-Abnahme einholen** (Faktencheck: Filter-Korrektheit inkl. Rundung/negativer Werte, Vollständigkeit der Template-Umstellung via grep `'%.2f'` → 0 Treffer in `app/templates/`, keine API-Format-Änderung). Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/formats.py finance/tests/test_formats.py finance/app/routers/gui.py finance/app/templates/ finance/tests/test_gui.py
|
||
git commit -m "feat: deutsche Betragsanzeige (eur-Filter) und deutsche GUI-Labels"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Deutsche Betrags-Eingabe — json-form `data-type="amount"`
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/base.html` (json-form-Extension, `encodeParameters`)
|
||
- Modify: `app/templates/planning.html` (alle Betrags-Inputs), `app/templates/index.html` (Anker-Saldo-Input)
|
||
- Modify: `tests/test_gui.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Filter `|eur` aus Task 1 (Vorbefüllung des Anker-Inputs).
|
||
- Produces: json-form-Konvention `data-type="amount"`: Eingabewert wird vor dem JSON-Versand normalisiert (Leerzeichen/`€` entfernen; enthält der Wert ein Komma → Tausenderpunkte entfernen, Komma→Punkt; ohne Komma unverändert). Tasks 4 und 7 verwenden `data-type="amount"` in ihren Formularen.
|
||
|
||
- [ ] **Step 1: Failing Test — Attribute im gerenderten HTML** (die JS-Konvertierung selbst ist serverseitig nicht testbar; sie wird im Fable-Live-Test geprüft). In `tests/test_gui.py`:
|
||
|
||
```python
|
||
def test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter(client):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
planung = client.get("/planung").text
|
||
# Fixposten-, Einmalposten-, Kredit-Formulare senden deutsche Eingaben:
|
||
assert planung.count('data-type="amount"') >= 4
|
||
assert '-49,99' in planung and '-2000,00' in planung
|
||
assert '-49.99' not in planung and '-2000.00' not in planung
|
||
index = client.get("/").text
|
||
assert 'placeholder="500,00"' in index
|
||
```
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_gui.py::test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter -q` → FAIL
|
||
|
||
- [ ] **Step 2: json-form-Extension erweitern** — `base.html`, in `encodeParameters` vor dem `else`-Zweig:
|
||
|
||
```javascript
|
||
} else if (kind === 'int') {
|
||
out[k] = parseInt(v, 10);
|
||
} else if (kind === 'amount') {
|
||
// Deutsche Betragseingabe: "1.234,56" -> "1234.56". Ohne Komma
|
||
// bleibt der Wert unveraendert (Punkt-Eingaben funktionieren weiter).
|
||
var s = String(v).replace(/[\s€]/g, '');
|
||
if (s.indexOf(',') !== -1) {
|
||
s = s.replace(/\./g, '').replace(',', '.');
|
||
}
|
||
out[k] = s;
|
||
} else {
|
||
```
|
||
|
||
- [ ] **Step 3: Eingabefelder umstellen**
|
||
- `planning.html` Fixposten-Formular: `<input type="text" name="amount" data-type="amount" placeholder="-49,99" required>`
|
||
- `planning.html` Einmalposten-Formular: `placeholder="-2000,00"`, `data-type="amount"`
|
||
- `planning.html` Kredit-Formular: `principal` → `data-type="amount" placeholder="10000,00"`; `annual_rate_pct` → `data-type="amount" placeholder="4,5"`
|
||
- `planning.html` Modifikator-Formular: `<input type="text" name="value" data-type="amount" value="0">`
|
||
- `index.html` Anker-Formular: `<input type="text" name="anchor_balance" data-type="amount" placeholder="500,00" value="{{ account.anchor_balance|eur if account.anchor_balance is not none else '' }}" required>`
|
||
- Die versteckten Felder der Vorschlags-Übernahme (`<input type="hidden" name="amount" value="{{ s.amount }}">`) bekommen KEIN `data-type="amount"` — der Server liefert dort Punktformat, und die amount-Normalisierung ließe es unverändert; ohne Attribut bleibt die Semantik explizit.
|
||
|
||
- [ ] **Step 4: Tests grün + Suite**
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_gui.py -q && .venv/bin/python -m pytest -q`
|
||
Expected: alles PASS
|
||
|
||
- [ ] **Step 5: Fable-Testagent-Abnahme** — diesmal MIT Live-Anteil: gegen die laufende Instanz (oder lokal gestartete App) im Browser/per curl prüfen, dass ein Fixposten mit Eingabe `1.234,56` als `1234.56` gespeichert wird und `-49,99` korrekt ankommt. Erst nach VERIFIED weiter. (Hinweis: Wenn erst beim Release-Task deployt wird, den Live-Anteil per `uvicorn`-Teststart gegen SQLite erledigen: `cd finance && FB_DATABASE_URL=sqlite:///./demo.sqlite FB_GUI_PASSWORD_HASH=$(.venv/bin/python -c "from app.auth import hash_password; print(hash_password('demo'))") .venv/bin/uvicorn app.main:app --port 8123` — Wegwerf-DB danach löschen.)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/templates/ finance/tests/test_gui.py
|
||
git commit -m "feat: Betragseingabe mit Komma (data-type=amount in json-form)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Fixposten — Start/Ende in der GUI + Datumsvalidierung
|
||
|
||
**Files:**
|
||
- Modify: `app/routers/planning.py` (`RecurringIn`, `patch_recurring`)
|
||
- Modify: `app/templates/planning.html` (Tabelle + Anlege-Formular)
|
||
- Modify: `tests/test_planning_api.py`, `tests/test_gui.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: bestehende Felder `RecurringItem.start_date/end_date`, `RecurringIn/RecurringPatch.start_date/end_date` (existieren schon), Filter `|eur`/`|de_label`.
|
||
- Produces: Validierungsregel »`end_date >= start_date`, sonst 422 mit Meldung „Ende darf nicht vor Start liegen"« bei POST und PATCH `/api/recurring`. GUI-Spalten »Start«/»Ende«. Task 4 baut das Bearbeiten-Formular auf dieselben Felder.
|
||
|
||
- [ ] **Step 1: Failing API-Tests** — in `tests/test_planning_api.py`:
|
||
|
||
```python
|
||
def test_recurring_ende_vor_start_wird_abgelehnt(client):
|
||
r = client.post("/api/recurring", headers=H, json={
|
||
"name": "Falschrum", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
|
||
"start_date": "2026-10-01", "end_date": "2026-09-01"})
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
|
||
r = client.post("/api/recurring", headers=H, json={
|
||
"name": "Befristet", "amount": "-10.00", "rhythm": "monthly", "due_day": 1,
|
||
"start_date": "2026-01-01", "end_date": "2026-12-31"})
|
||
assert r.status_code == 201
|
||
item = r.json()
|
||
assert item["start_date"] == "2026-01-01" and item["end_date"] == "2026-12-31"
|
||
# PATCH, der Ende vor den (bestehenden) Start schieben will -> 422:
|
||
bad = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
||
json={"end_date": "2025-06-30"})
|
||
assert bad.status_code == 422
|
||
# Datum explizit loeschen (null) ist erlaubt:
|
||
ok = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
||
json={"end_date": None})
|
||
assert ok.status_code == 200 and ok.json()["end_date"] is None
|
||
```
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → die neuen Tests FAIL (POST liefert 201 statt 422 bzw. PATCH 200 statt 422)
|
||
|
||
- [ ] **Step 2: Validierung implementieren** — `app/routers/planning.py`:
|
||
|
||
Import ergänzen: `from pydantic import BaseModel, ConfigDict, Field, model_validator`
|
||
|
||
In `RecurringIn` (gilt via Vererbung NICHT für `RecurringPatch` — dort prüft der Router den Merge-Zustand):
|
||
|
||
```python
|
||
class RecurringIn(BaseModel):
|
||
name: str
|
||
amount: Decimal
|
||
rhythm: Literal["monthly", "quarterly", "yearly"]
|
||
due_day: int = Field(ge=1, le=31)
|
||
start_date: date | None = None
|
||
end_date: date | None = None
|
||
category_id: int | None = None
|
||
|
||
@model_validator(mode="after")
|
||
def _ende_nicht_vor_start(self):
|
||
if (self.start_date is not None and self.end_date is not None
|
||
and self.end_date < self.start_date):
|
||
raise ValueError("Ende darf nicht vor Start liegen")
|
||
return self
|
||
```
|
||
|
||
In `patch_recurring` nach der `setattr`-Schleife, vor `session.commit()`:
|
||
|
||
```python
|
||
if (item.start_date is not None and item.end_date is not None
|
||
and item.end_date < item.start_date):
|
||
session.rollback()
|
||
raise HTTPException(422, "Ende darf nicht vor Start liegen")
|
||
```
|
||
|
||
- [ ] **Step 3: API-Tests grün**
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → PASS
|
||
|
||
- [ ] **Step 4: Failing GUI-Test** — in `tests/test_gui.py`:
|
||
|
||
```python
|
||
def test_planung_fixposten_zeigt_start_und_ende(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
db.add(RecurringItem(name="Befristet-GUI", amount=Decimal("-5.00"),
|
||
rhythm="monthly", due_day=1,
|
||
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31)))
|
||
db.commit()
|
||
r = client.get("/planung").text
|
||
assert "<th>Start</th>" in r and "<th>Ende</th>" in r
|
||
assert "01.01.2026" in r and "31.12.2026" in r
|
||
assert 'name="start_date"' in r and 'name="end_date"' in r
|
||
```
|
||
|
||
Run: → FAIL
|
||
|
||
- [ ] **Step 5: Template** — `planning.html`, Fixposten-Tabelle:
|
||
|
||
Kopfzeile: `<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th>Ende</th><th>Kategorie</th><th></th></tr>`
|
||
|
||
Datenzeile (nach der Fälligkeitstag-Zelle):
|
||
|
||
```html
|
||
<td>{{ r.start_date.strftime('%d.%m.%Y') if r.start_date else '–' }}</td>
|
||
<td>{{ r.end_date.strftime('%d.%m.%Y') if r.end_date else '–' }}</td>
|
||
```
|
||
|
||
`colspan` der Leerzeile von 6 auf 8 erhöhen. Anlege-Formular (nach Fälligkeitstag):
|
||
|
||
```html
|
||
<label>Start (optional) <input type="date" name="start_date"></label>
|
||
<label>Ende (optional) <input type="date" name="end_date"></label>
|
||
```
|
||
|
||
(Leere Datumsfelder sendet die json-form-Extension bereits als `null`.)
|
||
|
||
- [ ] **Step 6: Suite grün**
|
||
|
||
Run: `.venv/bin/python -m pytest -q` → PASS
|
||
|
||
- [ ] **Step 7: Fable-Testagent-Abnahme** (Faktencheck: Validierung POST+PATCH inkl. Null-Löschen; GUI-Spalten; `occurrences` klammert Start/Ende korrekt — bestehende Engine-Tests referenzieren). Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/routers/planning.py finance/app/templates/planning.html finance/tests/
|
||
git commit -m "feat: Start/Ende fuer Fixposten in der GUI + Datumsvalidierung"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Inline-Bearbeiten — Fixposten, Einmalposten, Kredite, Szenario-Kopf
|
||
|
||
**Files:**
|
||
- Modify: `app/templates/planning.html`
|
||
- Modify: `tests/test_gui.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PATCH /api/recurring/{id}`, `/api/planned/{id}`, `/api/loans/{id}`, `/api/scenarios/{id}` (existieren alle); json-form inkl. `data-type="amount"` (Task 2); Filter `|eur`/`|de_label` (Task 1); Start/Ende-Felder (Task 3).
|
||
- Produces: JS-Helfer `toggleEdit(prefix, id, editing)` in `planning.html`; Formularzeilen-IDs `rec-edit-{id}`, `pln-edit-{id}`, `loan-edit-{id}`, `sc-edit-{id}` und Anzeige-IDs `rec-row-{id}`, `pln-row-{id}`, `loan-row-{id}`, `sc-head-{id}`. Task 7 folgt demselben Muster nicht (Szenario-Unterobjekte haben eigene Formulare), benötigt aber `toggleEdit` nicht.
|
||
|
||
- [ ] **Step 1: Failing GUI-Test**
|
||
|
||
```python
|
||
def test_planung_hat_bearbeiten_formulare(client, db):
|
||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||
db.add(RecurringItem(name="EditR", amount=Decimal("-5.00"), rhythm="monthly", due_day=1))
|
||
db.add(PlannedItem(name="EditP", amount=Decimal("-7.00"), due=date(2026, 8, 1)))
|
||
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
|
||
annual_rate_pct=Decimal("4.50"), term_months=12,
|
||
payout_date=date(2026, 8, 1), repayment_type="annuity"))
|
||
db.add(Scenario(name="EditS", description="d"))
|
||
db.commit()
|
||
r = client.get("/planung").text
|
||
assert r.count(">Bearbeiten</button>") >= 4
|
||
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
|
||
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'):
|
||
assert fragment in r, fragment
|
||
```
|
||
|
||
Imports in `tests/test_gui.py` ergänzen: `from app.models.tables import Account, Loan, PlannedItem, RecurringItem, Scenario`
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_gui.py::test_planung_hat_bearbeiten_formulare -q` → FAIL
|
||
|
||
- [ ] **Step 2: JS-Helfer in `planning.html`** (in den bestehenden `<script>`-Block):
|
||
|
||
```javascript
|
||
function toggleEdit(prefix, id, editing) {
|
||
// UX-Regel: Zeilen werden versteckt (hidden), nie aus dem DOM entfernt.
|
||
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
||
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Fixposten-Zeile umbauen** — Anzeige-`<tr>` bekommt `id="rec-row-{{ r.id }}"`, in der Aktions-Zelle vor dem Löschen-Formular:
|
||
|
||
```html
|
||
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, true)">Bearbeiten</button>
|
||
```
|
||
|
||
Direkt nach der Anzeige-Zeile die Bearbeitungszeile:
|
||
|
||
```html
|
||
<tr id="rec-edit-{{ r.id }}" hidden>
|
||
<td colspan="8">
|
||
<form hx-ext="json-form" hx-patch="/api/recurring/{{ r.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Name <input type="text" name="name" value="{{ r.name }}" required></label>
|
||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ r.amount|eur }}" required></label>
|
||
<label>Rhythmus
|
||
<select name="rhythm" data-type="str">
|
||
{% for rh in rhythms %}<option value="{{ rh }}" {% if rh == r.rhythm %}selected{% endif %}>{{ rh|de_label }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<label>Fälligkeitstag <input type="number" name="due_day" data-type="int" min="1" max="31" value="{{ r.due_day }}" required></label>
|
||
<label>Start <input type="date" name="start_date" value="{{ r.start_date.isoformat() if r.start_date else '' }}"></label>
|
||
<label>Ende <input type="date" name="end_date" value="{{ r.end_date.isoformat() if r.end_date else '' }}"></label>
|
||
<label>Kategorie
|
||
<select name="category_id" data-type="int">
|
||
<option value="">–</option>
|
||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == r.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('rec', {{ r.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
```
|
||
|
||
- [ ] **Step 4: Einmalposten analog** — Anzeige-`<tr id="pln-row-{{ p.id }}">`, Button `toggleEdit('pln', {{ p.id }}, true)`, Bearbeitungszeile:
|
||
|
||
```html
|
||
<tr id="pln-edit-{{ p.id }}" hidden>
|
||
<td colspan="5">
|
||
<form hx-ext="json-form" hx-patch="/api/planned/{{ p.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Name <input type="text" name="name" value="{{ p.name }}" required></label>
|
||
<label>Betrag <input type="text" name="amount" data-type="amount" value="{{ p.amount|eur }}" required></label>
|
||
<label>Fällig am <input type="date" name="due" value="{{ p.due.isoformat() }}" required></label>
|
||
<label>Kategorie
|
||
<select name="category_id" data-type="int">
|
||
<option value="">–</option>
|
||
{% for c in categories %}<option value="{{ c.id }}" {% if c.id == p.category_id %}selected{% endif %}>{{ c.name }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('pln', {{ p.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
```
|
||
|
||
- [ ] **Step 5: Kredite analog** — Anzeige-`<tr id="loan-row-{{ l.id }}">`, Button `toggleEdit('loan', {{ l.id }}, true)`, Bearbeitungszeile (`colspan="7"`):
|
||
|
||
```html
|
||
<tr id="loan-edit-{{ l.id }}" hidden>
|
||
<td colspan="7">
|
||
<form hx-ext="json-form" hx-patch="/api/loans/{{ l.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Name <input type="text" name="name" value="{{ l.name }}" required></label>
|
||
<label>Darlehenssumme <input type="text" name="principal" data-type="amount" value="{{ l.principal|eur }}" required></label>
|
||
<label>Zins p.a. (%) <input type="text" name="annual_rate_pct" data-type="amount" value="{{ l.annual_rate_pct|eur }}" required></label>
|
||
<label>Laufzeit (Monate) <input type="number" name="term_months" data-type="int" min="1" value="{{ l.term_months }}" required></label>
|
||
<label>Auszahlungsdatum <input type="date" name="payout_date" value="{{ l.payout_date.isoformat() }}" required></label>
|
||
<label>Tilgungsart
|
||
<select name="repayment_type" data-type="str">
|
||
{% for rt in repayment_types %}<option value="{{ rt }}" {% if rt == l.repayment_type %}selected{% endif %}>{{ rt|de_label }}</option>{% endfor %}
|
||
</select>
|
||
</label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('loan', {{ l.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
```
|
||
|
||
Hinweis im Template als Kommentar: eine Kredit-Änderung ändert den Tilgungsplan; Szenarien müssen danach neu durchgerechnet werden.
|
||
|
||
- [ ] **Step 6: Szenario-Kopf** — der beschreibende `<p>` im Szenario-Fieldset bekommt `id="sc-head-{{ sc.id }}"` und daneben einen Button `<button type="button" onclick="toggleEdit('sc', sc_id, true)">Bearbeiten</button>` (mit `{{ sc.id }}`); direkt danach ein verstecktes `<div id="sc-edit-{{ sc.id }}" hidden>`:
|
||
|
||
```html
|
||
<div id="sc-edit-{{ sc.id }}" hidden>
|
||
<form hx-ext="json-form" hx-patch="/api/scenarios/{{ sc.id }}" hx-swap="none"
|
||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||
<label>Name <input type="text" name="name" value="{{ sc.name }}" required></label>
|
||
<label>Beschreibung <input type="text" name="description" value="{{ sc.description }}"></label>
|
||
<label><input type="checkbox" name="include_recurring" data-type="bool" {% if sc.include_recurring %}checked{% endif %}> Wiederkehrende Posten einschließen</label>
|
||
<label><input type="checkbox" name="include_planned" data-type="bool" {% if sc.include_planned %}checked{% endif %}> Einmalposten einschließen</label>
|
||
<button type="submit">Speichern</button>
|
||
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, false)">Abbrechen</button>
|
||
</form>
|
||
</div>
|
||
```
|
||
|
||
Achtung `toggleEdit`-Prefix `sc` erwartet `sc-row-{id}` — hier heißt das Anzeige-Element `sc-head-{id}`. Deshalb `toggleEdit` generisch halten: die Anzeige-ID als `prefix + '-row-' + id` ODER Sonderfall vermeiden, indem der `<p>` die ID `sc-row-{{ sc.id }}` bekommt (einfachste Lösung — so umsetzen, `sc-head` nicht verwenden).
|
||
|
||
- [ ] **Step 7: Tests + Suite grün**
|
||
|
||
Run: `.venv/bin/python -m pytest -q` → PASS
|
||
|
||
- [ ] **Step 8: Fable-Testagent-Abnahme** — Live-Test (uvicorn-Teststart wie in Task 2 oder deployte Instanz): je Bereich einmal Bearbeiten→Ändern→Speichern→Reload prüfen, inkl. deutscher Betragseingabe im Edit-Formular (vorbefüllt `1.234,56` muss nach Speichern unverändert bleiben) und Abbrechen. Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/templates/planning.html finance/tests/test_gui.py
|
||
git commit -m "feat: Inline-Bearbeiten fuer Fixposten, Einmalposten, Kredite und Szenario-Kopf"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Migration + Modelle + Engine — Modifikator-Art `ende`, Tabelle `scenario_planned_items`
|
||
|
||
**Files:**
|
||
- Modify: `app/models/tables.py` (`ScenarioModifier.end_date`, neue Klasse `ScenarioPlannedItem`)
|
||
- Create: `alembic/versions/c4d7e2a91b53_szenario_ende.py`
|
||
- Modify: `app/engine/scenario.py` (`PlainModifier.end_date`, effektives Ende in `build_cashflows`)
|
||
- Modify: `tests/test_scenario_engine.py`, `tests/test_models.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `engine.recurrence.occurrences(rhythm, due_day, window_start, window_end, item_start, item_end)` (unverändert).
|
||
- Produces: `ScenarioModifier.end_date: date | None`; ORM-Klasse `ScenarioPlannedItem(id, scenario_id, name, amount, due)` (Tabelle `scenario_planned_items`); `PlainModifier(target_type, target_id, kind, value, end_date: date | None = None)` — Task 6 baut API/Service darauf. Modifikator-Art-String: `"ende"`.
|
||
|
||
- [ ] **Step 1: Failing Engine-Tests** — in `tests/test_scenario_engine.py` (vorhandene Imports: `build_cashflows`, `PlainRecurring`, `PlainPlanned`, `PlainModifier`, `date`, `Decimal` — prüfen und ggf. ergänzen):
|
||
|
||
```python
|
||
def test_ende_modifier_beendet_posten_im_szenario():
|
||
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
|
||
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
|
||
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
|
||
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
|
||
assert all(a == Decimal("-2000.00") for _, a in flows)
|
||
|
||
|
||
def test_ende_modifier_minimum_mit_eigenem_ende():
|
||
# Posten endet selbst am 15.10.; Szenario-Ende 31.08. ist frueher -> gewinnt.
|
||
r = PlainRecurring(1, "Miete", Decimal("-100.00"), "monthly", 1,
|
||
None, date(2026, 10, 15), None)
|
||
m = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 8, 31))
|
||
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 12, 31))
|
||
assert [d for d, _ in flows] == [date(2026, 7, 1), date(2026, 8, 1)]
|
||
# Umgekehrt: Szenario-Ende NACH eigenem Ende aendert nichts.
|
||
m_spaet = PlainModifier("recurring", 1, "ende", Decimal("0"), date(2026, 12, 31))
|
||
flows2 = build_cashflows([r], [], [], [], [m_spaet], date(2026, 7, 1), date(2026, 12, 31))
|
||
assert [d for d, _ in flows2][-1] == date(2026, 10, 1)
|
||
|
||
|
||
def test_ende_modifier_auf_kategorie_trifft_alle_posten_der_kategorie():
|
||
r1 = PlainRecurring(1, "Miete A", Decimal("-10.00"), "monthly", 1, None, None, 7)
|
||
r2 = PlainRecurring(2, "Miete B", Decimal("-20.00"), "monthly", 1, None, None, 7)
|
||
r3 = PlainRecurring(3, "Strom", Decimal("-5.00"), "monthly", 1, None, None, 9)
|
||
m = PlainModifier("category", 7, "ende", Decimal("0"), date(2026, 7, 31))
|
||
flows = build_cashflows([r1, r2, r3], [], [], [], [m],
|
||
date(2026, 7, 1), date(2026, 9, 30))
|
||
betraege = sorted(a for _, a in flows)
|
||
# Miete A/B nur je 1x (Juli), Strom 3x (Juli-Sept).
|
||
assert betraege == [Decimal("-20.00"), Decimal("-10.00"),
|
||
Decimal("-5.00"), Decimal("-5.00"), Decimal("-5.00")]
|
||
|
||
|
||
def test_ende_modifier_ohne_datum_wirkt_nicht():
|
||
r = PlainRecurring(1, "Miete", Decimal("-10.00"), "monthly", 1, None, None, None)
|
||
m = PlainModifier("recurring", 1, "ende", Decimal("0"), None)
|
||
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 9, 30))
|
||
assert len(flows) == 3
|
||
|
||
|
||
def test_ende_modifier_aendert_betraege_nicht():
|
||
r = PlainRecurring(1, "Miete", Decimal("-2000.00"), "monthly", 1, None, None, None)
|
||
m = PlainModifier("recurring", 1, "ende", Decimal("50"), date(2026, 12, 31))
|
||
flows = build_cashflows([r], [], [], [], [m], date(2026, 7, 1), date(2026, 8, 31))
|
||
assert all(a == Decimal("-2000.00") for _, a in flows)
|
||
```
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_scenario_engine.py -q` → FAIL (`PlainModifier` nimmt kein 5. Argument)
|
||
|
||
- [ ] **Step 2: Engine implementieren** — `app/engine/scenario.py`:
|
||
|
||
`PlainModifier` erweitern:
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class PlainModifier:
|
||
target_type: str
|
||
target_id: int
|
||
kind: str
|
||
value: Decimal
|
||
end_date: date | None = None
|
||
```
|
||
|
||
Neue Hilfsfunktion + Nutzung in `build_cashflows` (die `for r in recurring:`-Schleife):
|
||
|
||
```python
|
||
def _effective_end(r: PlainRecurring, modifiers: list[PlainModifier]) -> date | None:
|
||
"""Fruehestes Ende aus eigenem end_date und allen treffenden ende-Modifikatoren."""
|
||
end = r.end_date
|
||
for m in modifiers:
|
||
if m.kind != "ende" or m.end_date is None:
|
||
continue
|
||
hit = ((m.target_type == "category" and r.category_id == m.target_id)
|
||
or (m.target_type == "recurring" and r.id == m.target_id))
|
||
if hit:
|
||
end = m.end_date if end is None else min(end, m.end_date)
|
||
return end
|
||
```
|
||
|
||
In `build_cashflows`:
|
||
|
||
```python
|
||
for r in recurring:
|
||
amount = _modified(r.amount, r.category_id, r.id, modifiers)
|
||
if amount is None:
|
||
continue
|
||
for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
|
||
r.start_date, _effective_end(r, modifiers)):
|
||
flows.append((d, amount))
|
||
```
|
||
|
||
`_modified` braucht KEINE Änderung (`ende` trifft keinen der kind-Zweige und lässt den Betrag unverändert) — der Test aus Step 1 sichert das ab.
|
||
|
||
- [ ] **Step 3: Engine-Tests grün**
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_scenario_engine.py -q` → PASS
|
||
|
||
- [ ] **Step 4: Failing Modell-Test** — in `tests/test_models.py`:
|
||
|
||
```python
|
||
def test_scenario_planned_item_roundtrip(db):
|
||
from app.models.tables import Scenario, ScenarioPlannedItem
|
||
sc = Scenario(name="SPI-Test", description="")
|
||
db.add(sc)
|
||
db.flush()
|
||
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Einmalzahlung",
|
||
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
||
db.commit()
|
||
item = db.query(ScenarioPlannedItem).one()
|
||
assert item.amount == Decimal("5000.00") and item.scenario_id == sc.id
|
||
```
|
||
|
||
Run: → FAIL (`ImportError`)
|
||
|
||
- [ ] **Step 5: Modelle** — `app/models/tables.py`:
|
||
|
||
In `ScenarioModifier` ergänzen:
|
||
|
||
```python
|
||
# Nur fuer kind="ende" gesetzt: Datum, an dem der Ziel-Posten innerhalb
|
||
# dieses Szenarios endet (Ausbaustufe 5).
|
||
end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||
```
|
||
|
||
Neue Klasse (nach `ScenarioModifier`):
|
||
|
||
```python
|
||
class ScenarioPlannedItem(Base):
|
||
"""Einmalzahlung, die NUR in einem bestimmten Szenario zaehlt -
|
||
unabhaengig von Scenario.include_planned (das steuert nur die globalen
|
||
PlannedItems). Ausbaustufe 5."""
|
||
__tablename__ = "scenario_planned_items"
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True)
|
||
name: Mapped[str] = mapped_column(String(200))
|
||
amount: Mapped[Decimal] = mapped_column(MONEY)
|
||
due: Mapped[date] = mapped_column(Date)
|
||
```
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_models.py -q` → PASS
|
||
|
||
- [ ] **Step 6: Alembic-Migration** — Create `alembic/versions/c4d7e2a91b53_szenario_ende.py`:
|
||
|
||
```python
|
||
"""szenario ende + scenario_planned_items
|
||
|
||
Revision ID: c4d7e2a91b53
|
||
Revises: 1ef6a356f028
|
||
Create Date: 2026-07-20
|
||
|
||
"""
|
||
from typing import Sequence, Union
|
||
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
|
||
|
||
revision: str = 'c4d7e2a91b53'
|
||
down_revision: Union[str, Sequence[str], None] = '1ef6a356f028'
|
||
branch_labels: Union[str, Sequence[str], None] = None
|
||
depends_on: Union[str, Sequence[str], None] = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.add_column('scenario_modifiers', sa.Column('end_date', sa.Date(), nullable=True))
|
||
op.create_table(
|
||
'scenario_planned_items',
|
||
sa.Column('id', sa.Integer(), primary_key=True),
|
||
sa.Column('scenario_id', sa.Integer(), sa.ForeignKey('scenarios.id'), nullable=False),
|
||
sa.Column('name', sa.String(length=200), nullable=False),
|
||
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
|
||
sa.Column('due', sa.Date(), nullable=False),
|
||
)
|
||
op.create_index('ix_scenario_planned_items_scenario_id',
|
||
'scenario_planned_items', ['scenario_id'])
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_index('ix_scenario_planned_items_scenario_id', 'scenario_planned_items')
|
||
op.drop_table('scenario_planned_items')
|
||
op.drop_column('scenario_modifiers', 'end_date')
|
||
```
|
||
|
||
- [ ] **Step 7: Migration in Wegwerf-DB verifizieren** (env.py liest `FB_DATABASE_URL`):
|
||
|
||
```bash
|
||
cd /home/wlfb/bin/finance
|
||
FB_DATABASE_URL=sqlite:////tmp/claude-1010/-home-wlfb-bin/42d50545-5cdd-41c9-9ade-aba54af164df/scratchpad/mig_check.sqlite .venv/bin/python -m alembic upgrade head
|
||
FB_DATABASE_URL=sqlite:////tmp/claude-1010/-home-wlfb-bin/42d50545-5cdd-41c9-9ade-aba54af164df/scratchpad/mig_check.sqlite .venv/bin/python -c "
|
||
import sqlalchemy as sa
|
||
e = sa.create_engine('sqlite:////tmp/claude-1010/-home-wlfb-bin/42d50545-5cdd-41c9-9ade-aba54af164df/scratchpad/mig_check.sqlite')
|
||
insp = sa.inspect(e)
|
||
assert 'scenario_planned_items' in insp.get_table_names()
|
||
assert any(c['name'] == 'end_date' for c in insp.get_columns('scenario_modifiers'))
|
||
print('Migration OK')"
|
||
```
|
||
|
||
Expected: `Migration OK`. Danach Wegwerf-Datei löschen.
|
||
|
||
- [ ] **Step 8: Suite grün**
|
||
|
||
Run: `.venv/bin/python -m pytest -q` → PASS
|
||
|
||
- [ ] **Step 9: Fable-Testagent-Abnahme** (Faktencheck: Minimum-Regel, Kategorie-Treffer, Migration up/down konsistent zu `tables.py`, `_modified` unangetastet für `ende`). Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/models/tables.py finance/app/engine/scenario.py finance/alembic/versions/c4d7e2a91b53_szenario_ende.py finance/tests/
|
||
git commit -m "feat: Engine+Schema fuer Szenario-Ende und szenario-eigene Einmalzahlungen"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: API + Projektions-Service — `ende`-Validierung, Szenario-Einmalzahlungen-CRUD
|
||
|
||
**Files:**
|
||
- Modify: `app/routers/scenarios.py`
|
||
- Modify: `app/services/projection_service.py`
|
||
- Modify: `tests/test_crud_api.py`, `tests/test_planning_api.py` (Projektion)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ScenarioPlannedItem`, `ScenarioModifier.end_date`, `PlainModifier(..., end_date)` aus Task 5.
|
||
- Produces: `ModifierIn`/`ModifierOut` mit `kind`-Literal `"ende"` und Feld `end_date: date | None`; Endpunkte `GET/POST /api/scenarios/{id}/planned` (`ScenarioPlannedOut(id, scenario_id, name, amount, due)`), `DELETE /api/scenarios/{id}/planned/{item_id}`. Task 7 (GUI) und Task 9 (Demo) rufen genau diese Endpunkte.
|
||
|
||
- [ ] **Step 1: Failing API-Tests** — in `tests/test_crud_api.py`:
|
||
|
||
```python
|
||
def test_modifier_ende_validierung(client, db):
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "EndeVal"}).json()
|
||
# ende ohne Datum -> 422
|
||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "ende"})
|
||
assert r.status_code == 422
|
||
# ende mit Datum -> 201, end_date in der Antwort
|
||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "ende",
|
||
"end_date": "2026-08-31"})
|
||
assert r.status_code == 201
|
||
assert r.json()["end_date"] == "2026-08-31"
|
||
# andere Arten nullen end_date
|
||
r = client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||
"target_type": "recurring", "target_id": 1, "kind": "remove",
|
||
"end_date": "2026-08-31"})
|
||
assert r.status_code == 201 and r.json()["end_date"] is None
|
||
|
||
|
||
def test_scenario_planned_crud_und_cleanup(client, db):
|
||
sc = client.post("/api/scenarios", headers=H, json={"name": "SPI-CRUD"}).json()
|
||
r = client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||
"name": "Sonderzahlung", "amount": "5000.00", "due": "2026-07-30"})
|
||
assert r.status_code == 201
|
||
item = r.json()
|
||
assert item["scenario_id"] == sc["id"] and item["amount"] == "5000.00"
|
||
assert client.get(f"/api/scenarios/{sc['id']}/planned", headers=H).json()[0]["name"] == "Sonderzahlung"
|
||
# DELETE mit fremder scenario_id -> 404
|
||
other = client.post("/api/scenarios", headers=H, json={"name": "SPI-Other"}).json()
|
||
assert client.delete(f"/api/scenarios/{other['id']}/planned/{item['id']}",
|
||
headers=H).status_code == 404
|
||
# Szenario loeschen raeumt Einmalzahlungen mit ab (kein FK-Fehler):
|
||
assert client.delete(f"/api/scenarios/{sc['id']}", headers=H).status_code == 204
|
||
from app.models.tables import ScenarioPlannedItem
|
||
assert db.query(ScenarioPlannedItem).count() == 0
|
||
```
|
||
|
||
Und in `tests/test_planning_api.py` (Projektion nutzt `_seed_balance`-Muster der Datei):
|
||
|
||
```python
|
||
def test_projektion_mit_ende_modifier_und_szenario_einmalzahlung(client, db):
|
||
_seed_balance(db)
|
||
rec = client.post("/api/recurring", headers=H, json={
|
||
"name": "Miete-Proj", "amount": "-1000.00", "rhythm": "monthly",
|
||
"due_day": 1}).json()
|
||
sc = client.post("/api/scenarios", headers=H,
|
||
json={"name": "BestCase-Test", "include_planned": False}).json()
|
||
client.post(f"/api/scenarios/{sc['id']}/modifiers", headers=H, json={
|
||
"target_type": "recurring", "target_id": rec["id"], "kind": "ende",
|
||
"end_date": "2026-08-31"})
|
||
client.post(f"/api/scenarios/{sc['id']}/planned", headers=H, json={
|
||
"name": "Zufluss", "amount": "5000.00", "due": "2026-07-30"})
|
||
r = client.post(f"/api/scenarios/{sc['id']}/project", headers=H,
|
||
params={"horizon_days": 150, "start_date": "2026-07-15"})
|
||
assert r.status_code == 200
|
||
series = {p["day"]: Decimal(p["balance"]) for p in r.json()["series"]}
|
||
# Start 1000; +5000 am 30.07.; Miete nur noch am 01.08. (ende 31.08.);
|
||
# include_planned=False, aber die SZENARIO-Zahlung zaehlt trotzdem.
|
||
assert series["2026-07-30"] == Decimal("6000.00")
|
||
assert series["2026-08-01"] == Decimal("5000.00")
|
||
assert series["2026-12-01"] == Decimal("5000.00") # keine Miete mehr ab Sept.
|
||
```
|
||
|
||
Run: `.venv/bin/python -m pytest tests/test_crud_api.py tests/test_planning_api.py -q` → neue Tests FAIL
|
||
|
||
- [ ] **Step 2: `ModifierIn` erweitern** — `app/routers/scenarios.py`:
|
||
|
||
Imports: `from pydantic import BaseModel, ConfigDict, model_validator` und `ScenarioPlannedItem` in den `app.models.tables`-Import aufnehmen.
|
||
|
||
```python
|
||
class ModifierIn(BaseModel):
|
||
target_type: Literal["category", "recurring"]
|
||
target_id: int
|
||
kind: Literal["percent", "absolute", "remove", "ende"]
|
||
value: Decimal = Decimal("0")
|
||
end_date: date | None = None
|
||
|
||
@model_validator(mode="after")
|
||
def _ende_braucht_datum(self):
|
||
if self.kind == "ende" and self.end_date is None:
|
||
raise ValueError("Art »Ende« erfordert ein Datum")
|
||
if self.kind != "ende":
|
||
self.end_date = None
|
||
return self
|
||
```
|
||
|
||
(`ModifierOut` erbt `end_date` automatisch; `add_modifier` übergibt es via `**data.model_dump()` bereits.)
|
||
|
||
- [ ] **Step 3: Szenario-Einmalzahlungen-Endpunkte** — `app/routers/scenarios.py` (vor `project_scenario` einfügen):
|
||
|
||
```python
|
||
class ScenarioPlannedIn(BaseModel):
|
||
name: str
|
||
amount: Decimal
|
||
due: date
|
||
|
||
|
||
class ScenarioPlannedOut(ScenarioPlannedIn):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
scenario_id: int
|
||
|
||
|
||
@router.get("/{scenario_id}/planned", response_model=list[ScenarioPlannedOut])
|
||
def list_scenario_planned(scenario_id: int, session: Session = Depends(get_session)):
|
||
_get_scenario(session, scenario_id)
|
||
return [ScenarioPlannedOut.model_validate(p) for p in session.execute(
|
||
select(ScenarioPlannedItem)
|
||
.where(ScenarioPlannedItem.scenario_id == scenario_id)
|
||
.order_by(ScenarioPlannedItem.due)).scalars()]
|
||
|
||
|
||
@router.post("/{scenario_id}/planned", response_model=ScenarioPlannedOut,
|
||
status_code=201)
|
||
def add_scenario_planned(scenario_id: int, data: ScenarioPlannedIn,
|
||
session: Session = Depends(get_session)):
|
||
_get_scenario(session, scenario_id)
|
||
item = ScenarioPlannedItem(scenario_id=scenario_id, **data.model_dump())
|
||
session.add(item)
|
||
session.commit()
|
||
session.refresh(item)
|
||
return ScenarioPlannedOut.model_validate(item)
|
||
|
||
|
||
@router.delete("/{scenario_id}/planned/{item_id}", status_code=204)
|
||
def delete_scenario_planned(scenario_id: int, item_id: int,
|
||
session: Session = Depends(get_session)):
|
||
_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")
|
||
session.delete(item)
|
||
session.commit()
|
||
```
|
||
|
||
In `delete_scenario` zusätzlich (bei den bestehenden Cleanup-Deletes):
|
||
|
||
```python
|
||
session.execute(delete(ScenarioPlannedItem).where(
|
||
ScenarioPlannedItem.scenario_id == scenario_id))
|
||
```
|
||
|
||
- [ ] **Step 4: Projektions-Service** — `app/services/projection_service.py`:
|
||
|
||
`ScenarioPlannedItem` in den `app.models.tables`-Import aufnehmen. Nach dem `planned`-Block:
|
||
|
||
```python
|
||
# Szenario-eigene Einmalzahlungen zaehlen IMMER fuer dieses Szenario -
|
||
# include_planned steuert nur die globalen PlannedItems.
|
||
planned += [PlainPlanned(p.name, Decimal(p.amount), p.due, None)
|
||
for p in session.execute(select(ScenarioPlannedItem).where(
|
||
ScenarioPlannedItem.scenario_id == scenario.id)).scalars()]
|
||
```
|
||
|
||
Modifier-Konstruktion um `end_date` ergänzen:
|
||
|
||
```python
|
||
modifiers = [PlainModifier(m.target_type, m.target_id, m.kind,
|
||
Decimal(m.value), m.end_date)
|
||
for m in session.execute(select(ScenarioModifier).where(
|
||
ScenarioModifier.scenario_id == scenario.id)).scalars()]
|
||
```
|
||
|
||
- [ ] **Step 5: Tests + Suite grün**
|
||
|
||
Run: `.venv/bin/python -m pytest -q` → PASS
|
||
|
||
- [ ] **Step 6: Fable-Testagent-Abnahme** (Faktencheck + Handrechnung der Projektions-Zahlen aus Step 1; 404-Pfade; Cleanup bei delete_scenario). Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/routers/scenarios.py finance/app/services/projection_service.py finance/tests/
|
||
git commit -m "feat: API fuer Szenario-Ende-Modifikator und szenario-eigene Einmalzahlungen"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Szenario-GUI — Art »Ende«, Einmalzahlungen-Fieldset, Hilfe
|
||
|
||
**Files:**
|
||
- Modify: `app/routers/gui.py` (`_scenario_rows`, `planung_page`)
|
||
- Modify: `app/templates/planning.html` (Modifikator-Formular/-Tabelle, neues Fieldset)
|
||
- Modify: `app/templates/hilfe.html`
|
||
- Modify: `tests/test_gui.py`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Endpunkte und Schemata aus Task 6; Filter `|eur`/`|de_label`; `ScenarioPlannedItem`.
|
||
- Produces: Template-Kontext `row.planned_items` je Szenario; `modifier_kinds = ["percent", "absolute", "remove", "ende"]`; JS `onModKindChange(select)`.
|
||
|
||
- [ ] **Step 1: Failing GUI-Test**
|
||
|
||
```python
|
||
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
|
||
```
|
||
|
||
Imports ergänzen: `ScenarioModifier, ScenarioPlannedItem` aus `app.models.tables`.
|
||
|
||
Run: → FAIL
|
||
|
||
- [ ] **Step 2: GUI-Router** — `app/routers/gui.py`: `ScenarioPlannedItem` importieren. In `planung_page`: `"modifier_kinds": ["percent", "absolute", "remove", "ende"],`. In `_scenario_rows` je Szenario ergänzen:
|
||
|
||
```python
|
||
planned_items = session.execute(
|
||
select(ScenarioPlannedItem)
|
||
.where(ScenarioPlannedItem.scenario_id == sc.id)
|
||
.order_by(ScenarioPlannedItem.due)).scalars().all()
|
||
```
|
||
|
||
und `"planned_items": planned_items,` ins `rows.append`-Dict.
|
||
|
||
- [ ] **Step 3: Modifikator-Formular + -Tabelle** — `planning.html`:
|
||
|
||
Tabelle: Wert-Zelle ersetzen durch
|
||
|
||
```html
|
||
<td>
|
||
{% if m.kind == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}
|
||
{% else %}{{ m.value|eur }}{% endif %}
|
||
</td>
|
||
```
|
||
|
||
Formular: Art-Select bekommt `onchange="onModKindChange(this)"`; nach dem Wert-Label:
|
||
|
||
```html
|
||
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
||
<label>Endet am <input type="date" name="end_date" disabled></label>
|
||
```
|
||
|
||
JS (in den `<script>`-Block):
|
||
|
||
```javascript
|
||
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;
|
||
}
|
||
```
|
||
|
||
(disabled-Felder fehlen in der Serialisierung → `value` fällt auf Default 0, `end_date` auf null — passt zur Validierung aus Task 6. UX-Regel eingehalten: Felder bleiben sichtbar, nur `disabled`.)
|
||
|
||
- [ ] **Step 4: Einmalzahlungen-Fieldset** — in `planning.html` im Szenario-Loop nach dem Modifikatoren-`</details>`:
|
||
|
||
```html
|
||
<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>
|
||
```
|
||
|
||
- [ ] **Step 5: Hilfe-Abschnitt** — `hilfe.html`, im Szenarien-`<li>` (Zeile ~68) ergänzen (generisch, KEINE echten Daten):
|
||
|
||
```html
|
||
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.
|
||
```
|
||
|
||
- [ ] **Step 6: Suite grün**
|
||
|
||
Run: `.venv/bin/python -m pytest -q` → PASS
|
||
|
||
- [ ] **Step 7: Fable-Testagent-Abnahme** — Live-Test (uvicorn-Teststart, Muster Task 2): Modifikator »Ende« mit Datumsfeld-Umschaltung anlegen, Einmalzahlung anlegen/löschen, Szenario durchrechnen, Ergebnis plausibel. Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add finance/app/routers/gui.py finance/app/templates/ finance/tests/test_gui.py
|
||
git commit -m "feat: Szenario-GUI mit Ende-Modifikator und Einmalzahlungen je Szenario"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Release v0.6.0 — Version, Redeploy, Live-Smoke
|
||
|
||
**Files:**
|
||
- Modify: `finance/VERSION` (`0.5.0` → `0.6.0`)
|
||
- Modify: `.superpowers/sdd/progress.md` (Ledger, unter `/home/wlfb/bin`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Tasks 1–7 committet, Suite grün.
|
||
- Produces: laufender Pod v0.6.0 mit angewendeter Migration `c4d7e2a91b53` — Voraussetzung für Task 9.
|
||
|
||
- [ ] **Step 1: Gesamte Suite final**
|
||
|
||
Run: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q`
|
||
Expected: alle Tests passed, 0 failed
|
||
|
||
- [ ] **Step 2: Version hochzählen**
|
||
|
||
```bash
|
||
echo "0.6.0" > /home/wlfb/bin/finance/VERSION
|
||
cd /home/wlfb/bin && git add finance/VERSION && git commit -m "chore: Version 0.6.0"
|
||
```
|
||
|
||
- [ ] **Step 3: Redeploy** (baut Image mit Tag 0.6.0, Entrypoint führt `alembic upgrade head` aus):
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && ./create_pod_finance.sh
|
||
```
|
||
|
||
Expected: Skript endet erfolgreich, Service aktiv.
|
||
|
||
- [ ] **Step 4: Live-Smoke**
|
||
|
||
```bash
|
||
systemctl --user is-active pod-finance_pod.service # -> active
|
||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8096/login # -> 200
|
||
curl -s http://127.0.0.1:8096/api/version # -> {"version":"0.6.0"}
|
||
podman exec finance-db_ctr psql -U finance -d finance -c "\d scenario_planned_items" | head -5
|
||
podman exec finance-db_ctr psql -U finance -d finance -c "SELECT column_name FROM information_schema.columns WHERE table_name='scenario_modifiers' AND column_name='end_date';"
|
||
```
|
||
|
||
Expected: Tabelle existiert, Spalte `end_date` vorhanden. (DB-Name/User ggf. aus `~/.local/share/finance_pod/.env` ablesen, falls abweichend.)
|
||
|
||
- [ ] **Step 5: Fable-Testagent-Abnahme** — Live: Login in die GUI, `/planung` rendert deutsche Formate, ein Test-Fixposten mit Komma-Eingabe anlegen und wieder löschen, Migration-Status wie Step 4. Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 6: Ledger + Push**
|
||
|
||
`.superpowers/sdd/progress.md`: je Task 1–8 eine Zeile (Commit-Range, Fable-Befund) im Stil der bestehenden Einträge. Dann:
|
||
|
||
```bash
|
||
cd /home/wlfb/bin && git add .superpowers/sdd/progress.md docs/superpowers/plans/2026-07-20-ausbaustufe-5.md && git commit -m "docs: Ledger Ausbaustufe 5 + Plan-Haekchen" && git push
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Demo »Best Case« live anlegen + Nutzer-Erklärung (kein Code)
|
||
|
||
**Files:** keine Repo-Änderungen (nur Live-DB + Chat). **DATENSCHUTZ:** Die konkreten Empfänger/Beträge/Termine stammen aus dem Nutzer-Chat vom 2026-07-20 — sie dürfen in Live-DB und Chat-Antwort auftauchen, aber NICHT in Commits/Ledger/Doku (Ledger-Eintrag nur generisch: »Demo-Szenario live angelegt und durchgerechnet«).
|
||
|
||
**Interfaces:**
|
||
- Consumes: deployte v0.6.0 (Task 8); `POST /api/scenarios`, `POST /api/scenarios/{id}/modifiers` (kind `ende`), `POST /api/scenarios/{id}/planned`, `POST /api/scenarios/{id}/project`; GUI-Login via `FB_PASSWORD` aus `~/.local/share/finance_pod/.env`.
|
||
|
||
- [ ] **Step 1: Bestehende Fixposten prüfen** — per API (`FB_API_KEY` aus der `.env`; Header `Authorization: Bearer <key>`): `GET http://127.0.0.1:8096/api/recurring` und mit den zwei im Chat genannten Mietposten abgleichen (Empfänger + Betrag). Fehlen sie: über `GET /api/recurring/suggestions` prüfen, ob sie als Vorschlag erkannt sind, und mit den Chat-Daten als monatliche Fixposten anlegen (`POST /api/recurring`, negativer Betrag, `due_day` aus den echten Buchungen der Vorschläge).
|
||
|
||
- [ ] **Step 2: Szenario »Best Case« anlegen** — `POST /api/scenarios` mit `{"name": "Best Case", "description": "<kurze deutsche Beschreibung>"}` (Standard-Flags belassen).
|
||
|
||
- [ ] **Step 3: Die zwei »Ende«-Modifikatoren setzen** — je Mietposten `POST /api/scenarios/{id}/modifiers` mit `{"target_type": "recurring", "target_id": <fixposten-id>, "kind": "ende", "end_date": "<termin aus dem Chat>"}`.
|
||
|
||
- [ ] **Step 4: Szenario-Einmalzahlung anlegen** — `POST /api/scenarios/{id}/planned` mit Name/Betrag/Datum aus dem Chat (positiver Betrag = Zufluss).
|
||
|
||
- [ ] **Step 5: Durchrechnen + Plausibilisierung** — `POST /api/scenarios/{id}/project`; prüfen, dass die Serie die Einmalzahlung am Stichtag springt und die beiden Mietabflüsse nach ihren Endterminen fehlen (Vergleich mit einem Basis-Szenario ohne Modifikatoren, falls vorhanden).
|
||
|
||
- [ ] **Step 6: Fable-Testagent-Abnahme** — Live-Faktencheck der Szenario-Einträge und der Projektion (Handrechnung der Sprünge). Erst nach VERIFIED weiter.
|
||
|
||
- [ ] **Step 7: Nutzer-Erklärung im Chat** — Schritt-für-Schritt beschreiben, wie man die Eingabe in der GUI macht (Planung → Szenario anlegen → Modifikatoren-Details → Art »Ende« wählen → Datum; Einmalzahlungen-Details → Betrag mit Komma eingeben), plus Ergebnis-Zusammenfassung (Tiefpunkt, Verlauf) und Grafana-Hinweis. Ledger-Eintrag (generisch) ergänzen und committen.
|
||
|
||
---
|
||
|
||
## Self-Review (durchgeführt beim Planschreiben)
|
||
|
||
- **Spec-Abdeckung:** Teil 1 → Tasks 1–2; Teil 2 → Tasks 3–4; Teil 3 → Tasks 5–7; Teil 4 (Demo/Release/Hilfe) → Tasks 7 (Hilfe), 8, 9. Migration-Test (Spec) → Task 5 Step 7. Keine Lücken gefunden.
|
||
- **Platzhalter:** keine; alle Steps enthalten konkreten Code/Kommandos.
|
||
- **Typ-Konsistenz:** `PlainModifier(..., end_date=None)` (Task 5) = Nutzung in Task 6; `ScenarioPlannedOut(id, scenario_id, name, amount, due)` (Task 6) = GUI-Kontext `row.planned_items` (Task 7); Modifikator-Art-String überall `"ende"`; `toggleEdit`-IDs `*-row-`/`*-edit-` konsistent (Szenario-Kopf nutzt `sc-row-{id}`, siehe Task 4 Step 6).
|