Compare commits
21 Commits
f1b5d442bd
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
| 8eee62ee19 | |||
| 7ca64c91fe | |||
| aff8e9b28d | |||
| a520390e31 | |||
| 220d1fc802 | |||
| bf3f56118e | |||
| 28d1267f0c | |||
| e002d8205f | |||
| 353ff6056c | |||
| d6659d8b18 | |||
| 4431640eb2 | |||
| b21d7b9839 | |||
| f03249bff1 | |||
| 7b273e218c | |||
| 974ef5c208 | |||
| 421899b922 | |||
| cb44194daf | |||
| 4843f2e130 | |||
| 1708abeb91 | |||
| 39c6a65b1d | |||
| 2282aa3922 |
@@ -1188,19 +1188,19 @@ cd /home/wlfb/bin && git add .superpowers/sdd/progress.md docs/superpowers/plans
|
|||||||
**Interfaces:**
|
**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`.
|
- 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).
|
- [x] **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).
|
- [x] **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>"}`.
|
- [x] **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).
|
- [x] **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).
|
- [x] **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.
|
- [x] **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.
|
- [x] **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
376
docs/superpowers/plans/2026-07-20-ausbaustufe-7.md
Normal file
376
docs/superpowers/plans/2026-07-20-ausbaustufe-7.md
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
# Ausbaustufe 7 Implementation Plan — Szenarien-Seite, Formular-UX, Kategorien (v0.8.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:** Szenarien auf eigene Seite `/szenarien`, verständliches Eintrags-Formular (Paar-Layout, Gesperrt-Kennzeichnung, dynamisches Wert-Label, abgesetzter Durchrechnen-Button), Kategorien-Verwaltung auf der Admin-Seite; Release v0.8.0.
|
||||||
|
|
||||||
|
**Architecture:** Kein Datenmodell-/Migrationsbedarf. Template-Umzug der Szenarien-Sektion aus `planning.html` in neues `szenarien.html` + Route in `gui.py`; CSS-Regeln in `style.css`; ein neuer Endpunkt `PATCH /api/categories/{id}`.
|
||||||
|
|
||||||
|
**Tech Stack:** FastAPI, Jinja2, htmx + json-form, CSS, pytest.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-20-szenarien-seite-ux-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- GUI deutsch, TT.MM.JJJJ, `|eur`; API-Werte englisch; Geldbeträge `Decimal`.
|
||||||
|
- UX-Regel: gesperrte Felder sichtbar + `disabled` + optisch gekennzeichnet; Edit-Zeilen per `hidden`.
|
||||||
|
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
|
||||||
|
- DATENSCHUTZ: keine echten Kontodaten in Commits/Tests/Doku.
|
||||||
|
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 181 passed, muss grün bleiben.
|
||||||
|
- Pfade relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Szenarien-Seite `/szenarien`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/templates/szenarien.html`
|
||||||
|
- Modify: `app/templates/planning.html` (Szenarien-`<section>` Zeilen ~263-464 und szenario-spezifisches JS entfernen), `app/templates/base.html` (Nav + `toggleEdit`), `app/routers/gui.py` (neue Route, `planung_page` verschlanken), `app/templates/hilfe.html` (Ortsangaben)
|
||||||
|
- Modify: `tests/test_gui.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: bestehenden Szenario-Kontext (`_scenario_rows`, `modifier_kinds`), `gui_session`, alle bestehenden Templates/JS-Helfer.
|
||||||
|
- Produces: `GET /szenarien` (login-geschützt) mit Kontext `scenario_rows`, `loans`, `categories`, `category_names`, `recurring`, `recurring_names`, `modifier_kinds`; `toggleEdit` global in `base.html`. Task 2 baut auf `szenarien.html` auf.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing GUI-Tests** — in `tests/test_gui.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_szenarien_seite_und_nav(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(Scenario(name="Seiten-Test", description="d"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/szenarien")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
|
||||||
|
assert 'href="/szenarien"' in r.text # Nav-Punkt
|
||||||
|
# Planung enthaelt keine Szenarien-Sektion mehr:
|
||||||
|
p = client.get("/planung").text
|
||||||
|
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
|
||||||
|
assert "Wiederkehrende Posten" in p and "Kredite" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenarien_seite_braucht_login(client):
|
||||||
|
r = client.get("/szenarien", follow_redirects=False)
|
||||||
|
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → neue Tests FAIL (404)
|
||||||
|
|
||||||
|
- [x] **Step 2: Route** — `app/routers/gui.py`: in `planung_page` die Zeilen `scenarios = _list_scenarios(...)`, `"scenario_rows": ...` und `"modifier_kinds": ...` entfernen (die übrigen Kontexteinträge bleiben). Neue Route direkt darunter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/szenarien", dependencies=[Depends(gui_session)])
|
||||||
|
def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
||||||
|
categories = session.execute(select(Category)).scalars().all()
|
||||||
|
recurring = _list_recurring(session=session)
|
||||||
|
loans = _list_loans(session=session)
|
||||||
|
scenarios = _list_scenarios(session=session)
|
||||||
|
return templates.TemplateResponse(request, "szenarien.html", {
|
||||||
|
"categories": categories,
|
||||||
|
"category_names": {c.id: c.name for c in categories},
|
||||||
|
"recurring": recurring,
|
||||||
|
"recurring_names": {r.id: r.name for r in recurring},
|
||||||
|
"loans": loans,
|
||||||
|
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||||
|
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Template-Umzug** — Neues `app/templates/szenarien.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Szenarien – Finanzberatung{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Szenarien</h1>
|
||||||
|
|
||||||
|
<section class="planning-section">
|
||||||
|
<!-- HIER: die komplette bisherige Szenarien-<section>-INNEREI aus
|
||||||
|
planning.html unverändert einfügen (alles zwischen
|
||||||
|
'<h2>Szenarien</h2>' … einschließlich des Fieldsets
|
||||||
|
'Neues Szenario anlegen'), ohne das umschließende <section>-Tag
|
||||||
|
doppelt zu setzen. -->
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Szenario-spezifische Helfer (aus planning.html hierher umgezogen):
|
||||||
|
// onModTargetTypeChange, onEntryArtChange, toggleScenarioLoan
|
||||||
|
// unverändert einfügen.
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Der `<h2>Szenarien</h2>` entfällt (die Seite hat die `<h1>`-Überschrift). In `planning.html`: die gesamte Szenarien-`<section>` löschen; aus dem Script-Block `onModTargetTypeChange`, `onEntryArtChange`, `toggleScenarioLoan` und `toggleEdit` entfernen — `toggleEdit` zieht in den zentralen Script-Block von `base.html` (dort nach der json-form-Extension einfügen, unverändert inkl. Kommentar); `loadLoanSchedule`/`fmtEur`/`formatIsoDate` und `loadedSchedules` bleiben in `planning.html`.
|
||||||
|
|
||||||
|
- [x] **Step 4: Nav + Hilfe** — `base.html`: `<a href="/szenarien">Szenarien</a>` zwischen Planung- und Admin-Link. `hilfe.html`: Formulierungen, die die Szenarien auf der Planung-Seite verorten, auf „Szenarien-Seite" anpassen (grep nach „Szenari" in `hilfe.html` und Ortsangaben prüfen).
|
||||||
|
|
||||||
|
- [x] **Step 5: Bestehende Tests umziehen** — `tests/test_gui.py`: die Szenario-GUI-Tests (`test_szenario_gui_ende_und_einmalzahlungen`, `test_szenario_eintraege_tabelle`, Szenario-Anteile von `test_planung_hat_bearbeiten_formulare`) von `client.get("/planung")` auf `client.get("/szenarien")` umstellen. In `test_planung_hat_bearbeiten_formulare` den Scenario-Teil (Scenario-Seed + `hx-patch="/api/scenarios/`-Assertion) in einen neuen Test `test_szenarien_hat_bearbeiten_formular` auslagern, der `/szenarien` lädt; der Planung-Test behält rec/pln/loan mit `>= 3` Bearbeiten-Buttons. `test_pages_require_login`/`test_pages_render_after_login`: Pfad `/szenarien` in die jeweilige Pfadliste aufnehmen.
|
||||||
|
|
||||||
|
- [x] **Step 6: Suite grün**
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest -q` → PASS
|
||||||
|
|
||||||
|
- [x] **Step 7: Fable-Testagent-Abnahme** (Live-Approximation: beide Seiten rendern; keine Szenario-Reste auf /planung; `toggleEdit` genau EINMAL definiert [base.html], auf beiden Seiten funktionale Edit-Formulare; keine JS-Referenzen auf entfernte Funktionen in planning.html; Nav auf allen Seiten). Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wlfb/bin && git add finance/app/ finance/tests/test_gui.py
|
||||||
|
git commit -m "feat: eigene Szenarien-Seite /szenarien"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Formular-UX — Paar-Layout, Gesperrt-Kennzeichnung, klare Struktur
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/static/style.css`, `app/templates/szenarien.html`, `tests/test_gui.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `szenarien.html` aus Task 1 (Neuer-Eintrag-Formular mit Feldern kind/target_type/target_id/value/end_date/name/amount/due; `onEntryArtChange`).
|
||||||
|
- Produces: CSS-Klassen `.entry-form`, `.entry-art`, `.value-label`; dynamisches Wert-Label in `onEntryArtChange`; abgesetzter Durchrechnen-Block.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing GUI-Test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_neuer_eintrag_formular_struktur(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(Scenario(name="UX-Test", description=""))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/szenarien").text
|
||||||
|
assert 'class="entry-form"' in r
|
||||||
|
assert 'class="entry-art"' in r # Eintragsart-Zeile
|
||||||
|
assert 'class="value-label"' in r # dynamisches Wert-Label
|
||||||
|
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
||||||
|
assert '<hr' in r # Durchrechnen abgesetzt
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: → FAIL
|
||||||
|
|
||||||
|
- [x] **Step 2: CSS** — `app/static/style.css` ergänzen:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
|
||||||
|
.entry-form label,
|
||||||
|
.planning-section form label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0 1.25rem 0.6rem 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.entry-form .entry-art {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.entry-form .entry-art > label { font-weight: bold; }
|
||||||
|
.entry-form .entry-art .muted { margin-left: 0.5rem; }
|
||||||
|
input:disabled, select:disabled {
|
||||||
|
background: #e3e3e3;
|
||||||
|
color: #8a8a8a;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
label:has(input:disabled), label:has(select:disabled) {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.project-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Falls `style.css` bereits eine kollidierende `label`-Regel für `.planning-section` hat: die neue Regel dahinter einsortieren, Spezifität prüfen.)
|
||||||
|
|
||||||
|
- [x] **Step 3: Formular-Markup** — in `szenarien.html` das Neuer-Eintrag-Formular umbauen:
|
||||||
|
- `<form … class="entry-form" …>` (Attribute inkl. `data-modifiers-url`/`data-planned-url` unverändert).
|
||||||
|
- Statt `<strong>Neuer Eintrag:</strong>`: erste Zeile
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="entry-art">
|
||||||
|
<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="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Wert-Label dynamisch: `<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0" title="Für diese Eintragsart nicht relevant"></label>` (Startzustand passend zur Default-Art `percent`).
|
||||||
|
- Die umschaltbaren Felder `end_date`, `name`, `amount`, `due` sowie die beiden Ziel-Selects bekommen `title="Für diese Eintragsart nicht relevant"`.
|
||||||
|
- Fieldset-Titel davor: `<h3>Einträge</h3>`-Tabelle bleibt; über dem Formular `<h4>Neuer Eintrag</h4>` (ersetzt das bisherige Inline-`<strong>`).
|
||||||
|
- Durchrechnen-Formular in einen abgesetzten Block:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<hr>
|
||||||
|
<div class="project-block">
|
||||||
|
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<button type="submit">Durchrechnen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Dynamisches Wert-Label** — in `onEntryArtChange` (szenarien.html) nach der `setDisabled('value', …)`-Zeile ergänzen:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var valueLabel = form.querySelector('.value-label');
|
||||||
|
if (valueLabel) {
|
||||||
|
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
|
||||||
|
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Suite grün** — `.venv/bin/python -m pytest -q` → PASS
|
||||||
|
|
||||||
|
- [x] **Step 6: Fable-Testagent-Abnahme** — Live-Approximation + Handtrace: gerenderte Struktur (entry-art zuerst, Paare als `inline-flex`-Einheiten via CSS-Regeln vorhanden, Tooltips an allen Umschaltfeldern, `<hr>`+project-block), Wert-Label-Wechsel für alle 5 Arten, Startzustand konsistent (percent: Wert aktiv mit Label „Prozentsatz (%)"). Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wlfb/bin && git add finance/app/static/style.css finance/app/templates/szenarien.html finance/tests/test_gui.py
|
||||||
|
git commit -m "feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Kategorien-Verwaltung auf der Admin-Seite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/routers/categories.py` (PATCH), `app/routers/admin.py` (Kontext), `app/templates/admin.html`
|
||||||
|
- Modify: `tests/test_crud_api.py`, `tests/test_admin.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `CategoryIn`/`CategoryOut`, `POST /api/categories` (vorhanden); `toggleEdit` (seit Task 1 global in base.html); json-form.
|
||||||
|
- Produces: `PATCH /api/categories/{category_id}` (404 „Kategorie nicht gefunden", 409 „Kategorie existiert bereits"); Admin-Abschnitt „Kategorien" mit IDs `cat-row-{id}`/`cat-edit-{id}`.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing API-Test** — `tests/test_crud_api.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_category_patch(client):
|
||||||
|
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
|
||||||
|
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
|
||||||
|
# Umbenennen
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||||
|
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
|
||||||
|
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Kollision mit anderer Kategorie -> 409
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
# unbekannte id -> 404
|
||||||
|
assert client.patch("/api/categories/99999", headers=H,
|
||||||
|
json={"name": "x"}).status_code == 404
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → FAIL (405)
|
||||||
|
|
||||||
|
- [x] **Step 2: PATCH-Endpunkt** — `app/routers/categories.py`, nach `create_category`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||||
|
def patch_category(category_id: int, data: CategoryIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
cat = session.get(Category, category_id)
|
||||||
|
if cat is None:
|
||||||
|
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||||
|
clash = session.execute(
|
||||||
|
select(Category).where(Category.name == data.name)).scalar()
|
||||||
|
if clash is not None and clash.id != category_id:
|
||||||
|
raise HTTPException(409, "Kategorie existiert bereits")
|
||||||
|
cat.name = data.name
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cat)
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Failing GUI-Test** — `tests/test_admin.py` (Muster für Login dort übernehmen; falls die Datei GUI-Zugriffe anders aufbaut, an bestehende Fixtures anlehnen):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_admin_zeigt_kategorien_verwaltung(client, db):
|
||||||
|
from app.models.tables import Category
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(Category(name="Admin-Kat"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/admin").text
|
||||||
|
assert "Kategorien" in r and "Admin-Kat" in r
|
||||||
|
assert "Neue Kategorie anlegen" in r
|
||||||
|
assert 'hx-patch="/api/categories/' in r
|
||||||
|
assert 'hx-post="/api/categories"' in r
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: → FAIL
|
||||||
|
|
||||||
|
- [x] **Step 4: Admin-Kontext + Template** — `app/routers/admin.py`: in `admin_page` (und im Fehler-/Erfolgs-Re-Render von `admin_change_password`, damit der Abschnitt nie verschwindet — UX-Regel) `"categories": session.execute(select(Category)).scalars().all()` in den Template-Kontext aufnehmen (Imports `select`, `Category` ergänzen; `session`-Dependency, falls die Route noch keine hat). `admin.html`, neuer Abschnitt vor dem `<script>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Kategorien</h2>
|
||||||
|
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
|
||||||
|
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in categories %}
|
||||||
|
<tr id="cat-row-{{ c.id }}">
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr id="cat-edit-{{ c.id }}" hidden>
|
||||||
|
<td colspan="2">
|
||||||
|
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Neue Kategorie anlegen</legend>
|
||||||
|
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" required maxlength="100"></label>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 5: Suite grün** — `.venv/bin/python -m pytest -q` → PASS
|
||||||
|
|
||||||
|
- [x] **Step 6: Fable-Testagent-Abnahme** (Live-Approximation: Anlegen → erscheint in Liste UND in den Kategorie-Dropdowns von Planung/Szenarien; Umbenennen-Roundtrip; 409 im Alert-Pfad; Admin-Fehler-Re-Render zeigt Abschnitt weiterhin). Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wlfb/bin && git add finance/app/routers/categories.py finance/app/routers/admin.py finance/app/templates/admin.html finance/tests/
|
||||||
|
git commit -m "feat: Kategorien-Verwaltung auf der Admin-Seite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Release v0.8.0 — Redeploy, Live-Check, Ledger
|
||||||
|
|
||||||
|
**Files:** `finance/VERSION` (`0.7.0` → `0.8.0`), `.superpowers/sdd/progress.md`, Plan-Häkchen.
|
||||||
|
|
||||||
|
- [x] **Step 1: Suite final** — `.venv/bin/python -m pytest -q` → alle grün.
|
||||||
|
- [x] **Step 2: Version + Commit** — `echo "0.8.0" > finance/VERSION`, Commit `chore: Version 0.8.0`.
|
||||||
|
- [x] **Step 3: Redeploy** — `./create_pod_finance.sh` (keine Migration). Service aktiv, Readiness 200.
|
||||||
|
- [x] **Step 4: Live-Smoke** — `/api/version` == 0.8.0; Nav zeigt „Szenarien"; `/szenarien` zeigt Best Case mit Einträgen; `/planung` ohne Szenarien; `/admin` mit Kategorien-Abschnitt.
|
||||||
|
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: Wegwerf-Kategorie „SMOKE-A7" anlegen → umbenennen → in Dropdowns sichtbar (bleibt stehen, Hinweis im Bericht — Kategorien sind nicht löschbar; Namenswahl „zz-Smoke" damit sie unten einsortiert? Nein: Kategorie „SMOKE-A7" wird nach dem Test per direktem psql-DELETE entfernt, NUR wenn keine FK-Referenzen existieren — vorher COUNT-Checks auf transactions/category_rules/recurring_items/scenario_modifiers); Best-Case-Einträge unverändert; Formular-Struktur auf /szenarien (entry-art, Tooltips, hr). Erst nach VERIFIED weiter.
|
||||||
|
- [x] **Step 6: Ledger + Plan-Häkchen + Push.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (beim Planschreiben)
|
||||||
|
|
||||||
|
- **Spec-Abdeckung:** Seite → Task 1; UX/CSS/Label/Durchrechnen → Task 2; Kategorien (PATCH + Admin-GUI) → Task 3; Release → Task 4. Vollständig.
|
||||||
|
- **Platzhalter:** Der Template-Umzug in Task 1 Step 3 ist bewusst als präziser Move beschrieben (Quelle: bestehende Szenarien-Sektion) statt als 200-Zeilen-Duplikat — die Quelle ist eindeutig benannt und unverändert zu übernehmen.
|
||||||
|
- **Typ-Konsistenz:** `toggleEdit` global (Task 1) wird von Task 3 (`cat-`-Präfix) vorausgesetzt; `entry-form`/`entry-art`/`value-label`-Klassen konsistent zwischen CSS (Task 2 Step 2) und Markup (Step 3) und Test (Step 1).
|
||||||
196
docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
Normal file
196
docs/superpowers/plans/2026-07-20-ausbaustufe-9.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Ausbaustufe 9 Implementation Plan — Vorschlags-Algorithmus v2 (v0.9.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:** `suggest_recurring` erkennt monatliche/vierteljährliche/jährliche Serien mit letztem Betrag, Aktiv-Check, Betrags-Clustern, Umfirmierungs-Merge und robustem Bestandsabgleich; GUI zeigt Rhythmus/Start/Hinweis; Release v0.9.0 mit Live-Gate gegen die echten Daten.
|
||||||
|
|
||||||
|
**Architecture:** Vollständiger Rewrite von `app/services/suggestions.py` (reine Session-in/dict-out-Funktion, Parameter als Modul-Konstanten); `SuggestionOut`-Erweiterung in `routers/planning.py`; Template-Anpassung der Vorschlags-Tabelle. Kein Datenmodell-/Migrationsbedarf.
|
||||||
|
|
||||||
|
**Tech Stack:** SQLAlchemy 2, Pydantic v2, Jinja2, pytest (synthetische Daten).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md` — die dortigen Abschnitte „Algorithmus" (8 Schritte, Konstanten) und „Tests" sind bindend und Teil dieses Plans.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Beträge `Decimal` (keine float-Arithmetik, auch nicht in Toleranzvergleichen — relative Differenzen als `Decimal`-Quotienten).
|
||||||
|
- `date.today()` nur an EINER Stelle (Parameter `today: date | None = None` der Hauptfunktion, Default heute) — Tests injizieren ein festes Datum.
|
||||||
|
- Tests ausschließlich mit synthetischen Daten (DATENSCHUTZ: keine echten Namen/Beträge aus der Live-DB in Tests/Commits).
|
||||||
|
- GUI deutsch, TT.MM.JJJJ, `|eur`, `|de_label`; API Punkt-Dezimal.
|
||||||
|
- Fable-Testagent-Gate je Task VOR Commit; Ledger-Eintrag je Task.
|
||||||
|
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 187 passed, muss grün bleiben (drei bestehende Suggestion-Tests DÜRFEN an die neue Semantik angepasst werden, siehe Task 1 Step 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Algorithmus-Rewrite + API-Schema
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `app/services/suggestions.py`
|
||||||
|
- Modify: `app/routers/planning.py` (`SuggestionOut`)
|
||||||
|
- Modify: `tests/test_planning_api.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `suggest_recurring(session, today: date | None = None) -> list[dict]` mit Keys `name, amount (Decimal), rhythm, due_day, start_date (date|None), category_id, hinweis (str)`; `SuggestionOut` mit denselben Feldern (`start_date: date | None = None`, `hinweis: str = ""`). Task 2 rendert genau diese Felder.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing Tests** — in `tests/test_planning_api.py` die drei bestehenden Suggestion-Tests ERSETZEN/ERWEITERN durch die Spec-Fälle (Helper zum Anlegen synthetischer Buchungen schreiben; `dedup_hash` eindeutig, `status="confirmed"`; ein Account genügt; `today=date(2026, 7, 20)` in alle Aufrufe injizieren):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||||||
|
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
|
||||||
|
purpose="p", counterparty=cp, category_id=cat,
|
||||||
|
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
|
||||||
|
|
||||||
|
TODAY = date(2026, 7, 20)
|
||||||
|
|
||||||
|
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||||||
|
acc = _acc(db) # Helper: Account anlegen, gibt id zurueck
|
||||||
|
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
|
||||||
|
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
|
||||||
|
(date(2026, 7, 1), "-202.94")]:
|
||||||
|
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
s = out[0]
|
||||||
|
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
|
||||||
|
assert s["due_day"] == 1 and s["start_date"] is None
|
||||||
|
|
||||||
|
def test_suggest_quartal_mit_phase(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
|
||||||
|
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["rhythm"] == "quarterly"
|
||||||
|
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
|
||||||
|
|
||||||
|
def test_suggest_jahr_mit_zwei_belegen(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
|
||||||
|
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
|
||||||
|
assert out[0]["start_date"] == date(2026, 6, 16)
|
||||||
|
assert "409.92" in out[0]["hinweis"] # Betrag zuletzt gestiegen
|
||||||
|
|
||||||
|
def test_suggest_tote_serie_kein_vorschlag(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for m in (9, 10, 11, 12):
|
||||||
|
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
|
||||||
|
db.commit()
|
||||||
|
assert suggest_recurring(db, today=TODAY) == []
|
||||||
|
|
||||||
|
def test_suggest_umfirmierung_merge(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for m in (11, 12):
|
||||||
|
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
|
||||||
|
for m in (1, 2, 3):
|
||||||
|
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
|
||||||
|
for m in (4, 5, 6, 7):
|
||||||
|
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
|
||||||
|
|
||||||
|
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
|
||||||
|
rhythm="monthly", due_day=1))
|
||||||
|
for m in (4, 5, 6, 7):
|
||||||
|
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
|
||||||
|
db.commit()
|
||||||
|
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
|
||||||
|
|
||||||
|
def test_suggest_zwei_vertraege_getrennt(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for m in (4, 5, 6, 7):
|
||||||
|
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
|
||||||
|
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 2
|
||||||
|
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`_acc`-Helper analog bestehender Tests; `RecurringItem`/`Transaction`-Importe existieren.) Die drei Alt-Tests (`three_consecutive_months_with_year_wrap`, `two_months_no_suggestion`, `excludes_existing_recurring_item`) an die neue Signatur/Semantik anpassen: feste `today`-Injektion; Daten ggf. ins Fenster schieben; der Exclusion-Test bleibt inhaltlich gültig (Name-Match).
|
||||||
|
|
||||||
|
Run: `.venv/bin/python -m pytest tests/test_planning_api.py -q` → neue Tests FAIL
|
||||||
|
|
||||||
|
- [x] **Step 2: Rewrite `app/services/suggestions.py`** gemäß Spec-Abschnitt „Algorithmus" (8 Schritte, Konstanten `WINDOW_DAYS=460`, Rhythmus-Tabelle monthly 25-36/≥3, quarterly 80-105/≥3, yearly 330-400/≥2, `STEP_DAYS={"monthly":30,"quarterly":91,"yearly":365}`, `ACTIVITY_FACTOR` 7/4 als `Fraction` oder Tage-Vergleich ganzzahlig, Cluster 35 %, Merge 25 %, Bestand 10 % — alle Toleranzvergleiche als `Decimal`). Struktur: `_norm`, `_rel_diff`, `_amount_clusters` (greedy gegen letztes Mitglied, gleiches Vorzeichen), `_classify` (Median der Abstände), Merge-Pass je Konto über alle Serien, `_covered_by_existing`, Hauptfunktion `suggest_recurring(session, today=None)`. Deutsche Docstrings/Kommentare zur Begründung der Toleranzen.
|
||||||
|
|
||||||
|
- [x] **Step 3: `SuggestionOut` erweitern** — `routers/planning.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SuggestionOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
amount: Decimal
|
||||||
|
rhythm: str
|
||||||
|
due_day: int
|
||||||
|
start_date: date | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
hinweis: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 4: Tests + Suite grün** — `.venv/bin/python -m pytest -q` → PASS (Alt-Test-Anpassungen im Report begründen).
|
||||||
|
|
||||||
|
- [x] **Step 5: Fable-Testagent-Abnahme** (Faktencheck: Toleranz-Arithmetik Decimal-rein; Aktiv-Check-Grenzen; Merge-Bedingungen; keine `date.today()`-Streuung; Alt-Test-Anpassungen berechtigt). Erst nach VERIFIED weiter.
|
||||||
|
|
||||||
|
- [x] **Step 6: Commit** — `git add finance/app/services/suggestions.py finance/app/routers/planning.py finance/tests/test_planning_api.py && git commit -m "feat: Vorschlags-Algorithmus v2 (Rhythmen, letzter Betrag, Aktiv-Check, Merge)"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: GUI — Rhythmus/Start/Hinweis in der Vorschlags-Tabelle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/templates/planning.html` (Fieldset „Vorschläge aus Buchungen")
|
||||||
|
- Modify: `tests/test_gui.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SuggestionOut`-Felder aus Task 1; Filter `|eur`/`|de_label`.
|
||||||
|
|
||||||
|
- [x] **Step 1: Failing GUI-Test** — in `tests/test_gui.py` (synthetische Serie seeden, `/planung` laden):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
|
||||||
|
db.add(acc)
|
||||||
|
db.flush()
|
||||||
|
for d in (date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)):
|
||||||
|
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
|
||||||
|
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
|
||||||
|
dedup_hash=f"sug-{d}"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/planung").text
|
||||||
|
assert "vierteljährlich" in r # de_label des Rhythmus
|
||||||
|
assert "15.06.2026" in r # Start-Spalte TT.MM.JJJJ
|
||||||
|
assert 'name="start_date"' in r # hidden input der Uebernahme
|
||||||
|
```
|
||||||
|
|
||||||
|
WICHTIG: Der Test hängt von `date.today()` der App ab (Aktiv-Check!) — Serie so legen, dass sie um den echten Testlauf-Zeitpunkt herum aktiv ist, oder (besser) `suggest_recurring` in `planung_page` unverändert lassen und den Test mit relativen Daten um `date.today()` konstruieren (letzte Buchung ≤ 45 Tage vor heute, Quartalsschritte rückwärts). Die Variante mit relativen Daten umsetzen; die obigen Fixdaten sind als Muster zu verstehen und auf `date.today()`-relative Werte umzustellen (inkl. erwartetem Start-String via `.strftime('%d.%m.%Y')`).
|
||||||
|
|
||||||
|
- [x] **Step 2: Template** — Vorschlags-Tabelle: Kopf `Name | Betrag | Rhythmus | Fälligkeitstag | Start | (Aktion)`; Zellen `{{ s.rhythm|de_label }}`, `{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}`; Betrag-Zelle ergänzt `{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}`; Übernahme-Formular: hidden inputs unverändert plus `<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">` (json-form macht leer → null). Hinweistext unter dem Fieldset: „Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung."
|
||||||
|
|
||||||
|
- [x] **Step 3: Suite grün**; **Step 4: Fable-Abnahme** (Live-Approximation: Rendering + Übernahme-Roundtrip eines Quartals-Vorschlags inkl. start_date); **Step 5: Commit** `feat: Vorschlaege mit Rhythmus, Start und Hinweis`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Release v0.9.0 + Live-Gate gegen echte Daten
|
||||||
|
|
||||||
|
- [x] **Step 1:** Suite final; `VERSION` → 0.9.0; Commit; `./create_pod_finance.sh`; `/api/version` == 0.9.0.
|
||||||
|
- [x] **Step 2: Fable-Release-Gate (LIVE, lesend):** `GET /api/recurring/suggestions` gegen die echte DB. Prüfen: (a) KEINER der bestehenden ~41 Fixposten wird erneut vorgeschlagen (Bestandsabgleich wirkt, auch bei gedrifteten Beträgen); (b) keine bekannten toten Serien (gelöschte PayPal-−4,99-Serie, ausgelaufene WWK-−35-Police) im Ergebnis; (c) verbleibende Vorschläge einzeln gegen die Buchungen plausibilisieren (echte aktive Serie? korrekte Werte?). Ergebnisliste NUR im Chat/Bericht, nie committen. Bei Fehlklassifikationen: Befund zurück an Task 1 (Toleranzen), Fix + Re-Gate.
|
||||||
|
- [x] **Step 3:** Ledger (generisch) + Plan-Häkchen + Push; Kandidatenliste dem Nutzer berichten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec-Abdeckung: Algorithmus/Schema → Task 1; GUI → Task 2; Release/Live-Gate → Task 3. Testfälle der Spec vollständig in Task 1 Step 1 kodiert.
|
||||||
|
- Platzhalter: Task 1 Step 2 verweist bewusst auf den bindenden Spec-Abschnitt (8 nummerierte Schritte + Konstanten) statt den vollen Code zu duplizieren; alle Schnittstellen/Konstanten sind exakt benannt.
|
||||||
|
- Typ-Konsistenz: `suggest_recurring(session, today)`-Signatur = Testaufrufe; `SuggestionOut`-Felder = Template-Zugriffe (`s.rhythm`, `s.start_date`, `s.hinweis`).
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Design — Ausbaustufe 7: Szenarien-Seite, Formular-UX, Kategorien-Verwaltung (v0.8.0)
|
||||||
|
|
||||||
|
> Status: vom Nutzer freigegeben (Chat 2026-07-20, inkl. Direktdurchlauf).
|
||||||
|
> Anlass: Nutzertest von v0.7.0 — vier Befunde: (1) Eintragsart
|
||||||
|
> „Einmalzahlung" im Formular nicht auffindbar (Layout verschleiert, welches
|
||||||
|
> Dropdown was steuert), (2) irrelevantes Kategorie-Dropdown bei
|
||||||
|
> Einmalzahlung + keine GUI zum Anlegen von Kategorien, (3) Label/Feld-Paare
|
||||||
|
> zerfließen über Zeilen, „Durchrechnen" klebt am Formular, (4) Felder
|
||||||
|
> „Wert" vs. „Betrag" unverständlich, gesperrte Felder nicht als gesperrt
|
||||||
|
> erkennbar.
|
||||||
|
|
||||||
|
## Nutzerentscheidungen
|
||||||
|
|
||||||
|
- Szenarien auf **eigene Seite** `/szenarien` (Nav-Punkt zwischen Planung
|
||||||
|
und Admin); Planung behält Fixposten/Einmalposten/Kredite.
|
||||||
|
- Nicht zur Eintragsart passende Felder bleiben **sichtbar, aber gesperrt
|
||||||
|
und optisch deutlich gekennzeichnet** (grau gefüllt, gedimmt) — keine
|
||||||
|
Ausblendung.
|
||||||
|
- Kategorien-Verwaltung auf der **Admin-Seite** (anlegen + umbenennen,
|
||||||
|
bewusst kein Löschen — FK-Abhängigkeiten zu Buchungen/Regeln/Posten/
|
||||||
|
Modifikatoren wären ein eigenes Thema).
|
||||||
|
- Kein „Keine"-Eintrag im Kategorie-Dropdown des Eintrags-Formulars
|
||||||
|
(Kategorie-Modifikator braucht zwingend ein Ziel).
|
||||||
|
|
||||||
|
## 1. Szenarien-Seite
|
||||||
|
|
||||||
|
- `routers/gui.py`: neue Route `GET /szenarien` (`szenarien_page`,
|
||||||
|
`gui_session`-geschützt) mit dem Szenario-Kontext (scenario_rows, loans,
|
||||||
|
categories/category_names, recurring/recurring_names, modifier_kinds);
|
||||||
|
`planung_page` verliert scenario_rows/modifier_kinds.
|
||||||
|
- Neues Template `szenarien.html` (extends base): komplette bisherige
|
||||||
|
Szenarien-`<section>` aus `planning.html` (Kopf+Edit, Kredite zuordnen,
|
||||||
|
Einträge-Tabelle, Neuer Eintrag, Durchrechnen, Ergebnis, Neues Szenario)
|
||||||
|
plus die szenario-spezifischen JS-Helfer (`onModTargetTypeChange`,
|
||||||
|
`onEntryArtChange`, `toggleScenarioLoan`).
|
||||||
|
- `toggleEdit` (von Planung UND Szenarien gebraucht) zieht in den zentralen
|
||||||
|
Script-Block von `base.html`.
|
||||||
|
- Nav in `base.html`: „Szenarien" zwischen „Planung" und „Admin".
|
||||||
|
- `hilfe.html`: Verweise „auf der Planung-Seite" für Szenarien anpassen.
|
||||||
|
|
||||||
|
## 2. Formular-UX (style.css + Templates)
|
||||||
|
|
||||||
|
- **Paar-Layout:** Regel für Formular-Labels der Planungs-/Szenarien-
|
||||||
|
Formulare: `label` als `inline-flex`-Einheit (Label + Feld in einer
|
||||||
|
Zeile, `white-space: nowrap`, `gap`), Paare mit Außenabstand voneinander
|
||||||
|
getrennt. Gilt automatisch auch für die Bearbeiten-Formulare (gleiches
|
||||||
|
Markup).
|
||||||
|
- **Gesperrt-Kennzeichnung:** `input:disabled, select:disabled` → graue
|
||||||
|
Füllung, gedimmter Text, `cursor: not-allowed`; `label:has(:disabled)` →
|
||||||
|
reduzierte Deckkraft + `title`-Tooltip im Markup der umschaltbaren Felder
|
||||||
|
(„Für diese Eintragsart nicht relevant").
|
||||||
|
- **Struktur „Neuer Eintrag":** Eintragsart als erste, eigene Zeile mit
|
||||||
|
fettem Label und Hinweistext („die passenden Felder werden aktiv");
|
||||||
|
danach die Feldpaare. Dynamisches Wert-Label in `onEntryArtChange`:
|
||||||
|
percent → „Prozentsatz (%)", absolute → „Kürzung (€)", sonst „Wert" —
|
||||||
|
Label-Text via `<span class="value-label">`.
|
||||||
|
- **„Durchrechnen"** in eigenem, per Abstand/`<hr>` abgesetztem Block.
|
||||||
|
|
||||||
|
## 3. Kategorien-Verwaltung (Admin)
|
||||||
|
|
||||||
|
- Neuer Endpunkt `PATCH /api/categories/{category_id}` in
|
||||||
|
`routers/categories.py`: Body `CategoryIn`, 404 „Kategorie nicht
|
||||||
|
gefunden", 409 „Kategorie existiert bereits" bei Namenskollision mit
|
||||||
|
anderer Kategorie, Antwort `CategoryOut`.
|
||||||
|
- `admin.html`: Abschnitt „Kategorien" — Tabelle (Name + Bearbeiten/
|
||||||
|
Inline-Umbenennen nach `toggleEdit`-Muster, IDs `cat-row-{id}`/
|
||||||
|
`cat-edit-{id}`) + Formular „Neue Kategorie anlegen" (`POST
|
||||||
|
/api/categories` via json-form). Kein Löschen.
|
||||||
|
- `routers/admin.py::admin_page` lädt die Kategorienliste in den Kontext.
|
||||||
|
|
||||||
|
## 4. Tests / Release
|
||||||
|
|
||||||
|
- GUI-Tests: `/szenarien` rendert Szenario-Inhalte, ist login-geschützt und
|
||||||
|
in der Nav; `/planung` enthält KEINE Szenarien-Sektion mehr (bestehende
|
||||||
|
Szenario-GUI-Tests auf `/szenarien` umziehen); Admin-Seite zeigt
|
||||||
|
Kategorien-Abschnitt.
|
||||||
|
- API-Tests: PATCH Kategorie (Erfolg, 404, 409-Kollision, Umbenennung auf
|
||||||
|
eigenen Namen erlaubt).
|
||||||
|
- CSS ist nicht automatisiert testbar → Fable-Gate prüft die gerenderte
|
||||||
|
Struktur (Klassen/Tooltips/Label-Spans) und macht den Live-Check.
|
||||||
|
- `VERSION` → 0.8.0, Redeploy, Live-Check (Szenarien-Seite mit Best Case,
|
||||||
|
Kategorien-Anlage+Umbenennung live mit Wegwerf-Kategorie).
|
||||||
|
|
||||||
|
**Außerhalb des Scopes:** Kategorie-Löschen/-Zusammenführen, Regel-Pflege-
|
||||||
|
GUI, Änderungen an Engine/Projektion/Datenmodell (keine Migration).
|
||||||
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.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Design — Ausbaustufe 9: Vorschlags-Algorithmus v2 (v0.9.0)
|
||||||
|
|
||||||
|
> Status: vom Nutzer freigegeben (Chat 2026-07-20, Direktdurchlauf). Anlass:
|
||||||
|
> Der bisherige `suggest_recurring` gruppiert nach exaktem Betrag (jede
|
||||||
|
> Preiserhöhung zerreißt die Serie), erkennt nur monatliche Serien, prüft
|
||||||
|
> keine Aktivität (schlägt tote Serien vor) und nutzt Median-Werte. Ein
|
||||||
|
> manueller Vollabgleich (A8-Datenpflege, siehe Ledger) fand 16 fehlende
|
||||||
|
> Posten — der Algorithmus soll solche Serien künftig selbst finden.
|
||||||
|
|
||||||
|
## Anforderungen (Nutzer)
|
||||||
|
|
||||||
|
- Betrag = **letzte** Buchung, nicht Median (Preissteigerungen relevant).
|
||||||
|
- Erkennung **monatlich, vierteljährlich, jährlich**.
|
||||||
|
- Empfänger-Gruppierung robust (Schreibweisen, Preisänderungen,
|
||||||
|
Umfirmierungen); mehrere Verträge desselben Anbieters getrennt.
|
||||||
|
- Keine „Leichen": abgerissene Serien werden nicht vorgeschlagen.
|
||||||
|
- Kein Wiedervorschlagen bereits gepflegter Posten (auch bei zwischenzeitlich
|
||||||
|
geändertem Betrag).
|
||||||
|
|
||||||
|
## Algorithmus (`app/services/suggestions.py`, vollständiger Rewrite)
|
||||||
|
|
||||||
|
Parameter als Modul-Konstanten (Toleranzen zentral änderbar):
|
||||||
|
`WINDOW_DAYS=460` (~15 Monate), Rhythmen mit Intervallgrenzen und
|
||||||
|
Mindestbelegen: monthly 25–36 Tage/≥3, quarterly 80–105/≥3, yearly
|
||||||
|
330–400/≥2; `ACTIVITY_FACTOR=1.75`; Betrags-Cluster-Toleranz 35 %;
|
||||||
|
Merge-Toleranz 25 %; Bestandsabgleich-Toleranz 10 %.
|
||||||
|
|
||||||
|
1. **Datenbasis:** bestätigte Buchungen der letzten `WINDOW_DAYS`, je Konto.
|
||||||
|
2. **Gruppierung:** Schlüssel = (account_id, normalisierter Empfänger)
|
||||||
|
(`casefold`, Whitespace kollabiert).
|
||||||
|
3. **Betrags-Cluster** innerhalb der Gruppe (chronologisch, greedy gegen das
|
||||||
|
jeweils letzte Cluster-Mitglied, gleiches Vorzeichen, relative Differenz
|
||||||
|
≤ 35 %) — trennt parallele Verträge, hält Preisdrift zusammen.
|
||||||
|
4. **Rhythmus je Cluster:** Median der Buchungsabstände gegen die
|
||||||
|
Intervallgrenzen; Mindestbelege je Rhythmus.
|
||||||
|
5. **Aktiv-Check:** letzte Buchung ≤ `ACTIVITY_FACTOR` × Rhythmus-Schrittweite
|
||||||
|
(30/91/365 Tage) her, sonst kein Vorschlag.
|
||||||
|
6. **Umfirmierungs-Merge** (über Gruppengrenzen, je Konto): Serie A endet,
|
||||||
|
Serie B beginnt danach (Lücke 0,4–1,6 Schrittweiten), gleicher Rhythmus,
|
||||||
|
Fälligkeitstag ±3, Betrag ±25 % → eine Serie; Name/Betrag der neueren.
|
||||||
|
7. **Vorschlagswerte:** Name = Empfänger-Schreibweise der neuesten Buchung;
|
||||||
|
Betrag = neueste Buchung; Fälligkeitstag = Tag der neuesten Buchung;
|
||||||
|
`start_date` = Datum der neuesten Buchung bei quarterly/yearly (Phase!),
|
||||||
|
sonst None; Kategorie = häufigste in der Serie; `hinweis` = Text
|
||||||
|
„Betrag zuletzt gestiegen (vorher X)" wenn die vorletzte Buchung
|
||||||
|
betragskleiner war, sonst leer.
|
||||||
|
8. **Bestandsabgleich:** Vorschlag entfällt, wenn ein `RecurringItem`
|
||||||
|
existiert mit (a) Namens-Substring-Match (normalisiert, in beide
|
||||||
|
Richtungen) ODER (b) gleichem Rhythmus + Fälligkeitstag ±2 + Betrag
|
||||||
|
±10 %.
|
||||||
|
|
||||||
|
## API/GUI
|
||||||
|
|
||||||
|
- `SuggestionOut` (routers/planning.py): + `start_date: date | None`,
|
||||||
|
+ `hinweis: str = ""`.
|
||||||
|
- Vorschlags-Tabelle (planning.html): Spalten Rhythmus (`|de_label`) und
|
||||||
|
Start (TT.MM.JJJJ bzw. „–"); `hinweis` als `muted`-Text hinter dem Betrag;
|
||||||
|
„Vorschlag übernehmen" überträgt `start_date` mit (hidden input).
|
||||||
|
- Hinweistext unter der Tabelle aktualisiert: monatliche/vierteljährliche/
|
||||||
|
jährliche Serien, Betrag = letzte Buchung.
|
||||||
|
|
||||||
|
## Tests (synthetische Daten, keine Fixtures)
|
||||||
|
|
||||||
|
Preiserhöhungs-Serie → letzter Betrag + hinweis; Quartals-/Jahres-Serie mit
|
||||||
|
korrektem start_date; tote Serie (letzte Buchung zu alt) → kein Vorschlag;
|
||||||
|
Umbenennungs-Merge → ein Vorschlag mit neuem Namen; Bestandsabgleich:
|
||||||
|
existierender Posten mit altem Betrag verhindert Wiedervorschlag; zwei
|
||||||
|
parallele Verträge eines Anbieters → zwei getrennte Vorschläge; bestehende
|
||||||
|
drei Suggestion-Tests an die neue Semantik anpassen.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
`VERSION` → 0.9.0, Redeploy, **Live-Gate gegen echte Daten**: kein einziger
|
||||||
|
der bestehenden Fixposten darf erneut vorgeschlagen werden; keine als
|
||||||
|
beendet bekannten Serien (z.B. gelöschte PayPal-Leiche, ausgelaufene
|
||||||
|
WWK-Police) im Ergebnis; verbleibende Vorschläge werden dem Nutzer als
|
||||||
|
Kandidatenliste berichtet (nur Chat, kein Commit). Fable-Gate je Task.
|
||||||
|
|
||||||
|
**Außerhalb des Scopes:** halbjährliche Rhythmen (nicht im Datenmodell),
|
||||||
|
automatische Übernahme ohne Nutzer-Klick, Einnahmen-Prognose des
|
||||||
|
Geschäftskontos.
|
||||||
|
|
||||||
|
## Nachtrag (nach Live-Release-Gate, gleiche Session)
|
||||||
|
|
||||||
|
Das erste Live-Gate scheiterte an einem Duplikat: ein kuratierter
|
||||||
|
„variabel"-Fixposten unter Alias-Namen des Anbieters wurde vom
|
||||||
|
Bestandsabgleich (a)/(b) nicht erkannt. Daraus zwei Ergänzungen:
|
||||||
|
|
||||||
|
- **Bestandsabgleich-Regel (c) Token-Match:** Vorschlag entfällt auch, wenn
|
||||||
|
ein Fixposten mit gleichem Rhythmus, Fälligkeitstag ±2 und mindestens
|
||||||
|
einem gemeinsamen Namens-Token (≥ 5 Zeichen, normalisiert, Split an
|
||||||
|
Nicht-Alphanumerik) existiert.
|
||||||
|
- **Volatilitäts-Hinweis:** Wurde die neueste Buchung einer Empfänger-Gruppe
|
||||||
|
durch den Betrags-Cluster-Split abgetrennt UND gehört sie zu keiner
|
||||||
|
anderen qualifizierten Serie der Gruppe, erhält der Vorschlag den Zusatz
|
||||||
|
„Beträge schwanken stark – letzte Buchung weicht ab" (keine
|
||||||
|
Unterdrückung; die Ausnahme verhindert False-Positives bei parallelen
|
||||||
|
Verträgen desselben Anbieters).
|
||||||
|
|
||||||
|
Bewiesene Pipeline-Eigenschaft (bindend fürs Verständnis): der
|
||||||
|
Umfirmierungs-Merge kann die Vorschlagsanzahl nie ändern (Aktiv-Check/
|
||||||
|
Fenster erledigen das allein); sein Nutzen ist Kategorie-/Historien-
|
||||||
|
Kontinuität. Nach einer Umfirmierung entsteht eine Vorschlags-Lücke, bis
|
||||||
|
der neue Name selbst die Mindestbelege erreicht.
|
||||||
@@ -1 +1 @@
|
|||||||
0.6.0
|
0.9.0
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from fastapi import APIRouter, Depends, Form, Request
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_auth
|
from app.auth import require_auth
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.models.tables import Category
|
||||||
from app.routers.gui import gui_session, templates
|
from app.routers.gui import gui_session, templates
|
||||||
from app.services.admin import apply_rules_retroactively, change_password
|
from app.services.admin import apply_rules_retroactively, change_password
|
||||||
|
|
||||||
@@ -10,8 +12,12 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/admin", dependencies=[Depends(gui_session)])
|
@router.get("/admin", dependencies=[Depends(gui_session)])
|
||||||
def admin_page(request: Request):
|
def admin_page(request: Request, session: Session = Depends(get_session)):
|
||||||
return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None})
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
|
"error": None,
|
||||||
|
"success": None,
|
||||||
|
"categories": session.execute(select(Category)).scalars().all(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
|
@router.post("/admin/passwort", dependencies=[Depends(gui_session)])
|
||||||
@@ -20,18 +26,24 @@ def admin_change_password(
|
|||||||
alt: str = Form(...),
|
alt: str = Form(...),
|
||||||
neu: str = Form(...),
|
neu: str = Form(...),
|
||||||
neu2: str = Form(...),
|
neu2: str = Form(...),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
categories = session.execute(select(Category)).scalars().all()
|
||||||
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
|
# UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler
|
||||||
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
|
# werden inline auf derselben Seite gemeldet statt still zu verwerfen.
|
||||||
|
# Der Kategorien-Abschnitt bleibt aus demselben Grund in JEDEM Render-Pfad
|
||||||
|
# (Fehler wie Erfolg) im Kontext, statt nur bei der GET-Route.
|
||||||
if len(neu) < 8:
|
if len(neu) < 8:
|
||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
"error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.",
|
||||||
"success": None,
|
"success": None,
|
||||||
|
"categories": categories,
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
if neu != neu2:
|
if neu != neu2:
|
||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
|
"error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.",
|
||||||
"success": None,
|
"success": None,
|
||||||
|
"categories": categories,
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
try:
|
try:
|
||||||
change_password(alt, neu)
|
change_password(alt, neu)
|
||||||
@@ -39,11 +51,13 @@ def admin_change_password(
|
|||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"success": None,
|
"success": None,
|
||||||
|
"categories": categories,
|
||||||
}, status_code=400)
|
}, status_code=400)
|
||||||
return templates.TemplateResponse(request, "admin.html", {
|
return templates.TemplateResponse(request, "admin.html", {
|
||||||
"error": None,
|
"error": None,
|
||||||
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
|
"success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). "
|
||||||
"Bestehende Sitzungen bleiben angemeldet.",
|
"Bestehende Sitzungen bleiben angemeldet.",
|
||||||
|
"categories": categories,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
|||||||
return CategoryOut.model_validate(cat)
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||||
|
def patch_category(category_id: int, data: CategoryIn,
|
||||||
|
session: Session = Depends(get_session)):
|
||||||
|
cat = session.get(Category, category_id)
|
||||||
|
if cat is None:
|
||||||
|
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||||
|
clash = session.execute(
|
||||||
|
select(Category).where(Category.name == data.name)).scalar()
|
||||||
|
if clash is not None and clash.id != category_id:
|
||||||
|
raise HTTPException(409, "Kategorie existiert bereits")
|
||||||
|
cat.name = data.name
|
||||||
|
session.commit()
|
||||||
|
session.refresh(cat)
|
||||||
|
return CategoryOut.model_validate(cat)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/category-rules", response_model=list[CategoryRuleOut])
|
@router.get("/category-rules", response_model=list[CategoryRuleOut])
|
||||||
def list_category_rules(session: Session = Depends(get_session)):
|
def list_category_rules(session: Session = Depends(get_session)):
|
||||||
rules = session.execute(
|
rules = session.execute(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -290,7 +291,6 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
|||||||
recurring = _list_recurring(session=session)
|
recurring = _list_recurring(session=session)
|
||||||
planned = _list_planned(session=session)
|
planned = _list_planned(session=session)
|
||||||
loans = _list_loans(session=session)
|
loans = _list_loans(session=session)
|
||||||
scenarios = _list_scenarios(session=session)
|
|
||||||
return templates.TemplateResponse(request, "planning.html", {
|
return templates.TemplateResponse(request, "planning.html", {
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"category_names": {c.id: c.name for c in categories},
|
"category_names": {c.id: c.name for c in categories},
|
||||||
@@ -299,8 +299,23 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
|||||||
"suggestions": _recurring_suggestions(session=session),
|
"suggestions": _recurring_suggestions(session=session),
|
||||||
"planned": planned,
|
"planned": planned,
|
||||||
"loans": loans,
|
"loans": loans,
|
||||||
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
|
||||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||||
"repayment_types": ["annuity", "bullet"],
|
"repayment_types": ["annuity", "bullet"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/szenarien", dependencies=[Depends(gui_session)])
|
||||||
|
def szenarien_page(request: Request, session: Session = Depends(get_session)):
|
||||||
|
categories = session.execute(select(Category)).scalars().all()
|
||||||
|
recurring = _list_recurring(session=session)
|
||||||
|
loans = _list_loans(session=session)
|
||||||
|
scenarios = _list_scenarios(session=session)
|
||||||
|
return templates.TemplateResponse(request, "szenarien.html", {
|
||||||
|
"categories": categories,
|
||||||
|
"category_names": {c.id: c.name for c in categories},
|
||||||
|
"recurring": recurring,
|
||||||
|
"recurring_names": {r.id: r.name for r in recurring},
|
||||||
|
"loans": loans,
|
||||||
|
"scenario_rows": _scenario_rows(session, scenarios, loans),
|
||||||
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ class SuggestionOut(BaseModel):
|
|||||||
amount: Decimal
|
amount: Decimal
|
||||||
rhythm: str
|
rhythm: str
|
||||||
due_day: int
|
due_day: int
|
||||||
|
start_date: date | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
|
hinweis: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _check_category(session: Session, category_id: int | None) -> None:
|
def _check_category(session: Session, category_id: int | None) -> None:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,52 +1,371 @@
|
|||||||
|
"""Vorschlagsalgorithmus fuer wiederkehrende Buchungen (Ausbaustufe 9, v2).
|
||||||
|
|
||||||
|
Ersetzt die reine exakte-Betrags-Gruppierung (v1) durch: Empfaenger-Cluster
|
||||||
|
mit Toleranz (haelt Preisdrift in einer Serie zusammen, trennt aber parallele
|
||||||
|
Vertraege desselben Anbieters), Rhythmus-Erkennung ueber den Median der
|
||||||
|
Buchungsabstaende (monatlich/vierteljaehrlich/jaehrlich statt nur monatlich),
|
||||||
|
einen Aktiv-Check (keine "Leichen"-Serien) sowie einen Merge-Pass fuer
|
||||||
|
Umfirmierungen (Anbieter aendert den Namen, die Serie laeuft inhaltlich
|
||||||
|
weiter). Bindende Spec:
|
||||||
|
docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md.
|
||||||
|
|
||||||
|
Alle Betrags-Toleranzvergleiche verwenden ausschliesslich `Decimal`
|
||||||
|
(CLAUDE.md: "Decimal, nicht float" - Rundungsfehler bei Geldbetraegen sind
|
||||||
|
inakzeptabel). Tage-Vergleiche (Rhythmus, Aktiv-Check, Merge-Luecke) sind
|
||||||
|
ganzzahlige Tage-Arithmetik, niemals float/Decimal-Bruchteile von Tagen.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import statistics
|
import statistics
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.formats import eur
|
||||||
from app.models.tables import RecurringItem, Transaction
|
from app.models.tables import RecurringItem, Transaction
|
||||||
|
|
||||||
|
# Betrachtungsfenster (Schritt 1): ~15 Monate. Muss mindestens die zwei
|
||||||
|
# Belege einer jaehrlichen Serie (bis zu 400 Tage auseinander) plus etwas
|
||||||
|
# Puffer fuer Cluster-/Merge-Bildung abdecken.
|
||||||
|
WINDOW_DAYS = 460
|
||||||
|
|
||||||
def _max_consecutive_months(months: list[tuple[int, int]]) -> int:
|
# Rhythmus-Tabelle: (min_tage, max_tage, mindestbelege) je Rhythmus. Der
|
||||||
if not months:
|
# Median der Buchungsabstaende einer Serie muss ins Intervall fallen, UND es
|
||||||
return 0
|
# muessen mindestens so viele Buchungen vorliegen (ein einzelner Zufallstreffer
|
||||||
best = current = 1
|
# mit "passendem" Abstand soll nicht als Serie gelten).
|
||||||
for prev, cur in zip(months, months[1:]):
|
RHYTHMS: dict[str, tuple[int, int, int]] = {
|
||||||
prev_idx = prev[0] * 12 + prev[1]
|
"monthly": (25, 36, 3),
|
||||||
cur_idx = cur[0] * 12 + cur[1]
|
"quarterly": (80, 105, 3),
|
||||||
current = current + 1 if cur_idx == prev_idx + 1 else 1
|
"yearly": (330, 400, 2),
|
||||||
best = max(best, current)
|
}
|
||||||
return best
|
|
||||||
|
# Nominelle Schrittweite je Rhythmus in Tagen - Referenzwert fuer Aktiv-Check
|
||||||
|
# und Merge-Luecken-Fenster (Schritt 5/6).
|
||||||
|
STEP_DAYS: dict[str, int] = {"monthly": 30, "quarterly": 91, "yearly": 365}
|
||||||
|
|
||||||
|
# Aktiv-Check (Schritt 5): die letzte Buchung darf hoechstens das 1,75-fache
|
||||||
|
# der Rhythmus-Schrittweite zurueckliegen, sonst gilt die Serie als beendet
|
||||||
|
# ("Leiche") und wird nicht vorgeschlagen. Als Fraction 7/4 ausgedrueckt und
|
||||||
|
# ganzzahlig verglichen (delta_tage * 4 <= schrittweite * 7), damit keine
|
||||||
|
# Gleitkomma-Rundung ueber "aktiv"/"inaktiv" entscheidet.
|
||||||
|
ACTIVITY_FACTOR_NUM = 7
|
||||||
|
ACTIVITY_FACTOR_DEN = 4
|
||||||
|
|
||||||
|
# Relative Toleranzen (immer als Decimal verglichen, nie float):
|
||||||
|
CLUSTER_TOL = Decimal("0.35") # Schritt 3: Betrags-Cluster (haelt Preisdrift zusammen)
|
||||||
|
MERGE_TOL = Decimal("0.25") # Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen
|
||||||
|
BESTAND_TOL = Decimal("0.10") # Schritt 8: Bestandsabgleich gegen RecurringItem
|
||||||
|
|
||||||
|
# Merge-Luecke (Schritt 6): die Zeit zwischen dem Ende von Serie A und dem
|
||||||
|
# Beginn von Serie B muss zwischen dem 0,4- und 1,6-fachen der
|
||||||
|
# Rhythmus-Schrittweite liegen (als ganzzahlige Bruchvergleiche, aus
|
||||||
|
# demselben Grund wie beim Aktiv-Check).
|
||||||
|
MERGE_GAP_MIN_NUM, MERGE_GAP_MIN_DEN = 4, 10 # 0.4
|
||||||
|
MERGE_GAP_MAX_NUM, MERGE_GAP_MAX_DEN = 16, 10 # 1.6
|
||||||
|
MERGE_DUE_DAY_TOL = 3 # Schritt 6: Faelligkeitstag-Toleranz in Tagen
|
||||||
|
BESTAND_DUE_DAY_TOL = 2 # Schritt 8: Faelligkeitstag-Toleranz in Tagen
|
||||||
|
|
||||||
|
# Bestandsabgleich, Token-Match (Live-Gate-Fund, Nachtrag 4): kuratierte
|
||||||
|
# Fixposten tragen haeufig einen Alias-/Variabel-Namen, der weder Substring
|
||||||
|
# noch betragsaehnlich zum automatisch erkannten Vorschlag ist (Muster:
|
||||||
|
# ein Sammel-Fixposten fuer eine Kreditkartenabrechnung mit variablem Betrag
|
||||||
|
# unter einem Alias-Namen des Anbieters deckt den vom Algorithmus erkannten
|
||||||
|
# Vorschlag desselben Anbieters unter seinem regulaeren Empfaenger-Namen
|
||||||
|
# nicht ab, weil weder Substring noch Betrags-Toleranz greifen). Ein
|
||||||
|
# gemeinsames, hinreichend spezifisches Namens-Token (>=5 Zeichen, um
|
||||||
|
# generische Woerter wie "Bank" nicht faelschlich matchen zu lassen) bei
|
||||||
|
# gleichem Rhythmus und nahem Faelligkeitstag gilt als ausreichendes Indiz
|
||||||
|
# fuer denselben Fixposten.
|
||||||
|
TOKEN_MIN_LEN = 5
|
||||||
|
|
||||||
|
# Volatilitaets-Hinweis (Live-Gate A9-Fund, Nachtrag 4): wenn der
|
||||||
|
# Betrags-Cluster-Split (Schritt 3) die neueste Buchung der Empfaenger-Gruppe
|
||||||
|
# abgetrennt hat (weil sie zu stark vom Serien-Betrag abweicht), ist der
|
||||||
|
# vorgeschlagene Betrag ggf. schon wieder veraltet - keine Unterdrueckung,
|
||||||
|
# nur ein Warnhinweis fuer die Nutzerin/den Nutzer.
|
||||||
|
VOLATILITAETS_HINWEIS = "Beträge schwanken stark – letzte Buchung weicht ab"
|
||||||
|
|
||||||
|
|
||||||
def suggest_recurring(session: Session) -> list[dict]:
|
def _norm(name: str) -> str:
|
||||||
|
"""Normalisiert einen Empfaenger-Namen fuer Gruppen- und
|
||||||
|
Substring-Vergleich: Bankexporte schreiben denselben Empfaenger nicht
|
||||||
|
einheitlich (Gross-/Kleinschreibung, mehrfache Leerzeichen), das ist fuer
|
||||||
|
die Erkennung irrelevant."""
|
||||||
|
return " ".join(name.split()).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(name: str) -> set[str]:
|
||||||
|
"""Zerlegt einen normalisierten Namen an Nicht-Alphanumerik in Tokens
|
||||||
|
(fuer den Token-Match im Bestandsabgleich, Schritt 8). Nur Tokens ab
|
||||||
|
TOKEN_MIN_LEN Zeichen zaehlen, damit kurze generische Woerter ("eG",
|
||||||
|
"AG", "Bank") keine falschen Treffer erzeugen."""
|
||||||
|
return {tok for tok in re.split(r"[^a-z0-9]+", _norm(name)) if len(tok) >= TOKEN_MIN_LEN}
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_diff(a: Decimal, b: Decimal) -> Decimal:
|
||||||
|
"""Relative Differenz von Betrag a zur Referenz b (immer >= 0), als
|
||||||
|
Decimal. b=0 kommt praktisch nicht vor (eine Nullbuchung bildet keine
|
||||||
|
erkennbare Serie); fuer diesen Sonderfall gilt "keine Aehnlichkeit"."""
|
||||||
|
if b == 0:
|
||||||
|
return Decimal("Infinity") if a != 0 else Decimal("0")
|
||||||
|
return abs(a - b) / abs(b)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Series:
|
||||||
|
"""Eine erkannte Serie: chronologisch sortierte Buchungen eines
|
||||||
|
Betrags-Clusters mit zugeordnetem Rhythmus.
|
||||||
|
|
||||||
|
`volatile` markiert, dass der Cluster-Split (Schritt 3) innerhalb der
|
||||||
|
Empfaenger-Gruppe eine NEUERE, betragsmaessig abweichende Buchung
|
||||||
|
abgetrennt hat - der hier vorgeschlagene Betrag koennte also schon
|
||||||
|
wieder veraltet sein (siehe VOLATILITAETS_HINWEIS)."""
|
||||||
|
items: list[Transaction]
|
||||||
|
rhythm: str
|
||||||
|
volatile: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first(self) -> Transaction:
|
||||||
|
return self.items[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last(self) -> Transaction:
|
||||||
|
return self.items[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _amount_clusters(items: list[Transaction]) -> list[list[Transaction]]:
|
||||||
|
"""Schritt 3: teilt chronologisch sortierte Buchungen einer
|
||||||
|
Empfaenger-Gruppe in Betrags-Cluster. Eine Buchung haengt sich an das
|
||||||
|
Cluster, dessen zuletzt aufgenommenes Mitglied gleiches Vorzeichen und
|
||||||
|
eine relative Differenz <= CLUSTER_TOL hat (greedy, erstes passendes
|
||||||
|
Cluster gewinnt) - das haelt eine langsam driftende Serie (Preiserhoehung)
|
||||||
|
zusammen, trennt aber parallele Vertraege mit deutlich anderem Betrag."""
|
||||||
|
clusters: list[list[Transaction]] = []
|
||||||
|
for t in items:
|
||||||
|
for cluster in clusters:
|
||||||
|
last = cluster[-1]
|
||||||
|
same_sign = (t.amount > 0) == (last.amount > 0)
|
||||||
|
if same_sign and _rel_diff(Decimal(t.amount), Decimal(last.amount)) <= CLUSTER_TOL:
|
||||||
|
cluster.append(t)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
clusters.append([t])
|
||||||
|
return clusters
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(dates: list[date]) -> str | None:
|
||||||
|
"""Schritt 4: bestimmt den Rhythmus einer Serie ueber den Median der
|
||||||
|
Buchungsabstaende (robust gegen einzelne Ausreisser, z.B.
|
||||||
|
Wochenend-/Feiertagsverschiebung einer einzelnen Buchung)."""
|
||||||
|
if len(dates) < 2:
|
||||||
|
return None
|
||||||
|
gaps = [(b - a).days for a, b in zip(dates, dates[1:])]
|
||||||
|
median_gap = statistics.median(gaps)
|
||||||
|
for rhythm, (lo, hi, min_belege) in RHYTHMS.items():
|
||||||
|
if len(dates) >= min_belege and lo <= median_gap <= hi:
|
||||||
|
return rhythm
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_gap_ok(gap_days: int, step: int) -> bool:
|
||||||
|
"""Schritt 6: Luecke zwischen Serienende und -beginn im Fenster
|
||||||
|
[0,4; 1,6] * Schrittweite (ganzzahliger Bruchvergleich, keine Rundung)."""
|
||||||
|
return (gap_days * MERGE_GAP_MIN_DEN >= MERGE_GAP_MIN_NUM * step
|
||||||
|
and gap_days * MERGE_GAP_MAX_DEN <= MERGE_GAP_MAX_NUM * step)
|
||||||
|
|
||||||
|
|
||||||
|
def _mergeable(a: _Series, b: _Series) -> bool:
|
||||||
|
"""Prueft die Umfirmierungs-Merge-Bedingungen aus Schritt 6 fuer ein
|
||||||
|
Paar (A endet, B beginnt danach): gleicher Rhythmus, plausible Luecke,
|
||||||
|
Faelligkeitstag nah beieinander (Transitionspunkte: letzte Buchung von A
|
||||||
|
gegen erste Buchung von B), Betrag nicht sprunghaft veraendert."""
|
||||||
|
if a.rhythm != b.rhythm:
|
||||||
|
return False
|
||||||
|
if a.last.booking_date >= b.first.booking_date:
|
||||||
|
return False
|
||||||
|
step = STEP_DAYS[a.rhythm]
|
||||||
|
gap = (b.first.booking_date - a.last.booking_date).days
|
||||||
|
if not _merge_gap_ok(gap, step):
|
||||||
|
return False
|
||||||
|
if abs(a.last.booking_date.day - b.first.booking_date.day) > MERGE_DUE_DAY_TOL:
|
||||||
|
return False
|
||||||
|
same_sign = (a.last.amount > 0) == (b.first.amount > 0)
|
||||||
|
if not same_sign:
|
||||||
|
return False
|
||||||
|
return _rel_diff(Decimal(b.first.amount), Decimal(a.last.amount)) <= MERGE_TOL
|
||||||
|
|
||||||
|
|
||||||
|
def _try_merge(series_list: list[_Series]) -> list[_Series]:
|
||||||
|
"""Schritt 6: fasst Serien desselben Kontos ueber Gruppengrenzen
|
||||||
|
(unterschiedlicher normalisierter Empfaenger-Name, z.B. nach einer
|
||||||
|
Umfirmierung) zusammen, solange `_mergeable` zutrifft. Laeuft iterativ
|
||||||
|
bis zum Fixpunkt, damit eine bereits gemergte Serie mit einer weiteren,
|
||||||
|
noch juengeren Serie erneut zusammengefasst werden kann (z.B. zwei
|
||||||
|
Umbenennungen hintereinander)."""
|
||||||
|
series_list = list(series_list)
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for i, a in enumerate(series_list):
|
||||||
|
for j, b in enumerate(series_list):
|
||||||
|
if i == j or not _mergeable(a, b):
|
||||||
|
continue
|
||||||
|
merged = _Series(
|
||||||
|
items=sorted(a.items + b.items, key=lambda t: t.booking_date),
|
||||||
|
rhythm=a.rhythm,
|
||||||
|
volatile=a.volatile or b.volatile,
|
||||||
|
)
|
||||||
|
series_list = [s for k, s in enumerate(series_list) if k not in (i, j)]
|
||||||
|
series_list.append(merged)
|
||||||
|
changed = True
|
||||||
|
break
|
||||||
|
if changed:
|
||||||
|
break
|
||||||
|
return series_list
|
||||||
|
|
||||||
|
|
||||||
|
def _covered_by_existing(cand_name: str, cand_amount: Decimal, rhythm: str, due_day: int,
|
||||||
|
existing: list[RecurringItem]) -> bool:
|
||||||
|
"""Schritt 8 (Bestandsabgleich): ein Vorschlag entfaellt, wenn er bereits
|
||||||
|
als Fixposten gepflegt ist - ueber einen von drei Wegen:
|
||||||
|
(a) Namens-Substring-Match (normalisiert, in beide Richtungen: sowohl
|
||||||
|
Kurz- als auch Langschreibweisen kommen in der Praxis in beiden
|
||||||
|
Datenquellen vor);
|
||||||
|
(b) Rhythmus + Faelligkeitstag + Betrag innerhalb enger Toleranz (falls
|
||||||
|
der Fixposten unter einem ganz anderen Namen gepflegt wurde);
|
||||||
|
(c) Token-Match: gleicher Rhythmus, Faelligkeitstag-Differenz <= 2 UND
|
||||||
|
mindestens ein gemeinsames Namens-Token (>=5 Zeichen) - faengt
|
||||||
|
kuratierte Alias-/Variabel-Fixposten, deren Name UND Betrag stark
|
||||||
|
vom automatisch erkannten Vorschlag abweichen (Live-Gate-Fund: ein
|
||||||
|
Sammel-Fixposten unter Alias-Namen des Anbieters deckt den
|
||||||
|
automatisch erkannten Vorschlag desselben Anbieters unter seinem
|
||||||
|
regulaeren Empfaenger-Namen ab, obwohl weder (a) noch (b) greifen)."""
|
||||||
|
cand_norm = _norm(cand_name)
|
||||||
|
cand_tokens = _tokens(cand_name)
|
||||||
|
for item in existing:
|
||||||
|
item_norm = _norm(item.name)
|
||||||
|
if cand_norm in item_norm or item_norm in cand_norm:
|
||||||
|
return True
|
||||||
|
if (item.rhythm == rhythm
|
||||||
|
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||||
|
and _rel_diff(cand_amount, Decimal(item.amount)) <= BESTAND_TOL):
|
||||||
|
return True
|
||||||
|
if (item.rhythm == rhythm
|
||||||
|
and abs(item.due_day - due_day) <= BESTAND_DUE_DAY_TOL
|
||||||
|
and cand_tokens & _tokens(item.name)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_recurring(session: Session, today: date | None = None) -> list[dict]:
|
||||||
|
"""Ermittelt Vorschlaege fuer wiederkehrende Posten aus bestaetigten
|
||||||
|
Buchungen der letzten WINDOW_DAYS Tage. `today` ist ausschliesslich zu
|
||||||
|
Testzwecken injizierbar (deterministischer Aktiv-Check) - im
|
||||||
|
Produktivbetrieb liefert der Default `date.today()`. Reihenfolge der
|
||||||
|
Schritte gemaess Spec, mit einer bewussten Umstellung gegenueber der
|
||||||
|
Nummerierung dort: der Aktiv-Check (Schritt 5) laeuft NACH dem
|
||||||
|
Umfirmierungs-Merge (Schritt 6) auf der ggf. gemergten Serie - sonst
|
||||||
|
wuerde eine per Umfirmierung fortgesetzte Serie an ihrem alten,
|
||||||
|
laengst inaktiven Teil scheitern, bevor der Merge sie retten kann."""
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
cutoff = today - timedelta(days=WINDOW_DAYS)
|
||||||
|
|
||||||
|
# Schritt 1: Datenbasis.
|
||||||
txs = session.execute(
|
txs = session.execute(
|
||||||
select(Transaction).where(Transaction.status == "confirmed")
|
select(Transaction)
|
||||||
|
.where(Transaction.status == "confirmed", Transaction.booking_date >= cutoff)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
groups: dict[tuple, list[Transaction]] = defaultdict(list)
|
|
||||||
for t in txs:
|
|
||||||
groups[(t.account_id, t.counterparty, t.amount)].append(t)
|
|
||||||
|
|
||||||
existing = {(r.name, Decimal(r.amount))
|
# Schritt 2: Gruppierung je (Konto, normalisierter Empfaenger).
|
||||||
for r in session.execute(select(RecurringItem)).scalars()}
|
groups: dict[tuple[int, str], list[Transaction]] = {}
|
||||||
|
for t in txs:
|
||||||
|
groups.setdefault((t.account_id, _norm(t.counterparty)), []).append(t)
|
||||||
|
|
||||||
|
# Schritt 3+4: je Gruppe Betrags-Cluster bilden und Rhythmus klassifizieren.
|
||||||
|
series_by_account: dict[int, list[_Series]] = {}
|
||||||
|
for (account_id, _name_norm), items in groups.items():
|
||||||
|
items_sorted = sorted(items, key=lambda t: t.booking_date)
|
||||||
|
clusters = _amount_clusters(items_sorted)
|
||||||
|
classified = [(cluster, _classify([t.booking_date for t in cluster]))
|
||||||
|
for cluster in clusters]
|
||||||
|
# Fuer den Volatilitaets-Check zaehlt eine neuere Buchung nur dann als
|
||||||
|
# "abgetrennt", wenn sie NICHT bereits zu einem ANDEREN qualifizierten
|
||||||
|
# (klassifizierten) Cluster derselben Gruppe gehoert - sonst waeren
|
||||||
|
# zwei parallele, stabile Vertraege (jeder fuer sich eine gueltige
|
||||||
|
# eigene Serie) faelschlich als "volatil" markiert, nur weil der
|
||||||
|
# jeweils andere Vertrag zufaellig spaeter im Monat faellig ist
|
||||||
|
# (Nachtrag 3b, Fable-Gate-Korrektur nach dem ersten Live-Gate-Fund).
|
||||||
|
qualified_items = {t for cluster, rhythm in classified if rhythm is not None
|
||||||
|
for t in cluster}
|
||||||
|
for cluster, rhythm in classified:
|
||||||
|
if rhythm is None:
|
||||||
|
continue
|
||||||
|
# Volatilitaets-Hinweis: hat der Cluster-Split innerhalb DIESER
|
||||||
|
# Empfaenger-Gruppe (gleiches Konto, gleiches Vorzeichen) eine
|
||||||
|
# NEUERE Buchung in einen UNQUALIFIZIERTEN Cluster abgetrennt
|
||||||
|
# (z.B. eine einzelne Ausreisser-Buchung, die allein keine Serie
|
||||||
|
# bildet), ist der hier vorgeschlagene (letzte) Betrag ggf. schon
|
||||||
|
# veraltet.
|
||||||
|
cluster_sign = cluster[-1].amount > 0
|
||||||
|
volatile = any(
|
||||||
|
(t.amount > 0) == cluster_sign
|
||||||
|
and t.booking_date > cluster[-1].booking_date
|
||||||
|
and t not in qualified_items
|
||||||
|
for t in items_sorted
|
||||||
|
)
|
||||||
|
series_by_account.setdefault(account_id, []).append(
|
||||||
|
_Series(items=cluster, rhythm=rhythm, volatile=volatile))
|
||||||
|
|
||||||
|
existing = list(session.execute(select(RecurringItem)).scalars())
|
||||||
|
|
||||||
suggestions: list[dict] = []
|
suggestions: list[dict] = []
|
||||||
for (_account_id, counterparty, amount), items in groups.items():
|
for series_list in series_by_account.values():
|
||||||
months = sorted({(t.booking_date.year, t.booking_date.month) for t in items})
|
# Schritt 6: Umfirmierungs-Merge ueber Gruppengrenzen, je Konto.
|
||||||
if _max_consecutive_months(months) < 3:
|
for s in _try_merge(series_list):
|
||||||
|
# Schritt 5: Aktiv-Check auf der (ggf. gemergten) finalen Serie.
|
||||||
|
step = STEP_DAYS[s.rhythm]
|
||||||
|
delta_tage = (today - s.last.booking_date).days
|
||||||
|
if delta_tage * ACTIVITY_FACTOR_DEN > step * ACTIVITY_FACTOR_NUM:
|
||||||
continue
|
continue
|
||||||
name = counterparty
|
|
||||||
if (name, Decimal(amount)) in existing:
|
# Schritt 7: Vorschlagswerte aus der neuesten Buchung.
|
||||||
continue
|
last = s.last
|
||||||
due_day = int(statistics.median(sorted(t.booking_date.day for t in items)))
|
name = last.counterparty
|
||||||
cat_counts = Counter(t.category_id for t in items if t.category_id is not None)
|
amount = Decimal(last.amount)
|
||||||
|
due_day = last.booking_date.day
|
||||||
|
start_date = last.booking_date if s.rhythm in ("quarterly", "yearly") else None
|
||||||
|
cat_counts = Counter(t.category_id for t in s.items if t.category_id is not None)
|
||||||
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
category_id = cat_counts.most_common(1)[0][0] if cat_counts else None
|
||||||
|
|
||||||
|
hinweis = ""
|
||||||
|
if len(s.items) >= 2:
|
||||||
|
previous = Decimal(s.items[-2].amount)
|
||||||
|
# "Gestiegen" bezieht sich auf den Betragswert (Ausgaben sind
|
||||||
|
# negativ: gestiegen heisst betragsmaessig groesser, also
|
||||||
|
# abs(neu) > abs(alt)), nicht auf das Vorzeichen.
|
||||||
|
if abs(amount) > abs(previous):
|
||||||
|
hinweis = f"Betrag zuletzt gestiegen (vorher {eur(abs(previous))} €)"
|
||||||
|
|
||||||
|
if s.volatile:
|
||||||
|
hinweis = f"{hinweis} {VOLATILITAETS_HINWEIS}".strip()
|
||||||
|
|
||||||
|
# Schritt 8: Bestandsabgleich.
|
||||||
|
if _covered_by_existing(name, amount, s.rhythm, due_day, existing):
|
||||||
|
continue
|
||||||
|
|
||||||
suggestions.append({
|
suggestions.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"amount": Decimal(amount),
|
"amount": amount,
|
||||||
"rhythm": "monthly",
|
"rhythm": s.rhythm,
|
||||||
"due_day": due_day,
|
"due_day": due_day,
|
||||||
|
"start_date": start_date,
|
||||||
"category_id": category_id,
|
"category_id": category_id,
|
||||||
|
"hinweis": hinweis,
|
||||||
})
|
})
|
||||||
return suggestions
|
return suggestions
|
||||||
|
|||||||
@@ -179,3 +179,34 @@ section.planning-section fieldset {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
|
||||||
|
.entry-form label,
|
||||||
|
.planning-section form label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0 1.25rem 0.6rem 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.entry-form select,
|
||||||
|
.planning-section form select {
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
.entry-form .entry-art {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.entry-form .entry-art > label { font-weight: bold; }
|
||||||
|
.entry-form .entry-art .muted { margin-left: 0.5rem; }
|
||||||
|
input:disabled, select:disabled {
|
||||||
|
background: #e3e3e3;
|
||||||
|
color: #8a8a8a;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
label:has(input:disabled), label:has(select:disabled) {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.project-block {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,43 @@
|
|||||||
<p id="apply-rules-result"></p>
|
<p id="apply-rules-result"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>Kategorien</h2>
|
||||||
|
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
|
||||||
|
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in categories %}
|
||||||
|
<tr id="cat-row-{{ c.id }}">
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr id="cat-edit-{{ c.id }}" hidden>
|
||||||
|
<td colspan="2">
|
||||||
|
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Neue Kategorie anlegen</legend>
|
||||||
|
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" required maxlength="100"></label>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function handleApplyRulesResult(event) {
|
function handleApplyRulesResult(event) {
|
||||||
var result = document.getElementById('apply-rules-result');
|
var result = document.getElementById('apply-rules-result');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<a href="/buchungen">Buchungen</a>
|
<a href="/buchungen">Buchungen</a>
|
||||||
<a href="/salden">Salden</a>
|
<a href="/salden">Salden</a>
|
||||||
<a href="/planung">Planung</a>
|
<a href="/planung">Planung</a>
|
||||||
|
<a href="/szenarien">Szenarien</a>
|
||||||
<a href="/admin">Admin</a>
|
<a href="/admin">Admin</a>
|
||||||
<a href="/hilfe">Hilfe</a>
|
<a href="/hilfe">Hilfe</a>
|
||||||
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
<a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a>
|
||||||
@@ -74,6 +75,12 @@
|
|||||||
return JSON.stringify(out);
|
return JSON.stringify(out);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -88,15 +88,19 @@
|
|||||||
{% if suggestions %}
|
{% if suggestions %}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th></th></tr>
|
<tr><th>Name</th><th>Betrag</th><th>Rhythmus</th><th>Fälligkeitstag</th><th>Start</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for s in suggestions %}
|
{% for s in suggestions %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ s.name }}</td>
|
<td>{{ s.name }}</td>
|
||||||
<td class="{{ 'neg' if s.amount < 0 else '' }}">{{ s.amount|eur }} €</td>
|
<td class="{{ 'neg' if s.amount < 0 else '' }}">
|
||||||
|
{{ s.amount|eur }} €
|
||||||
|
{% if s.hinweis %}<span class="muted">{{ s.hinweis }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ s.rhythm|de_label }}</td>
|
<td>{{ s.rhythm|de_label }}</td>
|
||||||
<td>{{ s.due_day }}</td>
|
<td>{{ s.due_day }}</td>
|
||||||
|
<td>{{ s.start_date.strftime('%d.%m.%Y') if s.start_date else '–' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" hx-swap="none"
|
<form class="inline-form" hx-ext="json-form" hx-post="/api/recurring" 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()}">
|
||||||
@@ -105,6 +109,7 @@
|
|||||||
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
|
<input type="hidden" name="rhythm" value="{{ s.rhythm }}">
|
||||||
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
|
<input type="hidden" name="due_day" data-type="int" value="{{ s.due_day }}">
|
||||||
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
|
<input type="hidden" name="category_id" data-type="int" value="{{ s.category_id if s.category_id is not none else '' }}">
|
||||||
|
<input type="hidden" name="start_date" value="{{ s.start_date.isoformat() if s.start_date else '' }}">
|
||||||
<button type="submit">Vorschlag übernehmen</button>
|
<button type="submit">Vorschlag übernehmen</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -113,8 +118,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Keine Vorschläge — erkannt werden Serien aus mindestens 3 Monaten gleichartiger Buchungen.</p>
|
<p class="muted">Keine Vorschläge.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<p class="muted">Erkannt werden monatliche, vierteljährliche und jährliche Serien; Betrag = jeweils letzte Buchung.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -260,216 +266,11 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="planning-section">
|
|
||||||
<h2>Szenarien</h2>
|
|
||||||
{% for row in scenario_rows %}
|
|
||||||
{% set sc = row.scenario %}
|
|
||||||
<fieldset>
|
|
||||||
<legend>{{ sc.name }}</legend>
|
|
||||||
<p id="sc-row-{{ sc.id }}">
|
|
||||||
{{ sc.description }}
|
|
||||||
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
|
|
||||||
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
|
|
||||||
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
|
|
||||||
</p>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Kredite zuordnen</summary>
|
|
||||||
{% for l in loans %}
|
|
||||||
<label class="inline-form">
|
|
||||||
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
|
|
||||||
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
|
|
||||||
{{ l.name }}
|
|
||||||
</label>
|
|
||||||
{% else %}
|
|
||||||
<p>Keine Kredite vorhanden.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Modifikatoren</summary>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Ziel</th><th>Art</th><th>Wert</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for m in row.modifiers %}
|
|
||||||
<tr>
|
|
||||||
<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 == 'ende' %}{{ m.end_date.strftime('%d.%m.%Y') if m.end_date else '–' }}
|
|
||||||
{% else %}{{ m.value|eur }}{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<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>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="4">Keine Modifikatoren.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<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()}">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</label>
|
|
||||||
<label>Wert <input type="text" name="value" data-type="amount" value="0"></label>
|
|
||||||
<label>Endet am <input type="date" name="end_date" disabled></label>
|
|
||||||
<button type="submit">Modifikator hinzufügen</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Einmalzahlungen in diesem Szenario</summary>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Name</th><th>Betrag</th><th>Fällig am</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for sp in row.planned_items %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ sp.name }}</td>
|
|
||||||
<td class="{{ 'neg' if sp.amount < 0 else '' }}">{{ sp.amount|eur }} €</td>
|
|
||||||
<td>{{ sp.due.strftime('%d.%m.%Y') }}</td>
|
|
||||||
<td>
|
|
||||||
<form class="inline-form" hx-delete="/api/scenarios/{{ sc.id }}/planned/{{ sp.id }}" hx-swap="none"
|
|
||||||
hx-confirm="„{{ sp.name }}“ wirklich löschen?"
|
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
|
||||||
<button type="submit">Löschen</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr><td colspan="4">Keine szenario-eigenen Einmalzahlungen.</td></tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<form hx-ext="json-form" hx-post="/api/scenarios/{{ sc.id }}/planned" hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
|
||||||
<label>Name <input type="text" name="name" required></label>
|
|
||||||
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" required></label>
|
|
||||||
<label>Fällig am <input type="date" name="due" required></label>
|
|
||||||
<button type="submit">Einmalzahlung hinzufügen</button>
|
|
||||||
</form>
|
|
||||||
<p class="hint">Zählt nur in diesem Szenario — unabhängig von „Einmalposten einschließen“.</p>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
|
||||||
<button type="submit">Durchrechnen</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if row.result %}
|
|
||||||
<p>
|
|
||||||
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
|
|
||||||
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
|
||||||
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
|
||||||
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
|
|
||||||
</p>
|
|
||||||
{% else %}
|
|
||||||
<p>Noch nicht durchgerechnet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</fieldset>
|
|
||||||
{% else %}
|
|
||||||
<p>Noch keine Szenarien.</p>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend>Neues Szenario anlegen</legend>
|
|
||||||
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
|
||||||
<label>Name <input type="text" name="name" required></label>
|
|
||||||
<label>Beschreibung <input type="text" name="description"></label>
|
|
||||||
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
|
|
||||||
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
|
|
||||||
<button type="submit">Szenario anlegen</button>
|
|
||||||
</form>
|
|
||||||
</fieldset>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// json-form-Extension ist zentral in base.html registriert (eine
|
// json-form-Extension ist zentral in base.html registriert (eine
|
||||||
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
|
// Definition fuer alle Templates, inkl. data-type="bool"-Handling).
|
||||||
|
// toggleEdit ist ebenfalls zentral in base.html definiert (wird auf
|
||||||
function toggleEdit(prefix, id, editing) {
|
// mehreren Seiten fuer Inline-Edit-Formulare gebraucht).
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onModTargetTypeChange(select) {
|
|
||||||
var form = select.closest('form');
|
|
||||||
var catSpan = form.querySelector('.mod-target-category');
|
|
||||||
var recSpan = form.querySelector('.mod-target-recurring');
|
|
||||||
var catSelect = catSpan.querySelector('select');
|
|
||||||
var recSelect = recSpan.querySelector('select');
|
|
||||||
var isCategory = select.value === 'category';
|
|
||||||
catSpan.style.display = isCategory ? '' : 'none';
|
|
||||||
recSpan.style.display = isCategory ? 'none' : '';
|
|
||||||
catSelect.disabled = !isCategory;
|
|
||||||
recSelect.disabled = isCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onModKindChange(select) {
|
|
||||||
var form = select.closest('form');
|
|
||||||
var isEnde = select.value === 'ende';
|
|
||||||
form.querySelector('[name="end_date"]').disabled = !isEnde;
|
|
||||||
form.querySelector('[name="value"]').disabled = isEnde;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleScenarioLoan(scenarioId, loanId, checked, checkbox) {
|
|
||||||
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
|
||||||
.then(function (resp) {
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error('HTTP ' + resp.status);
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
checkbox.checked = !checked;
|
|
||||||
alert('Zuordnung fehlgeschlagen: ' + err.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var loadedSchedules = {};
|
var loadedSchedules = {};
|
||||||
function formatIsoDate(iso) {
|
function formatIsoDate(iso) {
|
||||||
|
|||||||
298
finance/app/templates/szenarien.html
Normal file
298
finance/app/templates/szenarien.html
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Szenarien – Finanzberatung{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Szenarien</h1>
|
||||||
|
|
||||||
|
<section class="planning-section">
|
||||||
|
{% for row in scenario_rows %}
|
||||||
|
{% set sc = row.scenario %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>{{ sc.name }}</legend>
|
||||||
|
<p id="sc-row-{{ sc.id }}">
|
||||||
|
{{ sc.description }}
|
||||||
|
· Wiederkehrende Posten: {{ 'ja' if sc.include_recurring else 'nein' }}
|
||||||
|
· Einmalposten: {{ 'ja' if sc.include_planned else 'nein' }}
|
||||||
|
<button type="button" onclick="toggleEdit('sc', {{ sc.id }}, true)">Bearbeiten</button>
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Kredite zuordnen</summary>
|
||||||
|
{% for l in loans %}
|
||||||
|
<label class="inline-form">
|
||||||
|
<input type="checkbox" {% if l.id in row.assigned_loan_ids %}checked{% endif %}
|
||||||
|
onchange="toggleScenarioLoan({{ sc.id }}, {{ l.id }}, this.checked, this)">
|
||||||
|
{{ l.name }}
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<p>Keine Kredite vorhanden.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h4>Neuer Eintrag</h4>
|
||||||
|
<form class="entry-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()}">
|
||||||
|
<div class="entry-art">
|
||||||
|
<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="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<span class="mod-target-recurring" style="display:none">
|
||||||
|
<label>Posten
|
||||||
|
<select name="target_id" data-type="int" disabled title="Für diese Eintragsart nicht relevant">
|
||||||
|
{% for r in recurring %}<option value="{{ r.id }}">{{ r.name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0"></label>
|
||||||
|
<label>Endet am <input type="date" name="end_date" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
|
<label>Name <input type="text" name="name" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
|
<label>Betrag <input type="text" name="amount" data-type="amount" placeholder="5000,00" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
|
<label>Fällig am <input type="date" name="due" disabled title="Für diese Eintragsart nicht relevant"></label>
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="project-block">
|
||||||
|
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<button type="submit">Durchrechnen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if row.result %}
|
||||||
|
<p>
|
||||||
|
Tiefpunkt: {{ row.result.low_point_balance|eur }} € am {{ row.result.low_point_date.strftime('%d.%m.%Y') }}<br>
|
||||||
|
{% if row.result.below_zero_date %}Unterschreitet 0 € ab {{ row.result.below_zero_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||||
|
{% if row.result.below_threshold_date %}Unterschreitet Warnschwelle ab {{ row.result.below_threshold_date.strftime('%d.%m.%Y') }}<br>{% endif %}
|
||||||
|
Kurven in <a href="http://{{ request.url.hostname or '127.0.0.1' }}:8097" target="_blank" rel="noopener">Grafana</a> ansehen.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Noch nicht durchgerechnet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% else %}
|
||||||
|
<p>Noch keine Szenarien.</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Neues Szenario anlegen</legend>
|
||||||
|
<form hx-ext="json-form" hx-post="/api/scenarios" hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
|
||||||
|
<label>Name <input type="text" name="name" required></label>
|
||||||
|
<label>Beschreibung <input type="text" name="description"></label>
|
||||||
|
<label><input type="checkbox" name="include_recurring" data-type="bool" checked> Wiederkehrende Posten einschließen</label>
|
||||||
|
<label><input type="checkbox" name="include_planned" data-type="bool" checked> Einmalposten einschließen</label>
|
||||||
|
<button type="submit">Szenario anlegen</button>
|
||||||
|
</form>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function onModTargetTypeChange(select) {
|
||||||
|
var form = select.closest('form');
|
||||||
|
var catSpan = form.querySelector('.mod-target-category');
|
||||||
|
var recSpan = form.querySelector('.mod-target-recurring');
|
||||||
|
var catSelect = catSpan.querySelector('select');
|
||||||
|
var recSelect = recSpan.querySelector('select');
|
||||||
|
var isCategory = select.value === 'category';
|
||||||
|
catSpan.style.display = isCategory ? '' : 'none';
|
||||||
|
recSpan.style.display = isCategory ? 'none' : '';
|
||||||
|
catSelect.disabled = !isCategory;
|
||||||
|
if (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { catSelect.removeAttribute('title'); }
|
||||||
|
recSelect.disabled = isCategory;
|
||||||
|
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { recSelect.removeAttribute('title'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (disabled) { el.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { el.removeAttribute('title'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDisabled('value', !(art === 'percent' || art === 'absolute'));
|
||||||
|
var valueLabel = form.querySelector('.value-label');
|
||||||
|
if (valueLabel) {
|
||||||
|
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
|
||||||
|
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
if (!isMod) { typeSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { typeSelect.removeAttribute('title'); }
|
||||||
|
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 (catSelect.disabled) { catSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { catSelect.removeAttribute('title'); }
|
||||||
|
}
|
||||||
|
if (recSelect) {
|
||||||
|
recSelect.disabled = !isMod || isCategory;
|
||||||
|
if (recSelect.disabled) { recSelect.title = 'Für diese Eintragsart nicht relevant'; }
|
||||||
|
else { recSelect.removeAttribute('title'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
fetch('/api/scenarios/' + scenarioId + '/loans/' + loanId, { method: checked ? 'POST' : 'DELETE' })
|
||||||
|
.then(function (resp) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
checkbox.checked = !checked;
|
||||||
|
alert('Zuordnung fehlgeschlagen: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -234,6 +234,21 @@ def test_admin_passwort_success_returns_200_with_confirmation(
|
|||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kategorien-Verwaltung --------------------------------------------------
|
||||||
|
|
||||||
|
def test_admin_zeigt_kategorien_verwaltung(client_with_env_file, db):
|
||||||
|
from app.models.tables import Category
|
||||||
|
client, password = client_with_env_file
|
||||||
|
client.post("/login", data={"username": "admin", "password": password})
|
||||||
|
db.add(Category(name="Admin-Kat"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/admin").text
|
||||||
|
assert "Kategorien" in r and "Admin-Kat" in r
|
||||||
|
assert "Neue Kategorie anlegen" in r
|
||||||
|
assert 'hx-patch="/api/categories/' in r
|
||||||
|
assert 'hx-post="/api/categories"' in r
|
||||||
|
|
||||||
|
|
||||||
# --- Regeln neu anwenden ---------------------------------------------------
|
# --- Regeln neu anwenden ---------------------------------------------------
|
||||||
|
|
||||||
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
|
def test_apply_rules_retroactively_categorizes_uncategorized_confirmed_tx(db):
|
||||||
|
|||||||
@@ -113,6 +113,23 @@ def test_rules_categorize(client):
|
|||||||
assert txs[0]["category_id"] == cat["id"]
|
assert txs[0]["category_id"] == cat["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_category_patch(client):
|
||||||
|
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
|
||||||
|
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
|
||||||
|
# Umbenennen
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||||
|
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
|
||||||
|
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Kollision mit anderer Kategorie -> 409
|
||||||
|
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
# unbekannte id -> 404
|
||||||
|
assert client.patch("/api/categories/99999", headers=H,
|
||||||
|
json={"name": "x"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_patch_account_name(client):
|
def test_patch_account_name(client):
|
||||||
acc = client.post("/api/accounts", headers=H, json={
|
acc = client.post("/api/accounts", headers=H, json={
|
||||||
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
"bank": "DKB", "iban": "DE71", "name": "DE71", "type": "giro"}).json()
|
||||||
@@ -229,3 +246,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
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ H = {"Authorization": "Bearer test-key"}
|
|||||||
|
|
||||||
|
|
||||||
def test_pages_require_login(client):
|
def test_pages_require_login(client):
|
||||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
|
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
|
||||||
r = client.get(path, follow_redirects=False)
|
r = client.get(path, follow_redirects=False)
|
||||||
assert r.status_code in (302, 303), path
|
assert r.status_code in (302, 303), path
|
||||||
assert r.headers["location"] == "/login"
|
assert r.headers["location"] == "/login"
|
||||||
@@ -17,7 +17,7 @@ def test_pages_require_login(client):
|
|||||||
|
|
||||||
def test_pages_render_after_login(client):
|
def test_pages_render_after_login(client):
|
||||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/hilfe"):
|
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
|
||||||
r = client.get(path)
|
r = client.get(path)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Finanzberatung" in r.text
|
assert "Finanzberatung" in r.text
|
||||||
@@ -246,6 +246,33 @@ def test_planning_page_shows_empty_suggestions_hint(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "Vorschläge aus Buchungen" in r.text
|
assert "Vorschläge aus Buchungen" in r.text
|
||||||
assert "Keine Vorschläge" in r.text
|
assert "Keine Vorschläge" in r.text
|
||||||
|
assert ("Erkannt werden monatliche, vierteljährliche und jährliche "
|
||||||
|
"Serien; Betrag = jeweils letzte Buchung.") in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
|
||||||
|
# Synthetische vierteljaehrliche Serie relativ zu date.today(), da die
|
||||||
|
# Route suggest_recurring() ohne today-Injektion aufruft (echter
|
||||||
|
# Aktiv-Check gegen date.today()). Schrittweite ~91 Tage rueckwaerts,
|
||||||
|
# letzte Buchung 30 Tage vor heute (innerhalb des Aktiv-Fensters).
|
||||||
|
from app.models.tables import Transaction
|
||||||
|
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
|
||||||
|
db.add(acc)
|
||||||
|
db.flush()
|
||||||
|
today = date.today()
|
||||||
|
booking_dates = [today - timedelta(days=d) for d in (303, 212, 121, 30)]
|
||||||
|
for d in booking_dates:
|
||||||
|
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
|
||||||
|
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
|
||||||
|
dedup_hash=f"sug-{d.isoformat()}"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/planung").text
|
||||||
|
letzte_buchung = booking_dates[-1]
|
||||||
|
assert "vierteljährlich" in r
|
||||||
|
assert letzte_buchung.strftime("%d.%m.%Y") in r
|
||||||
|
assert 'name="start_date"' in r
|
||||||
|
|
||||||
|
|
||||||
def test_salden_page_stichtag_and_month_overview(client, db):
|
def test_salden_page_stichtag_and_month_overview(client, db):
|
||||||
@@ -317,15 +344,23 @@ def test_planung_hat_bearbeiten_formulare(client, db):
|
|||||||
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
|
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
|
||||||
annual_rate_pct=Decimal("4.50"), term_months=12,
|
annual_rate_pct=Decimal("4.50"), term_months=12,
|
||||||
payout_date=date(2026, 8, 1), repayment_type="annuity"))
|
payout_date=date(2026, 8, 1), repayment_type="annuity"))
|
||||||
db.add(Scenario(name="EditS", description="d"))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
r = client.get("/planung").text
|
r = client.get("/planung").text
|
||||||
assert r.count(">Bearbeiten</button>") >= 4
|
assert r.count(">Bearbeiten</button>") >= 3
|
||||||
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
|
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
|
||||||
'hx-patch="/api/loans/', 'hx-patch="/api/scenarios/'):
|
'hx-patch="/api/loans/'):
|
||||||
assert fragment in r, fragment
|
assert fragment in r, fragment
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenarien_hat_bearbeiten_formular(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(Scenario(name="EditS", description="d"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/szenarien").text
|
||||||
|
assert r.count(">Bearbeiten</button>") >= 1
|
||||||
|
assert 'hx-patch="/api/scenarios/' in r
|
||||||
|
|
||||||
|
|
||||||
def test_salden_shows_ohne_anker_footnote(client, db):
|
def test_salden_shows_ohne_anker_footnote(client, db):
|
||||||
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
|
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
|
||||||
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
|
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
|
||||||
@@ -350,10 +385,71 @@ def test_szenario_gui_ende_und_einmalzahlungen(client, db):
|
|||||||
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
|
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
|
||||||
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
|
||||||
db.commit()
|
db.commit()
|
||||||
r = client.get("/planung").text
|
r = client.get("/szenarien").text
|
||||||
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("/szenarien").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
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenarien_seite_und_nav(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(Scenario(name="Seiten-Test", description="d"))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/szenarien")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
|
||||||
|
assert 'href="/szenarien"' in r.text # Nav-Punkt
|
||||||
|
# Planung enthaelt keine Szenarien-Sektion mehr:
|
||||||
|
p = client.get("/planung").text
|
||||||
|
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
|
||||||
|
assert "Wiederkehrende Posten" in p and "Kredite" in p
|
||||||
|
|
||||||
|
|
||||||
|
def test_szenarien_seite_braucht_login(client):
|
||||||
|
r = client.get("/szenarien", follow_redirects=False)
|
||||||
|
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuer_eintrag_formular_struktur(client, db):
|
||||||
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
|
db.add(Scenario(name="UX-Test", description=""))
|
||||||
|
db.commit()
|
||||||
|
r = client.get("/szenarien").text
|
||||||
|
assert 'class="entry-form"' in r
|
||||||
|
assert 'class="entry-art"' in r # Eintragsart-Zeile
|
||||||
|
assert 'class="value-label"' in r # dynamisches Wert-Label
|
||||||
|
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
|
||||||
|
assert '<hr' in r # Durchrechnen abgesetzt
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from itertools import count
|
||||||
|
|
||||||
from app.models.tables import Account, Category, RecurringItem, Transaction
|
from app.models.tables import Account, Category, RecurringItem, Transaction
|
||||||
from app.services.suggestions import suggest_recurring
|
from app.services.suggestions import _Series, _try_merge, suggest_recurring
|
||||||
|
|
||||||
|
# Feste Vergleichs-"heute" fuer alle Vorschlags-Tests (Ausbaustufe 9): macht
|
||||||
|
# den Aktiv-Check deterministisch, ohne echtes date.today() im Testlauf.
|
||||||
|
TODAY = date(2026, 7, 20)
|
||||||
|
|
||||||
H = {"Authorization": "Bearer test-key"}
|
H = {"Authorization": "Bearer test-key"}
|
||||||
|
|
||||||
@@ -72,42 +77,60 @@ def test_loan_in_scenario_keeps_balance_positive(client, db):
|
|||||||
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
assert Decimal(body["low_point_balance"]) > Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
def _tx(acc, d, amount, counterparty, dedup, category_id=None):
|
_iban_seq = count(1)
|
||||||
return Transaction(account_id=acc.id, booking_date=d, amount=Decimal(amount),
|
|
||||||
purpose="", counterparty=counterparty, status="confirmed",
|
|
||||||
dedup_hash=dedup, category_id=category_id)
|
def _acc(db) -> int:
|
||||||
|
"""Legt ein Konto an und gibt dessen id zurueck. IBAN ist je Aufruf
|
||||||
|
eindeutig (falls ein Test mehrere Konten braucht), Praefix "DE" plus
|
||||||
|
laufende Nummer reicht dafuer aus."""
|
||||||
|
acc = Account(bank="dkb", iban=f"DE{next(_iban_seq):032d}"[:34], name="G", type="giro")
|
||||||
|
db.add(acc)
|
||||||
|
db.flush()
|
||||||
|
return acc.id
|
||||||
|
|
||||||
|
|
||||||
|
def _tx(db, acc_id, d, amount, cp, cat=None):
|
||||||
|
"""Legt eine bestaetigte Buchung an. dedup_hash aus den Nutzdaten
|
||||||
|
abgeleitet reicht fuer Testzwecke (muss nur innerhalb eines Tests
|
||||||
|
eindeutig sein)."""
|
||||||
|
db.add(Transaction(account_id=acc_id, booking_date=d, amount=Decimal(amount),
|
||||||
|
purpose="p", counterparty=cp, category_id=cat,
|
||||||
|
status="confirmed", dedup_hash=f"h-{cp}-{d}-{amount}"))
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
|
def test_suggest_recurring_three_consecutive_months_with_year_wrap(db):
|
||||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
acc = _acc(db)
|
||||||
db.add(acc)
|
|
||||||
db.flush()
|
|
||||||
cat = Category(name="Miete")
|
cat = Category(name="Miete")
|
||||||
db.add(cat)
|
db.add(cat)
|
||||||
db.flush()
|
db.flush()
|
||||||
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
|
# Dez 2025 -> Jan 2026 -> Feb 2026: 3 aufeinanderfolgende Monate ueber den
|
||||||
# Jahreswechsel hinweg (prueft die Monats-Linearisierung y*12+m).
|
# Jahreswechsel hinweg, gleichmaessiger 31-Tage-Abstand (Median im
|
||||||
db.add(_tx(acc, date(2025, 12, 1), "-600.00", "Vermieter", "h1", cat.id))
|
# monatlichen Fenster 25-36 Tage; die alte Jahreswechsel-Pruefung galt der
|
||||||
db.add(_tx(acc, date(2026, 1, 15), "-600.00", "Vermieter", "h2", cat.id))
|
# Monats-Linearisierung, die es im neuen Tage-basierten Median-Ansatz
|
||||||
db.add(_tx(acc, date(2026, 2, 28), "-600.00", "Vermieter", "h3", cat.id))
|
# nicht mehr braucht).
|
||||||
|
_tx(db, acc, date(2025, 12, 1), "-600.00", "Vermieter", cat.id)
|
||||||
|
_tx(db, acc, date(2026, 1, 1), "-600.00", "Vermieter", cat.id)
|
||||||
|
_tx(db, acc, date(2026, 2, 1), "-600.00", "Vermieter", cat.id)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
out = suggest_recurring(db)
|
out = suggest_recurring(db, today=date(2026, 2, 10))
|
||||||
|
|
||||||
# Median der Tage [1, 15, 28] = 15; Betrag unveraendert uebernommen.
|
# due_day = Tag der NEUESTEN Buchung (nicht mehr Median, Spec Schritt 7);
|
||||||
|
# Betrag unveraendert, keine Preissteigerung -> hinweis leer.
|
||||||
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
|
assert out == [{"name": "Vermieter", "amount": Decimal("-600.00"),
|
||||||
"rhythm": "monthly", "due_day": 15, "category_id": cat.id}]
|
"rhythm": "monthly", "due_day": 1, "start_date": None,
|
||||||
|
"category_id": cat.id, "hinweis": ""}]
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_recurring_two_months_no_suggestion(db):
|
def test_suggest_recurring_two_months_no_suggestion(db):
|
||||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
acc = _acc(db)
|
||||||
db.add(acc)
|
_tx(db, acc, date(2026, 3, 10), "-50.00", "Zweimonatig")
|
||||||
db.flush()
|
_tx(db, acc, date(2026, 4, 10), "-50.00", "Zweimonatig")
|
||||||
db.add(_tx(acc, date(2026, 3, 10), "-50.00", "Zweimonatig", "h1"))
|
|
||||||
db.add(_tx(acc, date(2026, 4, 10), "-50.00", "Zweimonatig", "h2"))
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
assert suggest_recurring(db) == []
|
# Nur 2 Belege: Mindestbelege fuer monthly (>=3) nicht erreicht.
|
||||||
|
assert suggest_recurring(db, today=date(2026, 4, 20)) == []
|
||||||
|
|
||||||
|
|
||||||
def test_recurring_ende_vor_start_wird_abgelehnt(client):
|
def test_recurring_ende_vor_start_wird_abgelehnt(client):
|
||||||
@@ -135,15 +158,213 @@ def test_recurring_start_ende_roundtrip_und_patch_validierung(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_suggest_recurring_excludes_existing_recurring_item(db):
|
def test_suggest_recurring_excludes_existing_recurring_item(db):
|
||||||
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
|
acc = _acc(db)
|
||||||
db.add(acc)
|
_tx(db, acc, date(2026, 1, 5), "-30.00", "Streaming")
|
||||||
db.flush()
|
_tx(db, acc, date(2026, 2, 5), "-30.00", "Streaming")
|
||||||
db.add(_tx(acc, date(2026, 1, 5), "-30.00", "Streaming", "h1"))
|
_tx(db, acc, date(2026, 3, 5), "-30.00", "Streaming")
|
||||||
db.add(_tx(acc, date(2026, 2, 5), "-30.00", "Streaming", "h2"))
|
|
||||||
db.add(_tx(acc, date(2026, 3, 5), "-30.00", "Streaming", "h3"))
|
|
||||||
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
|
db.add(RecurringItem(name="Streaming", amount=Decimal("-30.00"),
|
||||||
rhythm="monthly", due_day=5))
|
rhythm="monthly", due_day=5))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Gleicher Name + Betrag wie ein bereits vorhandenes RecurringItem -> ausgelassen.
|
# Gleicher Name wie ein bereits vorhandenes RecurringItem -> Bestandsabgleich
|
||||||
assert suggest_recurring(db) == []
|
# (Schritt 8, Namens-Match) greift, unabhaengig vom Betrag.
|
||||||
|
assert suggest_recurring(db, today=date(2026, 3, 20)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- Ausbaustufe 9: Algorithmus v2
|
||||||
|
# Synthetische Faelle aus der Spec (siehe
|
||||||
|
# docs/superpowers/specs/2026-07-20-vorschlags-algorithmus-v2-design.md).
|
||||||
|
|
||||||
|
def test_suggest_letzter_betrag_bei_preiserhoehung(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for d, a in [(date(2026, 3, 1), "-190.65"), (date(2026, 4, 1), "-202.94"),
|
||||||
|
(date(2026, 5, 4), "-202.94"), (date(2026, 6, 1), "-202.94"),
|
||||||
|
(date(2026, 7, 1), "-202.94")]:
|
||||||
|
_tx(db, acc, d, a, "Entis Lebensversicherung AG")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
s = out[0]
|
||||||
|
assert s["amount"] == Decimal("-202.94") and s["rhythm"] == "monthly"
|
||||||
|
assert s["due_day"] == 1 and s["start_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_quartal_mit_phase(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for d in [date(2025, 9, 15), date(2025, 12, 15), date(2026, 3, 16), date(2026, 6, 15)]:
|
||||||
|
_tx(db, acc, d, "-55.08", "Rundfunk ARD ZDF")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["rhythm"] == "quarterly"
|
||||||
|
assert out[0]["start_date"] == date(2026, 6, 15) and out[0]["due_day"] == 15
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_jahr_mit_zwei_belegen(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for d, a in [(date(2025, 6, 16), "-409.92"), (date(2026, 6, 16), "-467.33")]:
|
||||||
|
_tx(db, acc, d, a, "Kraftfahrer-Schutz e.V.")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["rhythm"] == "yearly" and out[0]["amount"] == Decimal("-467.33")
|
||||||
|
assert out[0]["start_date"] == date(2026, 6, 16)
|
||||||
|
assert "409,92" in out[0]["hinweis"] # Betrag zuletzt gestiegen (deutsches Format)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_tote_serie_kein_vorschlag(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for m in (9, 10, 11, 12):
|
||||||
|
_tx(db, acc, date(2025, m, 1), "-35.00", "WWK Alt")
|
||||||
|
db.commit()
|
||||||
|
assert suggest_recurring(db, today=TODAY) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_umfirmierung_merge(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for m in (11, 12):
|
||||||
|
_tx(db, acc, date(2025, m, 1), "-190.65", "Heidelberger Leben")
|
||||||
|
for m in (1, 2, 3):
|
||||||
|
_tx(db, acc, date(2026, m, 2), "-190.65", "Entis Lebensversicherung")
|
||||||
|
for m in (4, 5, 6, 7):
|
||||||
|
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert "Entis" in out[0]["name"] and out[0]["amount"] == Decimal("-202.94")
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_bestandsabgleich_trotz_preisdrift(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
db.add(RecurringItem(name="Entis Lebensversicherung AG", amount=Decimal("-190.65"),
|
||||||
|
rhythm="monthly", due_day=1))
|
||||||
|
for m in (4, 5, 6, 7):
|
||||||
|
_tx(db, acc, date(2026, m, 1), "-202.94", "Entis Lebensversicherung AG")
|
||||||
|
db.commit()
|
||||||
|
assert suggest_recurring(db, today=TODAY) == [] # Namens-Match schlaegt an
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_zwei_vertraege_getrennt(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for m in (4, 5, 6, 7):
|
||||||
|
_tx(db, acc, date(2026, m, 1), "-346.23", "Heidelberger LV")
|
||||||
|
_tx(db, acc, date(2026, m, 2), "-145.21", "Heidelberger LV")
|
||||||
|
db.commit()
|
||||||
|
out = suggest_recurring(db, today=TODAY)
|
||||||
|
assert len(out) == 2
|
||||||
|
assert {s["amount"] for s in out} == {Decimal("-346.23"), Decimal("-145.21")}
|
||||||
|
# Nachtrag 3b: beide Vertraege sind fuer sich genommen stabile,
|
||||||
|
# qualifizierte Serien - die jeweils neuere Buchung des ANDEREN Vertrags
|
||||||
|
# gehoert selbst zu einer qualifizierten Serie und darf deshalb NICHT als
|
||||||
|
# "abgetrennte neueste Buchung" gewertet werden (sonst waere einer der
|
||||||
|
# beiden faelschlich als "volatil" markiert, nur weil der andere Vertrag
|
||||||
|
# einen Tag spaeter faellig ist).
|
||||||
|
assert all(s["hinweis"] == "" for s in out)
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_merge_kombiniert_serien_ueber_gruppengrenzen():
|
||||||
|
# Direkter, isolierter Test der Merge-Mechanik (Schritt 6): siehe Report
|
||||||
|
# fuer den rechnerischen Nachweis, dass ein End-to-End-Szenario, in dem
|
||||||
|
# die ALTE und die NEUE Serie GLEICHZEITIG unabhaengig voneinander den
|
||||||
|
# Aktiv-Check bestehen, fuer keinen der drei Rhythmen innerhalb von
|
||||||
|
# WINDOW_DAYS=460 konstruierbar ist (die alte Serie ist beim Aktiv-Check
|
||||||
|
# immer laengst "tot", sobald die neue genug eigene Belege hat, bzw. bei
|
||||||
|
# yearly passt die noetige Gesamtspanne nicht ins Fenster). Deshalb hier
|
||||||
|
# `_try_merge` direkt gegen zwei synthetische `_Series` geprueft, ganz ohne
|
||||||
|
# DB/Fenster/Aktiv-Check-Interaktion.
|
||||||
|
a = _Series(items=[
|
||||||
|
Transaction(booking_date=date(2026, 1, 3), amount=Decimal("-50.00"),
|
||||||
|
counterparty="Alte Firma GmbH", category_id=None),
|
||||||
|
Transaction(booking_date=date(2026, 2, 3), amount=Decimal("-50.00"),
|
||||||
|
counterparty="Alte Firma GmbH", category_id=None),
|
||||||
|
Transaction(booking_date=date(2026, 3, 3), amount=Decimal("-50.00"),
|
||||||
|
counterparty="Alte Firma GmbH", category_id=None),
|
||||||
|
], rhythm="monthly")
|
||||||
|
b = _Series(items=[
|
||||||
|
Transaction(booking_date=date(2026, 4, 5), amount=Decimal("-52.00"),
|
||||||
|
counterparty="Neue Firma GmbH", category_id=None),
|
||||||
|
Transaction(booking_date=date(2026, 5, 5), amount=Decimal("-52.00"),
|
||||||
|
counterparty="Neue Firma GmbH", category_id=None),
|
||||||
|
Transaction(booking_date=date(2026, 6, 5), amount=Decimal("-52.00"),
|
||||||
|
counterparty="Neue Firma GmbH", category_id=None),
|
||||||
|
], rhythm="monthly")
|
||||||
|
|
||||||
|
merged = _try_merge([a, b])
|
||||||
|
|
||||||
|
# Luecke A-Ende->B-Anfang = 33 Tage (in [12,48]), Faelligkeitstag 3 vs 5
|
||||||
|
# (Differenz 2 <= 3), Betrag +4% (<=25%) -> Bedingungen erfuellt, genau
|
||||||
|
# EINE kombinierte Serie mit allen 6 Buchungen, juengste zuerst.
|
||||||
|
assert len(merged) == 1
|
||||||
|
assert len(merged[0].items) == 6
|
||||||
|
assert merged[0].last.counterparty == "Neue Firma GmbH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_umfirmierung_merge_verschiebt_kategorie_mehrheit(db):
|
||||||
|
# End-to-End-Nachweis, dass Schritt 6 tatsaechlich in `suggest_recurring`
|
||||||
|
# verdrahtet ist: da die Anzahl der Vorschlaege sich (bewiesenermassen,
|
||||||
|
# siehe Report) end-to-end NICHT als Diskriminator eignet (die alte Serie
|
||||||
|
# faellt so oder so per Aktiv-Check heraus), wird hier die
|
||||||
|
# Kategorie-Mehrheit als Diskriminator genutzt - die haengt direkt davon
|
||||||
|
# ab, ob die Buchungen der alten Serie ueber den Merge in die Zaehlung
|
||||||
|
# eingehen. Alte Serie: 4 Buchungen Kategorie A. Neue Serie: 3 Buchungen
|
||||||
|
# Kategorie B. Ohne Merge zaehlen nur die 3 B-Buchungen (Mehrheit B). Mit
|
||||||
|
# Merge kommen die 4 A-Buchungen dazu und kippen die Mehrheit auf A.
|
||||||
|
acc = _acc(db)
|
||||||
|
cat_a = Category(name="Alt-Kategorie")
|
||||||
|
cat_b = Category(name="Neu-Kategorie")
|
||||||
|
db.add(cat_a)
|
||||||
|
db.add(cat_b)
|
||||||
|
db.flush()
|
||||||
|
for d in [date(2026, 1, 3), date(2026, 2, 3), date(2026, 3, 3), date(2026, 4, 3)]:
|
||||||
|
_tx(db, acc, d, "-50.00", "Alte Firma GmbH", cat_a.id)
|
||||||
|
for d in [date(2026, 5, 5), date(2026, 6, 5), date(2026, 7, 5)]:
|
||||||
|
_tx(db, acc, d, "-52.00", "Neue Firma GmbH", cat_b.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
out = suggest_recurring(db, today=date(2026, 7, 20))
|
||||||
|
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["name"] == "Neue Firma GmbH" and out[0]["amount"] == Decimal("-52.00")
|
||||||
|
# Kategorie-Mehrheit kippt durch den Merge von B (3) auf A (4):
|
||||||
|
assert out[0]["category_id"] == cat_a.id
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- Nachtrag 4 (Live-Gate-Fund)
|
||||||
|
# Live-Gate-Fund (Muster, keine echten Kontodaten - Namen/Betraege hier rein
|
||||||
|
# synthetisch): ein kuratiertes Sammel-Fixposten unter Alias-Namen des
|
||||||
|
# Anbieters ("Kreditkarten-Abrechnung ... (variabel)") deckte den vom
|
||||||
|
# Algorithmus erkannten Vorschlag desselben Anbieters unter dessen
|
||||||
|
# regulaerem Empfaenger-Namen nicht ab, weil weder Substring- noch
|
||||||
|
# Betrags-Toleranz-Regel griffen.
|
||||||
|
|
||||||
|
def test_suggest_alias_recurring_item_token_match(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
db.add(RecurringItem(name="Kreditkarten-Abrechnung Musterbank (variabel)",
|
||||||
|
amount=Decimal("-250.00"), rhythm="monthly", due_day=7))
|
||||||
|
for d, a in [(date(2026, 4, 5), "-560.00"), (date(2026, 5, 5), "-575.00"),
|
||||||
|
(date(2026, 6, 5), "-590.00"), (date(2026, 7, 5), "-575.00")]:
|
||||||
|
_tx(db, acc, d, a, "Musterbank Neustadt eG")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Substring-Match (a) schlaegt fehl (kein Teilstring gemeinsam), Betrags-
|
||||||
|
# Toleranz (b) auch (-575 vs. -250.00, >10%) - erst der Token-Match (c)
|
||||||
|
# ueber das gemeinsame Token "musterbank" (Rhythmus gleich, due_day 5 vs. 7
|
||||||
|
# -> Differenz 2 <= 2) deckt den Vorschlag ab.
|
||||||
|
assert suggest_recurring(db, today=TODAY) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_volatilitaetshinweis_bei_abgetrennter_neuester_buchung(db):
|
||||||
|
acc = _acc(db)
|
||||||
|
for d in [date(2026, 1, 5), date(2026, 2, 5), date(2026, 3, 5), date(2026, 4, 5)]:
|
||||||
|
_tx(db, acc, d, "-200.00", "Schwankender Anbieter GmbH")
|
||||||
|
# Neueste Buchung weicht >35% vom Serien-Betrag ab -> eigener Cluster,
|
||||||
|
# klassifiziert selbst nicht (nur 1 Buchung) -> die vorgeschlagene Serie
|
||||||
|
# bleibt die -200.00-Serie, aber mit Volatilitaets-Warnhinweis.
|
||||||
|
_tx(db, acc, date(2026, 5, 5), "-600.00", "Schwankender Anbieter GmbH")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
out = suggest_recurring(db, today=date(2026, 5, 20))
|
||||||
|
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["amount"] == Decimal("-200.00") # Betrag NICHT durch die 600er-Buchung verfaelscht
|
||||||
|
assert "schwanken" in out[0]["hinweis"].lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user