docs: Spec Ausbaustufe 12 (System-Präfix, Doppelzählungs-Warnung Kredit/Fixposten)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:51:19 +02:00
co-authored by Claude Opus 5.5
parent ffd7647c5f
commit 404c1a5648
@@ -0,0 +1,249 @@
# Design — Ausbaustufe 12: System-Präfix + Doppelzählungs-Warnung Kredit/Fixposten (v0.12.0)
> Status: Design im Chat vom Nutzer freigegeben (2026-09-28), Spec zur
> Durchsicht. Anlass: (1) Eine echte Kategorie „Kreditraten" würde im
> Grafana-Panel „Planung nach Kategorie" mit der synthetischen
> Kreditraten-Serie verschmelzen. (2) Wird eine Kreditrate sowohl als
> wiederkehrender Posten als auch als Kredit gepflegt, zählt sie in Planung
> und Projektion doppelt — das passiert leicht versehentlich, ein Hilfetext
> allein schützt nicht.
## Nutzerentscheidungen (2026-09-28)
- **Reservierter Präfix „System-"** für alle vom Tool erzeugten Serien.
Kategorie- und Szenarionamen mit diesem Präfix werden abgelehnt.
Kreditraten-Serie heißt künftig **„System-Kreditraten"**; „System-Ist"
fällt unter dieselbe Regel.
- **„unkategorisiert" bleibt** unverändert (Verschmelzen mit einer echten
gleichnamigen Kategorie ist semantisch harmlos; Beschriftung bleibt
gleich zum Ist-Diagramm „Monat nach Kategorie").
- **Aktiver Doppelzählungs-Check** statt Hilfevermerk: Speichern wird bei
Verdacht blockiert und ist nur nach **expliziter Bestätigung** in einem
Warnfenster möglich.
- **Toleranz** Betragsvergleich: **±3 %, mindestens 1,00 €**.
- **Dauerhafter Hinweis** auf der Planung-Seite an betroffenen Posten und
Krediten (nicht blockierend).
- **Version v0.12.0** (neues API-Verhalten).
## Befund (Ist-Zustand, v0.11.0)
- `RESERVED_SCENARIO_NAME = "System-Ist"` in `models/views.py`;
`routers/scenarios.py::_check_reserved_name` prüft nur diesen einen Namen.
- `services/planning_chart.py`: `KREDITRATEN = "Kreditraten"`.
- `routers/categories.py`: `create_category`/`patch_category` ohne
Namensreservierung.
- `routers/planning.py`: `create_recurring`/`patch_recurring`,
`create_loan`/`patch_loan` ohne Querprüfung. Schemas: `RecurringIn`
(Basis von `RecurringOut`), `RecurringPatch`, `LoanIn` (Basis von
`LoanOut`), `LoanPatch`.
- GUI-Formulare gegen diese Endpunkte (alle `hx-ext="json-form"`,
`planning.html`): Fixposten anlegen (`hx-post /api/recurring`),
bearbeiten (`hx-patch /api/recurring/{id}`), „Vorschlag übernehmen"
(`hx-post /api/recurring`), Kredit anlegen (`hx-post /api/loans`),
bearbeiten (`hx-patch /api/loans/{id}`).
- Fehleranzeige global in `base.html`: `htmx:responseError` →
`alert('Fehler: ' + responseText)`.
- Live-Bestand geprüft (2026-09-28): keine Kategorie/kein Szenario beginnt
mit „System-", keine Kategorie heißt „Kreditraten"/„unkategorisiert";
0 Kredite.
- Beratungsumgebung `/home/wlfb/fb`: Skill `finanz-api` dokumentiert
`POST /api/recurring` und `POST /api/loans`, Skill `finanzberatung` legt
Kredite per API an → vom neuen 409-Verhalten betroffen.
## 1. Reservierter Präfix „System-"
Neues Modul `app/reserved.py` (einzige Quelle):
```python
SYSTEM_PREFIX = "System-"
def is_reserved_name(name: str) -> bool:
"""True, wenn name (strip + casefold) mit SYSTEM_PREFIX beginnt."""
def check_reserved_name(name: str | None) -> None:
"""None -> nichts; reserviert -> HTTPException(422, <Meldung>)."""
```
Meldung (422): **„Namen, die mit »System-« beginnen, sind für vom Tool
erzeugte Einträge reserviert"**.
- `models/views.py`: `RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"`
(Wert unverändert „System-Ist", View-SQL unverändert).
- `services/planning_chart.py`: `KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"`.
- `routers/scenarios.py`: `_check_reserved_name` entfällt, stattdessen
`check_reserved_name` in `create_scenario` und `patch_scenario` (gleiche
Stellen wie bisher).
- `routers/categories.py`: `check_reserved_name(data.name)` als erste
Zeile in `create_category` und `patch_category`.
- Grafana-Panel 5 zeigt ab dem nächsten Seitenaufruf „System-Kreditraten"
(Tabelle `planning_monthly` wird ohnehin komplett neu geschrieben — keine
Migration nötig).
Tests: Kategorien und Szenarien je POST/PATCH mit „System-X",
„system-kreditraten", „ SYSTEM-Ist " → 422 mit Meldung; „Kreditraten",
„Systemkosten", „Ist-System" → erlaubt (201/200). Planning-Chart-Tests
nutzen die Konstante (Wert „System-Kreditraten").
## 2. Doppelzählungs-Erkennung (`app/services/double_count.py`)
Reine Logik ohne HTTP-Abhängigkeit.
```python
TOLERANZ_PROZENT = Decimal("3")
TOLERANZ_MIN = Decimal("1.00")
CODE = "doppelzaehlung_kredit"
@dataclass(frozen=True)
class Konflikt:
posten_id: int | None # None = Posten noch nicht gespeichert
posten_name: str
posten_betrag: Decimal # negativ
kredit_id: int | None # None = Kredit noch nicht gespeichert
kredit_name: str
kredit_rate: Decimal # positiv, reguläre Monatsrate
def regular_rate(principal, annual_rate_pct, term_months, repayment_type) -> Decimal | None
def rate_period(payout_date, term_months) -> tuple[date, date]
def is_conflict(posten, kredit) -> bool
def conflicts_for_recurring(session, posten, exclude_id=None) -> list[Konflikt]
def conflicts_for_loan(session, kredit, exclude_id=None) -> list[Konflikt]
def all_conflicts(session) -> list[Konflikt]
```
`posten`/`kredit` sind einfache Wertobjekte (Name, Betrag bzw. Kreditdaten,
Rhythmus, Start/Ende) — so lassen sich auch noch nicht gespeicherte bzw.
per PATCH veränderte Zustände prüfen.
**Reguläre Monatsrate** (`regular_rate`, positiv, auf Cent gerundet):
- `annuity`: `engine/loans.py::annuity_payment(principal, rate, term)`.
- `bullet`: monatliche Zinsen `principal × rate / 100 / 12` (ROUND_HALF_UP).
Bei Zinssatz 0 → `None` (keine reguläre Rate → nie Konflikt).
- Schlusszahlung endfälliger Kredite wird nicht betrachtet.
**Ratenzeitraum** (`rate_period`): erste Rate
`add_months(payout_date, 1)`, letzte Rate `add_months(payout_date,
term_months)` (identisch zu `loan_schedule`).
**Konflikt** (`is_conflict`), wenn ALLE gelten:
1. Posten-Rhythmus `monthly` und Posten-Betrag `< 0`;
2. Kredit hat eine reguläre Rate `r` (nicht `None`);
3. `abs(abs(betrag) − r) ≤ max(TOLERANZ_MIN, r × TOLERANZ_PROZENT / 100)`;
4. Zeiträume überschneiden sich: `(start is None or start ≤ letzte_rate)
and (ende is None or ende ≥ erste_rate)`.
Kein Namensvergleich (unzuverlässig). Bekannte Grenze: weicht der Posten
stärker als die Toleranz ab (z.B. Rate inkl. Versicherung), wird nichts
erkannt. Alle Beträge `Decimal`.
Tests (Handrechnung, künstliche Werte): Annuität exakt / knapp innerhalb /
knapp außerhalb der 3 %-Grenze; Mindesttoleranz 1,00 € bei kleiner Rate;
endfällig (Zinsrate) inkl. Zinssatz 0 → kein Konflikt; Rhythmus
quarterly → kein Konflikt; positiver Betrag → kein Konflikt; Zeiträume
disjunkt (Posten endet vor erster Rate / startet nach letzter Rate) →
kein Konflikt; offene Start/Ende → Konflikt; `exclude_id` schließt den
bearbeiteten Datensatz selbst aus; `all_conflicts` findet mehrere Paare.
## 3. API-Verhalten (`routers/planning.py`)
- Neues Anfragefeld **`doppelzaehlung_bestaetigt: bool = False`** — nur in
den Request-Modellen (`RecurringCreate(RecurringIn)`, `RecurringPatch`,
`LoanCreate(LoanIn)`, `LoanPatch`), **nicht** in `RecurringOut`/`LoanOut`,
nicht persistiert, beim Anlegen der ORM-Objekte aus `model_dump`
ausgeschlossen.
- **POST** `/api/recurring` bzw. `/api/loans`: vor dem Speichern
`conflicts_for_recurring`/`conflicts_for_loan` für den neuen Datensatz;
Treffer und Flag nicht gesetzt → **409** (nichts gespeichert).
- **PATCH** `/api/recurring/{id}` bzw. `/api/loans/{id}`: Konflikte vor und
nach Anwendung der Änderung ermitteln (bezogen auf diesen Datensatz);
409 nur, wenn nach der Änderung ein Konflikt-Paar existiert, das vorher
nicht existierte (ein bereits bestätigter Konflikt fragt bei reiner
Namensänderung nicht erneut). Bei 409 `session.rollback()`.
- **409-Antwort** (`HTTPException(409, detail={...})`):
```json
{"detail": {"code": "doppelzaehlung_kredit",
"meldung": "<deutscher Erklärtext>",
"konflikte": [{"posten": "...", "posten_betrag": "-312.50",
"kredit": "...", "kredit_rate": "312.50"}]}}
```
Beträge als Strings mit Punkt-Dezimal (API-Konvention).
- Flag `true` → Speichern trotz Konflikt (201/200 wie bisher).
- Andere Endpunkte unverändert (Einmalposten, Szenario-Zuordnung).
Tests: je Endpunkt (POST/PATCH × Posten/Kredit) Konflikt → 409 mit
`code`, `konflikte` und nichts gespeichert/geändert; mit Flag → 201/200 und
gespeichert; ohne Konflikt → unverändert 201/200; PATCH einer reinen
Namensänderung bei bestehendem Konflikt → 200 ohne Flag; `RecurringOut`/
`LoanOut` enthalten kein `doppelzaehlung_bestaetigt`; bestehende Tests
bleiben grün.
## 4. GUI: Warnfenster (`base.html`)
- Ein `<dialog id="doppelzaehlung-dialog">` im Basis-Layout mit Titel
„Mögliche Doppelzählung", Erklärtext (`meldung`), Liste der Konflikte
(„Posten „X" (−312,50 €) ↔ Kredit „Y" (Rate 312,50 €)", Beträge deutsch
formatiert) und Warnsatz („Beide zusammen zählen die Rate doppelt —
Planung und Szenarien wären zu pessimistisch."). Buttons:
**„Abbrechen"** (`autofocus`, schließt ohne Speichern) und
**„Trotzdem speichern"**.
- `htmx:responseError`-Handler: Status 409 und JSON-`detail.code ===
"doppelzaehlung_kredit"` → Dialog füllen + `showModal()`, **kein**
`alert`. Alle anderen Fehler wie bisher per `alert`.
- „Trotzdem speichern": setzt am auslösenden Formular
`data-doppelzaehlung-bestaetigt="1"` und löst es erneut aus
(`htmx.trigger(form, 'submit')`); die `json-form`-Extension ergänzt bei
gesetztem Attribut `doppelzaehlung_bestaetigt: true`; nach dem Request
(`htmx:afterRequest`) wird das Attribut wieder entfernt.
- Gilt automatisch für alle fünf Formulare (Anlegen/Bearbeiten Posten,
Vorschlag übernehmen, Anlegen/Bearbeiten Kredit).
- UX-Regel: Formular bleibt sichtbar; Abbrechen lässt Eingaben stehen.
## 5. Dauerhafter Hinweis (Planung-Seite)
- `routers/gui.py::planung_page` ermittelt `all_conflicts(session)` und
übergibt je Posten-ID bzw. Kredit-ID die Gegenseite(n).
- `planning.html`: in der Namens-Zelle betroffener Posten/Kredite ein
`<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „Y"</span>`
bzw. „… mit Posten „X"". Die Namens-Zelle bekommt `data-sort="{{ name }}"`,
damit der Hinweis die Sortierung nicht beeinflusst.
- CSS `.warn-hint` dezent (kleine Schrift, Warnfarbe), nicht blockierend.
Tests: GUI `/planung` mit einem Konflikt-Paar zeigt den Hinweis an Posten
und Kredit; ohne Konflikt kein Hinweis; `data-sort` der Namenszellen
enthält nur den Namen.
## 6. Doku / Beratungsumgebung / Release
- `hilfe.html`: kurzer Absatz „Reserviert: Namen mit »System-«" und
„Doppelzählungs-Warnung Kredit/Fixposten" (was geprüft wird, Toleranz,
Bestätigung, Hinweis in der Planung).
- `docs/ARCHITEKTUR.md`: `reserved.py`, `double_count.py` (Diagrammnamen
müssen im Code existieren).
- **`/home/wlfb/fb/.claude/skills/finanz-api/SKILL.md`** (eigenes Repo,
eigener Commit): Abschnitte „Wiederkehrende Posten" und „Kredite" um die
409-Antwort `doppelzaehlung_kredit` und das Feld
`doppelzaehlung_bestaetigt` ergänzen, mit der Regel: **Flag nur nach
ausdrücklicher Rückfrage beim Nutzer setzen**; Reservierung „System-" für
Kategorien/Szenarien erwähnen.
- `finance/VERSION` 0.11.0 → **0.12.0**; Backup, Redeploy, Live-Smoke
(Wegwerf-Posten/-Kredit mit Konflikt → 409, mit Flag → 201, wieder
löschen; Kategorie „System-Test" → 422; Panel 5 zeigt
„System-Kreditraten" nur wenn Kredite existieren; Bestandszahlen
unverändert).
## Abgrenzung
- Keine Prüfung von Einmalposten (z.B. Schlusszahlung endfällig).
- Keine Prüfung bei Szenario-Kredit-Zuordnung.
- Kein Namensabgleich Posten↔Kredit.
- Keine Umbenennung von „unkategorisiert".
- Keine Migration (keine Schemaänderung).
## Rahmenregeln
- DATENSCHUTZ: Tests nur mit künstlichen Werten; keine Live-Beträge/-Namen
in Commits, Ledger, Chat.
- Decimal statt float für alle Beträge (auch Toleranzrechnung).
- Deutsch für GUI-Texte, Meldungen, Commits; Datum TT.MM.JJJJ.
- UX-Regel: Bedienelemente sichtbar; Warnung per Dialog mit expliziter
Bestätigung, sonst bestehender Fehler-Alert.
- Fable-Testagent-Gate je Plan-Task.