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:
@@ -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.
|
||||||
Reference in New Issue
Block a user