Compare commits
9
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
b904709d0b | ||
|
|
f79e1ce26b | ||
|
|
b78990786f | ||
|
|
e665180b1f | ||
|
|
9e856ec655 | ||
|
|
42d3d95e48 | ||
|
|
0a5fa90ae8 | ||
|
|
5a8e05596f | ||
|
|
404c1a5648 |
+36
-2
@@ -40,8 +40,11 @@ flowchart TD
|
||||
S_projection["projection_service.py<br/>run_projection, refresh_stale_projections"]
|
||||
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
|
||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
||||
S_doublecount["double_count.py<br/>all_conflicts, conflicts_for_recurring,<br/>conflicts_for_loan"]
|
||||
end
|
||||
|
||||
N_reserved["app/reserved.py<br/>check_reserved_name"]
|
||||
|
||||
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
|
||||
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
|
||||
E_recurrence["recurrence.py<br/>occurrences"]
|
||||
@@ -86,7 +89,11 @@ flowchart TD
|
||||
R_imports --> S_importer
|
||||
R_planning --> E_loans
|
||||
R_planning --> S_suggestions
|
||||
R_planning --> S_doublecount
|
||||
R_scenarios --> S_projection
|
||||
R_scenarios --> N_reserved
|
||||
R_categories --> N_reserved
|
||||
R_gui --> S_doublecount
|
||||
R_admin --> S_admin
|
||||
|
||||
ROUTERS -.-> M_tables
|
||||
@@ -315,9 +322,36 @@ heute), je bereits durchgerechnetem Szenario einen Startpunkt exakt heute
|
||||
Projektionspunkte NACH heute (`projection_points`, `day > CURRENT_DATE`) —
|
||||
Punkte <= heute aus einer älteren Berechnung erscheinen so nie.
|
||||
`RESERVED_SCENARIO_NAME` ist für Szenario-Namen gesperrt:
|
||||
`routers/scenarios.py::_check_reserved_name` lehnt `POST`/`PATCH` mit diesem
|
||||
`app/reserved.py::check_reserved_name` lehnt `POST`/`PATCH` mit diesem
|
||||
Namen (case-insensitiv) mit 422 ab.
|
||||
|
||||
**Präfix `System-` reserviert (Ausbaustufe 12).** `app/reserved.py`
|
||||
(`SYSTEM_PREFIX = "System-"`, `is_reserved_name`, `check_reserved_name`)
|
||||
sperrt diesen Präfix für Kategorie- und Szenario-Namen (`routers/categories.py`,
|
||||
`routers/scenarios.py`, je `check_reserved_name` auf `POST`/`PATCH`, 422 bei
|
||||
Verstoß) — er gehört den vom Tool erzeugten Serien `RESERVED_SCENARIO_NAME`
|
||||
(`"System-Ist"`, `models/views.py`) und `services/planning_chart.py::KREDITRATEN`
|
||||
(`"System-Kreditraten"`).
|
||||
|
||||
**Doppelzählungs-Erkennung Kredit ↔ Fixposten (Ausbaustufe 12).**
|
||||
`app/services/double_count.py` erkennt, wenn ein monatlicher Ausgabe-Posten
|
||||
(`RecurringItem`, `rhythm == "monthly"`) betraglich vermutlich einer regulären
|
||||
Kreditrate (`regular_rate`, Annuität bzw. bei endfälligen Krediten die
|
||||
monatlichen Zinsen) entspricht — Toleranz `max(TOLERANZ_MIN=1.00,
|
||||
rate * TOLERANZ_PROZENT=3 / 100)` — und sich die Zeiträume überschneiden
|
||||
(`rate_period`). `routers/planning.py` ruft `conflicts_for_recurring` bzw.
|
||||
`conflicts_for_loan` bei `POST`/`PATCH` auf `/api/recurring` und
|
||||
`/api/loans` auf; ohne `doppelzaehlung_bestaetigt: true` im Request-Body
|
||||
antwortet es mit `409` (`detail.code == "doppelzaehlung_kredit"`, Konstante
|
||||
`CODE`). `static/doppelzaehlung.js` fängt genau diesen 409 im globalen
|
||||
`htmx:responseError`-Handler ab und zeigt das `<dialog id=
|
||||
"doppelzaehlung-dialog">` aus `base.html`; „Trotzdem speichern“ markiert das
|
||||
Formular (`data-doppelzaehlung-bestaetigt="1"`) und sendet es erneut, die
|
||||
`json-form`-Erweiterung ergänzt dabei `doppelzaehlung_bestaetigt: true`.
|
||||
`routers/gui.py::planung_page` ruft zusätzlich `all_conflicts` auf und zeigt
|
||||
einen dauerhaften ⚠-Hinweis je betroffenem Posten/Kredit in `planning.html`,
|
||||
solange der Konflikt fortbesteht.
|
||||
|
||||
**Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände
|
||||
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
|
||||
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
|
||||
@@ -329,7 +363,7 @@ neu (Advisory-Lock `STALE_PROJECTION_LOCK = 110_001`), aufgerufen von
|
||||
schreibt die Tabelle `planning_monthly` (`models/tables.py::PlanningMonthly`,
|
||||
Migration `d5e8b3c7a410`) komplett neu — Monatssummen je Kategorie aus
|
||||
Planung (wiederkehrende Posten, Einmalposten, Kreditraten aller Kredite
|
||||
unter Kategorie „Kreditraten") für das Grafana-Panel „Planung nach
|
||||
unter Kategorie „System-Kreditraten") für das Grafana-Panel „Planung nach
|
||||
Kategorie" (`Advisory-Lock PLANNING_CHART_LOCK = 110_002`), aufgerufen von
|
||||
`index` und `planung_page`. Beide Tabellen sind jederzeit reproduzierbar
|
||||
(kein Backup-Sonderfall).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.11.0
|
||||
0.12.0
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from app.reserved import SYSTEM_PREFIX
|
||||
|
||||
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
||||
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein
|
||||
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11).
|
||||
RESERVED_SCENARIO_NAME = "System-Ist"
|
||||
# Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py.
|
||||
RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"
|
||||
|
||||
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
|
||||
# same math as app.services.balances.account_balance, expressed as a single
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Reservierte Namen (Ausbaustufe 12).
|
||||
|
||||
Alles, was mit SYSTEM_PREFIX beginnt, gehört dem Tool: synthetische Serien
|
||||
wie „System-Ist" (v_projection) oder „System-Kreditraten" (Planungs-Panel).
|
||||
Kategorien und Szenarien dürfen solche Namen nicht tragen, sonst verschmelzen
|
||||
sie in Grafana mit den Tool-Serien.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
SYSTEM_PREFIX = "System-"
|
||||
RESERVED_MESSAGE = (f"Namen, die mit »{SYSTEM_PREFIX}« beginnen, sind für "
|
||||
"vom Tool erzeugte Einträge reserviert")
|
||||
|
||||
|
||||
def is_reserved_name(name: str) -> bool:
|
||||
"""True, wenn name (ohne Randleerzeichen, Groß/klein egal) mit dem
|
||||
System-Präfix beginnt."""
|
||||
return name.strip().casefold().startswith(SYSTEM_PREFIX.casefold())
|
||||
|
||||
|
||||
def check_reserved_name(name: str | None) -> None:
|
||||
"""None (Feld nicht gesetzt) -> keine Prüfung; reserviert -> 422."""
|
||||
if name is not None and is_reserved_name(name):
|
||||
raise HTTPException(422, RESERVED_MESSAGE)
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from app.auth import require_auth
|
||||
from app.db import get_session
|
||||
from app.models.tables import Category, CategoryRule
|
||||
from app.reserved import check_reserved_name
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["categories"],
|
||||
dependencies=[Depends(require_auth)])
|
||||
@@ -39,6 +40,7 @@ def list_categories(session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("/categories", response_model=CategoryOut, status_code=201)
|
||||
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
||||
check_reserved_name(data.name)
|
||||
if session.execute(select(Category).where(Category.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Kategorie existiert bereits")
|
||||
cat = Category(**data.model_dump())
|
||||
@@ -51,6 +53,7 @@ def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
||||
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||
def patch_category(category_id: int, data: CategoryIn,
|
||||
session: Session = Depends(get_session)):
|
||||
check_reserved_name(data.name)
|
||||
cat = session.get(Category, category_id)
|
||||
if cat is None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.routers.planning import recurring_suggestions as _recurring_suggestions
|
||||
from app.routers.scenarios import list_scenarios as _list_scenarios
|
||||
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
||||
from app.services.balances import account_balance, total_balance
|
||||
from app.services.double_count import all_conflicts
|
||||
from app.services.planning_chart import refresh_planning_chart
|
||||
from app.services.projection_service import refresh_stale_projections
|
||||
from app.version import get_version
|
||||
@@ -337,6 +338,11 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
recurring = _list_recurring(session=session)
|
||||
planned = _list_planned(session=session)
|
||||
loans = _list_loans(session=session)
|
||||
doppelt_posten: dict[int, list[str]] = {}
|
||||
doppelt_kredit: dict[int, list[str]] = {}
|
||||
for k in all_conflicts(session):
|
||||
doppelt_posten.setdefault(k.posten_id, []).append(k.kredit_name)
|
||||
doppelt_kredit.setdefault(k.kredit_id, []).append(k.posten_name)
|
||||
return templates.TemplateResponse(request, "planning.html", {
|
||||
"categories": categories,
|
||||
"category_names": {c.id: c.name for c in categories},
|
||||
@@ -347,6 +353,8 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
||||
"loans": loans,
|
||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||
"repayment_types": ["annuity", "bullet"],
|
||||
"doppelt_posten": doppelt_posten,
|
||||
"doppelt_kredit": doppelt_kredit,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ from app.db import get_session
|
||||
from app.engine.loans import loan_schedule
|
||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||
ScenarioLoan)
|
||||
from app.services.double_count import (CODE as DOPPELZAEHLUNG_CODE, Konflikt,
|
||||
KreditDaten, PostenDaten,
|
||||
conflicts_for_loan, conflicts_for_recurring,
|
||||
kredit_aus, posten_aus)
|
||||
from app.services.suggestions import suggest_recurring
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["planning"],
|
||||
@@ -36,6 +40,11 @@ class RecurringIn(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class RecurringCreate(RecurringIn):
|
||||
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class RecurringOut(RecurringIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -49,6 +58,7 @@ class RecurringPatch(BaseModel):
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
category_id: int | None = None
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class SuggestionOut(BaseModel):
|
||||
@@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None:
|
||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
||||
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
_FLAG = "doppelzaehlung_bestaetigt"
|
||||
|
||||
|
||||
def _doppelzaehlung(konflikte: list[Konflikt]) -> HTTPException:
|
||||
"""409 mit strukturierter Erklärung; die GUI zeigt daraus das Warnfenster."""
|
||||
return HTTPException(409, detail={
|
||||
"code": DOPPELZAEHLUNG_CODE,
|
||||
"meldung": ("Der Betrag entspricht vermutlich einer Kreditrate, die bereits "
|
||||
"über „Kredite“ eingeplant ist (oder umgekehrt). Beide zusammen "
|
||||
"zählen die Rate doppelt – Planung und Szenarien wären zu "
|
||||
"pessimistisch. Nur speichern, wenn das wirklich gewollt ist."),
|
||||
"konflikte": [{"posten": k.posten_name,
|
||||
"posten_betrag": str(k.posten_betrag.quantize(_CENT)),
|
||||
"kredit": k.kredit_name,
|
||||
"kredit_rate": str(k.kredit_rate.quantize(_CENT))}
|
||||
for k in konflikte],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/recurring", response_model=list[RecurringOut])
|
||||
def list_recurring(session: Session = Depends(get_session)):
|
||||
return [RecurringOut.model_validate(r)
|
||||
@@ -73,9 +103,16 @@ def list_recurring(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/recurring", response_model=RecurringOut, status_code=201)
|
||||
def create_recurring(data: RecurringIn, session: Session = Depends(get_session)):
|
||||
def create_recurring(data: RecurringCreate, session: Session = Depends(get_session)):
|
||||
_check_category(session, data.category_id)
|
||||
item = RecurringItem(**data.model_dump())
|
||||
fields = data.model_dump(exclude={_FLAG})
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
konflikte = conflicts_for_recurring(session, PostenDaten(
|
||||
None, fields["name"], fields["amount"], fields["rhythm"],
|
||||
fields["start_date"], fields["end_date"]))
|
||||
if konflikte:
|
||||
raise _doppelzaehlung(konflikte)
|
||||
item = RecurringItem(**fields)
|
||||
session.add(item)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
@@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
item = session.get(RecurringItem, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
vorher = {k.paar for k in conflicts_for_recurring(session, posten_aus(item))}
|
||||
fields = data.model_dump(exclude_unset=True, exclude={_FLAG})
|
||||
if "category_id" in fields:
|
||||
_check_category(session, fields["category_id"])
|
||||
for key, value in fields.items():
|
||||
@@ -103,6 +141,12 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
||||
and item.end_date < item.start_date):
|
||||
session.rollback()
|
||||
raise HTTPException(422, "Ende darf nicht vor Start liegen")
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
neu = [k for k in conflicts_for_recurring(session, posten_aus(item))
|
||||
if k.paar not in vorher]
|
||||
if neu:
|
||||
session.rollback()
|
||||
raise _doppelzaehlung(neu)
|
||||
session.commit()
|
||||
session.refresh(item)
|
||||
return RecurringOut.model_validate(item)
|
||||
@@ -188,6 +232,10 @@ class LoanIn(BaseModel):
|
||||
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||
|
||||
|
||||
class LoanCreate(LoanIn):
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class LoanOut(LoanIn):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -200,6 +248,7 @@ class LoanPatch(BaseModel):
|
||||
term_months: int | None = None
|
||||
payout_date: date | None = None
|
||||
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||
doppelzaehlung_bestaetigt: bool = False
|
||||
|
||||
|
||||
class InstallmentOut(BaseModel):
|
||||
@@ -217,8 +266,15 @@ def list_loans(session: Session = Depends(get_session)):
|
||||
|
||||
|
||||
@router.post("/loans", response_model=LoanOut, status_code=201)
|
||||
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
|
||||
loan = Loan(**data.model_dump())
|
||||
def create_loan(data: LoanCreate, session: Session = Depends(get_session)):
|
||||
fields = data.model_dump(exclude={_FLAG})
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
konflikte = conflicts_for_loan(session, KreditDaten(
|
||||
None, fields["name"], fields["principal"], fields["annual_rate_pct"],
|
||||
fields["term_months"], fields["payout_date"], fields["repayment_type"]))
|
||||
if konflikte:
|
||||
raise _doppelzaehlung(konflikte)
|
||||
loan = Loan(**fields)
|
||||
session.add(loan)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
@@ -230,8 +286,15 @@ def patch_loan(loan_id: int, data: LoanPatch, session: Session = Depends(get_ses
|
||||
loan = session.get(Loan, loan_id)
|
||||
if loan is None:
|
||||
raise HTTPException(404, "Kredit nicht gefunden")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
vorher = {k.paar for k in conflicts_for_loan(session, kredit_aus(loan))}
|
||||
for key, value in data.model_dump(exclude_unset=True, exclude={_FLAG}).items():
|
||||
setattr(loan, key, value)
|
||||
if not data.doppelzaehlung_bestaetigt:
|
||||
neu = [k for k in conflicts_for_loan(session, kredit_aus(loan))
|
||||
if k.paar not in vorher]
|
||||
if neu:
|
||||
session.rollback()
|
||||
raise _doppelzaehlung(neu)
|
||||
session.commit()
|
||||
session.refresh(loan)
|
||||
return LoanOut.model_validate(loan)
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.db import get_session
|
||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||
Scenario, ScenarioLoan, ScenarioModifier,
|
||||
ScenarioPlannedItem)
|
||||
from app.models.views import RESERVED_SCENARIO_NAME
|
||||
from app.reserved import check_reserved_name
|
||||
from app.services.projection_service import run_projection
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||
@@ -86,14 +86,6 @@ def _get_scenario(session: Session, scenario_id: int) -> Scenario:
|
||||
return scenario
|
||||
|
||||
|
||||
def _check_reserved_name(name: str | None) -> None:
|
||||
"""Der Serienname der Ist-Kurve (v_projection) ist für Szenarien tabu."""
|
||||
if name is not None and (name.strip().casefold()
|
||||
== RESERVED_SCENARIO_NAME.casefold()):
|
||||
raise HTTPException(
|
||||
422, f"Der Name »{RESERVED_SCENARIO_NAME}« ist für die Ist-Kurve reserviert")
|
||||
|
||||
|
||||
@router.get("", response_model=list[ScenarioOut])
|
||||
def list_scenarios(session: Session = Depends(get_session)):
|
||||
return [ScenarioOut.model_validate(s)
|
||||
@@ -102,7 +94,7 @@ def list_scenarios(session: Session = Depends(get_session)):
|
||||
|
||||
@router.post("", response_model=ScenarioOut, status_code=201)
|
||||
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
|
||||
_check_reserved_name(data.name)
|
||||
check_reserved_name(data.name)
|
||||
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
|
||||
raise HTTPException(409, "Szenario existiert bereits")
|
||||
scenario = Scenario(**data.model_dump())
|
||||
@@ -117,7 +109,7 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
||||
session: Session = Depends(get_session)):
|
||||
scenario = _get_scenario(session, scenario_id)
|
||||
fields = data.model_dump(exclude_unset=True)
|
||||
_check_reserved_name(fields.get("name"))
|
||||
check_reserved_name(fields.get("name"))
|
||||
if "name" in fields and fields["name"] != scenario.name:
|
||||
clash = session.execute(
|
||||
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Doppelzählungs-Erkennung Kredit ↔ wiederkehrender Posten (Ausbaustufe 12).
|
||||
|
||||
Ein monatlicher Ausgabe-Posten gilt als vermutlich doppelt zu einem Kredit,
|
||||
wenn sein Betrag innerhalb der Toleranz der regulären Monatsrate liegt und
|
||||
sich die Zeiträume überschneiden. Bewusst kein Namensvergleich
|
||||
(„Autokredit" vs. „Rate VW Bank" ist nicht zuverlässig zuordenbar).
|
||||
Reine Logik auf Wertobjekten, damit auch noch nicht gespeicherte bzw. per
|
||||
PATCH veränderte Zustände geprüft werden können. Alle Beträge Decimal.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.engine.loans import add_months, annuity_payment
|
||||
from app.models.tables import Loan, RecurringItem
|
||||
|
||||
CODE = "doppelzaehlung_kredit"
|
||||
TOLERANZ_PROZENT = Decimal("3")
|
||||
TOLERANZ_MIN = Decimal("1.00")
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostenDaten:
|
||||
id: int | None
|
||||
name: str
|
||||
amount: Decimal
|
||||
rhythm: str
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KreditDaten:
|
||||
id: int | None
|
||||
name: str
|
||||
principal: Decimal
|
||||
annual_rate_pct: Decimal
|
||||
term_months: int
|
||||
payout_date: date
|
||||
repayment_type: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konflikt:
|
||||
posten_id: int | None
|
||||
posten_name: str
|
||||
posten_betrag: Decimal
|
||||
kredit_id: int | None
|
||||
kredit_name: str
|
||||
kredit_rate: Decimal
|
||||
|
||||
@property
|
||||
def paar(self) -> tuple[int | None, int | None]:
|
||||
return (self.posten_id, self.kredit_id)
|
||||
|
||||
|
||||
def posten_aus(item: RecurringItem) -> PostenDaten:
|
||||
return PostenDaten(item.id, item.name, Decimal(item.amount), item.rhythm,
|
||||
item.start_date, item.end_date)
|
||||
|
||||
|
||||
def kredit_aus(loan: Loan) -> KreditDaten:
|
||||
return KreditDaten(loan.id, loan.name, Decimal(loan.principal),
|
||||
Decimal(loan.annual_rate_pct), loan.term_months,
|
||||
loan.payout_date, loan.repayment_type)
|
||||
|
||||
|
||||
def regular_rate(k: KreditDaten) -> Decimal | None:
|
||||
"""Reguläre Monatsrate (positiv): Annuität bzw. bei endfälligen Krediten
|
||||
die monatlichen Zinsen. Ohne reguläre Rate (0 % endfällig, Laufzeit <= 0)
|
||||
-> None, dann nie Konflikt. Die Schlusszahlung wird nicht betrachtet."""
|
||||
if k.term_months <= 0:
|
||||
return None
|
||||
if k.repayment_type == "bullet":
|
||||
if k.annual_rate_pct == 0:
|
||||
return None
|
||||
return (k.principal * k.annual_rate_pct / Decimal(100) / Decimal(12)
|
||||
).quantize(CENT, ROUND_HALF_UP)
|
||||
return annuity_payment(k.principal, k.annual_rate_pct, k.term_months)
|
||||
|
||||
|
||||
def rate_period(k: KreditDaten) -> tuple[date, date]:
|
||||
"""Erste und letzte Rate, identisch zu engine/loans.loan_schedule."""
|
||||
return add_months(k.payout_date, 1), add_months(k.payout_date, k.term_months)
|
||||
|
||||
|
||||
def find_conflict(p: PostenDaten, k: KreditDaten) -> Konflikt | None:
|
||||
if p.rhythm != "monthly" or p.amount >= 0:
|
||||
return None
|
||||
rate = regular_rate(k)
|
||||
if rate is None or rate <= 0:
|
||||
return None
|
||||
toleranz = max(TOLERANZ_MIN, rate * TOLERANZ_PROZENT / Decimal(100))
|
||||
if abs(-p.amount - rate) > toleranz:
|
||||
return None
|
||||
erste, letzte = rate_period(k)
|
||||
if p.start_date is not None and p.start_date > letzte:
|
||||
return None
|
||||
if p.end_date is not None and p.end_date < erste:
|
||||
return None
|
||||
return Konflikt(p.id, p.name, p.amount, k.id, k.name, rate)
|
||||
|
||||
|
||||
def conflicts_for_recurring(session: Session, p: PostenDaten) -> list[Konflikt]:
|
||||
loans = session.execute(select(Loan).order_by(Loan.name, Loan.id)).scalars()
|
||||
return [c for c in (find_conflict(p, kredit_aus(l)) for l in loans) if c]
|
||||
|
||||
|
||||
def conflicts_for_loan(session: Session, k: KreditDaten) -> list[Konflikt]:
|
||||
items = session.execute(select(RecurringItem)
|
||||
.order_by(RecurringItem.name, RecurringItem.id)).scalars()
|
||||
return [c for c in (find_conflict(posten_aus(r), k) for r in items) if c]
|
||||
|
||||
|
||||
def all_conflicts(session: Session) -> list[Konflikt]:
|
||||
kredite = [kredit_aus(l) for l in session.execute(
|
||||
select(Loan).order_by(Loan.name, Loan.id)).scalars()]
|
||||
out: list[Konflikt] = []
|
||||
for r in session.execute(select(RecurringItem)
|
||||
.order_by(RecurringItem.name, RecurringItem.id)).scalars():
|
||||
p = posten_aus(r)
|
||||
out.extend(c for c in (find_conflict(p, k) for k in kredite) if c)
|
||||
return out
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
|
||||
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
|
||||
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen,
|
||||
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel
|
||||
(engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
||||
„System-Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit
|
||||
Vorzeichen, Semantik wie v_monthly_by_category. Kreditraten brauchen die
|
||||
Annuitätenformel (engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
@@ -19,9 +19,10 @@ from app.engine.loans import loan_schedule
|
||||
from app.engine.recurrence import occurrences
|
||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||
RecurringItem)
|
||||
from app.reserved import SYSTEM_PREFIX
|
||||
|
||||
UNKATEGORISIERT = "unkategorisiert"
|
||||
KREDITRATEN = "Kreditraten"
|
||||
KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"
|
||||
PLANNING_CHART_LOCK = 110_002
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/* Warnfenster bei möglicher Doppelzählung Kredit ↔ Fixposten (Ausbaustufe 12).
|
||||
*
|
||||
* Die API antwortet mit 409 und detail.code "doppelzaehlung_kredit", wenn ein
|
||||
* Posten vermutlich einer Kreditrate entspricht. handle() fängt genau diesen
|
||||
* Fall im globalen htmx:responseError-Handler ab (alle anderen Fehler laufen
|
||||
* weiter über den alert) und zeigt den <dialog>. „Trotzdem speichern" markiert
|
||||
* das auslösende Formular (data-doppelzaehlung-bestaetigt="1") und sendet es
|
||||
* erneut; die json-form-Extension ergänzt dann doppelzaehlung_bestaetigt=true.
|
||||
* Nach jedem Request wird die Markierung entfernt, damit ein späteres
|
||||
* Speichern die Warnung nicht stillschweigend umgeht.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CODE = 'doppelzaehlung_kredit';
|
||||
var ATTR = 'data-doppelzaehlung-bestaetigt';
|
||||
var fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
function parseDetail(xhr) {
|
||||
if (!xhr || xhr.status !== 409) { return null; }
|
||||
var body;
|
||||
try { body = JSON.parse(xhr.responseText); } catch (e) { return null; }
|
||||
var d = body && body.detail;
|
||||
return d && d.code === CODE ? d : null;
|
||||
}
|
||||
|
||||
function eur(s) { return fmt.format(parseFloat(s)) + ' €'; }
|
||||
|
||||
function konfliktText(k) {
|
||||
return 'Posten „' + k.posten + '“ (' + eur(k.posten_betrag) + ') ↔ Kredit „'
|
||||
+ k.kredit + '“ (Rate ' + eur(k.kredit_rate) + ')';
|
||||
}
|
||||
|
||||
function handle(evt) {
|
||||
var d = parseDetail(evt && evt.detail && evt.detail.xhr);
|
||||
var elt = evt && evt.detail && evt.detail.elt;
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
if (!d || !form) { return false; }
|
||||
var doc = form.ownerDocument;
|
||||
var dlg = doc.getElementById('doppelzaehlung-dialog');
|
||||
if (!dlg) { return false; }
|
||||
dlg.querySelector('.dz-meldung').textContent = d.meldung;
|
||||
var list = dlg.querySelector('.dz-konflikte');
|
||||
list.textContent = '';
|
||||
(d.konflikte || []).forEach(function (k) {
|
||||
var li = doc.createElement('li');
|
||||
li.textContent = konfliktText(k);
|
||||
list.appendChild(li);
|
||||
});
|
||||
dlg.fbForm = form;
|
||||
dlg.showModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
function abbrechen(dlg) {
|
||||
dlg.fbForm = null;
|
||||
dlg.close();
|
||||
}
|
||||
|
||||
function bestaetigen(dlg) {
|
||||
var form = dlg.fbForm;
|
||||
dlg.fbForm = null;
|
||||
dlg.close();
|
||||
if (!form) { return; }
|
||||
form.setAttribute(ATTR, '1');
|
||||
form.ownerDocument.defaultView.htmx.trigger(form, 'submit');
|
||||
}
|
||||
|
||||
function init(doc) {
|
||||
var dlg = doc.getElementById('doppelzaehlung-dialog');
|
||||
if (!dlg) { return; }
|
||||
doc.getElementById('dz-abbrechen').addEventListener('click', function () { abbrechen(dlg); });
|
||||
doc.getElementById('dz-bestaetigen').addEventListener('click', function () { bestaetigen(dlg); });
|
||||
doc.body.addEventListener('htmx:afterRequest', function (e) {
|
||||
var elt = e.detail && e.detail.elt;
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
if (form && form.getAttribute(ATTR) === '1') {
|
||||
// Erst nach dem Request mit Flag entfernen: der 409-Request selbst
|
||||
// hat die Markierung nie gesetzt.
|
||||
form.removeAttribute(ATTR);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var api = { parseDetail: parseDetail, konfliktText: konfliktText, handle: handle,
|
||||
abbrechen: abbrechen, bestaetigen: bestaetigen, init: init };
|
||||
if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
|
||||
if (typeof window !== 'undefined') { window.FBDoppel = api; }
|
||||
})();
|
||||
@@ -226,3 +226,13 @@ label:has(input:disabled), label:has(select:disabled) {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Doppelzählung Kredit/Fixposten (Ausbaustufe 12). */
|
||||
.warn-hint {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: #b45309;
|
||||
}
|
||||
#doppelzaehlung-dialog {
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script src="/static/sort.js"></script>
|
||||
<script src="/static/doppelzaehlung.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
@@ -26,8 +27,18 @@
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<dialog id="doppelzaehlung-dialog">
|
||||
<h2>Mögliche Doppelzählung</h2>
|
||||
<p class="dz-meldung"></p>
|
||||
<ul class="dz-konflikte"></ul>
|
||||
<p>Trotzdem speichern?</p>
|
||||
<button type="button" id="dz-abbrechen" autofocus>Abbrechen</button>
|
||||
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
|
||||
</dialog>
|
||||
<script>
|
||||
document.body.addEventListener('htmx:responseError', function (e) {
|
||||
// Mögliche Doppelzählung Kredit/Fixposten: eigenes Warnfenster statt alert.
|
||||
if (window.FBDoppel && window.FBDoppel.handle(e)) { return; }
|
||||
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||
});
|
||||
|
||||
@@ -72,6 +83,10 @@
|
||||
out[field.name] = field.checked;
|
||||
});
|
||||
}
|
||||
// Bestätigte Doppelzählung (Warnfenster „Trotzdem speichern"):
|
||||
if (elt.getAttribute && elt.getAttribute('data-doppelzaehlung-bestaetigt') === '1') {
|
||||
out.doppelzaehlung_bestaetigt = true;
|
||||
}
|
||||
xhr.overrideMimeType('text/json');
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
@@ -82,6 +97,8 @@
|
||||
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
||||
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
||||
}
|
||||
|
||||
if (window.FBDoppel) { window.FBDoppel.init(document); }
|
||||
</script>
|
||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||
</body>
|
||||
|
||||
@@ -87,12 +87,33 @@
|
||||
Kategorie, der Szenario-Vergleich als Kurven (darin die Ist-Kurve
|
||||
„System-Ist“ bis heute, ab heute je Szenario weiter) und das Panel
|
||||
„Planung nach Kategorie“ (Monatssummen aus der reinen Planung inkl.
|
||||
Kreditraten, ohne Szenario-Modifikatoren) – einmal mit demselben
|
||||
„System-Kreditraten“, ohne Szenario-Modifikatoren) – einmal mit demselben
|
||||
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||
Übersicht.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<strong>Reservierte Namen:</strong> Kategorien und Szenarien dürfen nicht
|
||||
mit „System-“ beginnen – dieses Präfix ist für vom Tool erzeugte Einträge
|
||||
reserviert (z. B. die Ist-Serie „System-Ist“ und die Kreditraten-Serie
|
||||
„System-Kreditraten“ in Grafana). Ein Versuch, einen eigenen Namen mit
|
||||
diesem Präfix anzulegen, wird abgelehnt.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Doppelzählungs-Warnung:</strong> beim Anlegen oder Ändern eines
|
||||
wiederkehrenden Postens oder eines Kredits prüft das Tool, ob ein
|
||||
monatlicher Ausgabe-Posten ungefähr einer regulären Kreditrate entspricht
|
||||
(Betrag innerhalb ±3 %, mindestens 1 €, mit überlappendem Zeitraum). Trifft
|
||||
das zu, erscheint ein Warnfenster – erst ein Klick auf „Trotzdem
|
||||
speichern“ übernimmt den Eintrag. Solange der Konflikt bestehen bleibt,
|
||||
zeigt die Planungs-Seite dauerhaft einen ⚠-Hinweis bei den betroffenen
|
||||
Posten und Krediten an. Am saubersten lässt sich das vermeiden, wenn
|
||||
Kreditraten ausschließlich über „Kredite“ gepflegt werden statt zusätzlich
|
||||
als wiederkehrender Posten.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Tabellen sortieren:</strong> ein Klick auf einen Spaltenkopf
|
||||
sortiert die Tabelle danach (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<tbody>
|
||||
{% for r in recurring %}
|
||||
<tr id="rec-row-{{ r.id }}">
|
||||
<td>{{ r.name }}</td>
|
||||
<td data-sort="{{ r.name }}">{{ r.name }}{% for kn in doppelt_posten.get(r.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Kredit „{{ kn }}“</span>{% endfor %}</td>
|
||||
<td class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
|
||||
<td>{{ r.rhythm|de_label }}</td>
|
||||
<td>{{ r.due_day }}</td>
|
||||
@@ -197,7 +197,7 @@
|
||||
<tbody>
|
||||
{% for l in loans %}
|
||||
<tr id="loan-row-{{ l.id }}">
|
||||
<td>{{ l.name }}</td>
|
||||
<td data-sort="{{ l.name }}">{{ l.name }}{% for pn in doppelt_kredit.get(l.id, []) %}<span class="warn-hint">⚠ zählt evtl. doppelt mit Posten „{{ pn }}“</span>{% endfor %}</td>
|
||||
<td data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
|
||||
<td>{{ l.annual_rate_pct|eur }} %</td>
|
||||
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</td>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
let JSDOM = null;
|
||||
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
|
||||
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/doppelzaehlung.js'), 'utf8');
|
||||
|
||||
const HTML = `
|
||||
<form id="f" hx-post="/api/recurring"><input name="name" value="Rate Auto"></form>
|
||||
<dialog id="doppelzaehlung-dialog">
|
||||
<p class="dz-meldung"></p><ul class="dz-konflikte"></ul>
|
||||
<button type="button" id="dz-abbrechen">Abbrechen</button>
|
||||
<button type="button" id="dz-bestaetigen">Trotzdem speichern</button>
|
||||
</dialog>`;
|
||||
const BODY = JSON.stringify({ detail: { code: 'doppelzaehlung_kredit', meldung: 'Erklärung',
|
||||
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-100.00', kredit: 'Auto', kredit_rate: '100.00' }] } });
|
||||
|
||||
function load() {
|
||||
const dom = new JSDOM(`<!doctype html><body>${HTML}</body>`, { runScripts: 'outside-only' });
|
||||
const w = dom.window;
|
||||
const calls = [];
|
||||
w.htmx = { trigger: (el, name) => calls.push([el.id, name]) };
|
||||
const dlg = w.document.getElementById('doppelzaehlung-dialog');
|
||||
dlg.showModal = function () { this.setAttribute('open', ''); };
|
||||
dlg.close = function () { this.removeAttribute('open'); };
|
||||
w.eval(SRC);
|
||||
w.FBDoppel.init(w.document);
|
||||
const form = w.document.getElementById('f');
|
||||
const evt = { detail: { xhr: { status: 409, responseText: BODY }, elt: form } };
|
||||
return { w, dlg, form, evt, calls };
|
||||
}
|
||||
|
||||
test('409 öffnet Dialog mit Erklärung und Konfliktliste', { skip: !JSDOM }, () => {
|
||||
const { w, dlg, evt } = load();
|
||||
assert.equal(w.FBDoppel.handle(evt), true);
|
||||
assert.ok(dlg.hasAttribute('open'));
|
||||
assert.equal(dlg.querySelector('.dz-meldung').textContent, 'Erklärung');
|
||||
assert.equal(dlg.querySelectorAll('.dz-konflikte li').length, 1);
|
||||
});
|
||||
|
||||
test('andere Fehler werden nicht abgefangen', { skip: !JSDOM }, () => {
|
||||
const { w, form } = load();
|
||||
assert.equal(w.FBDoppel.handle({ detail: { xhr: { status: 422, responseText: '{}' }, elt: form } }), false);
|
||||
});
|
||||
|
||||
test('Abbrechen speichert nicht', { skip: !JSDOM }, () => {
|
||||
const { w, dlg, form, evt, calls } = load();
|
||||
w.FBDoppel.handle(evt);
|
||||
w.document.getElementById('dz-abbrechen').click();
|
||||
assert.ok(!dlg.hasAttribute('open'));
|
||||
assert.equal(calls.length, 0);
|
||||
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
|
||||
assert.equal(form.querySelector('input').value, 'Rate Auto');
|
||||
});
|
||||
|
||||
test('Trotzdem speichern sendet erneut mit Flag', { skip: !JSDOM }, () => {
|
||||
const { w, dlg, form, evt, calls } = load();
|
||||
w.FBDoppel.handle(evt);
|
||||
w.document.getElementById('dz-bestaetigen').click();
|
||||
assert.ok(!dlg.hasAttribute('open'));
|
||||
assert.equal(form.getAttribute('data-doppelzaehlung-bestaetigt'), '1');
|
||||
assert.deepEqual(calls, [['f', 'submit']]);
|
||||
});
|
||||
|
||||
test('Flag nach Request entfernt', { skip: !JSDOM }, () => {
|
||||
const { w, form, evt } = load();
|
||||
w.FBDoppel.handle(evt);
|
||||
w.document.getElementById('dz-bestaetigen').click();
|
||||
const e = new w.CustomEvent('htmx:afterRequest', { bubbles: true, detail: { elt: form } });
|
||||
form.dispatchEvent(e);
|
||||
assert.ok(!form.hasAttribute('data-doppelzaehlung-bestaetigt'));
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const D = require('../../app/static/doppelzaehlung.js');
|
||||
|
||||
const body = { detail: { code: 'doppelzaehlung_kredit', meldung: 'M',
|
||||
konflikte: [{ posten: 'Rate Auto', posten_betrag: '-1234.50', kredit: 'Auto', kredit_rate: '1234.50' }] } };
|
||||
|
||||
test('parseDetail erkennt nur 409 mit Code', () => {
|
||||
assert.equal(D.parseDetail({ status: 409, responseText: JSON.stringify(body) }).code, 'doppelzaehlung_kredit');
|
||||
assert.equal(D.parseDetail({ status: 409, responseText: '{"detail":"Kategorie existiert bereits"}' }), null);
|
||||
assert.equal(D.parseDetail({ status: 422, responseText: JSON.stringify(body) }), null);
|
||||
assert.equal(D.parseDetail({ status: 409, responseText: 'kein json' }), null);
|
||||
assert.equal(D.parseDetail(null), null);
|
||||
});
|
||||
|
||||
test('konfliktText deutsch formatiert', () => {
|
||||
assert.equal(D.konfliktText(body.detail.konflikte[0]),
|
||||
'Posten „Rate Auto“ (-1.234,50 €) ↔ Kredit „Auto“ (Rate 1.234,50 €)');
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.tables import Loan, RecurringItem
|
||||
from app.services.double_count import (KreditDaten, PostenDaten, all_conflicts,
|
||||
conflicts_for_loan, conflicts_for_recurring,
|
||||
find_conflict, rate_period, regular_rate)
|
||||
|
||||
D = Decimal
|
||||
# 1.200 € zu 0 % auf 12 Monate, Auszahlung 15.01.2026 -> Rate 100,00;
|
||||
# erste Rate 15.02.2026, letzte 15.01.2027.
|
||||
K100 = KreditDaten(1, "Auto", D("1200.00"), D("0"), 12, date(2026, 1, 15), "annuity")
|
||||
|
||||
|
||||
def P(amount, rhythm="monthly", start=None, end=None, name="Rate"):
|
||||
return PostenDaten(7, name, D(amount), rhythm, start, end)
|
||||
|
||||
|
||||
def test_regular_rate_und_zeitraum():
|
||||
assert regular_rate(K100) == D("100.00")
|
||||
assert rate_period(K100) == (date(2026, 2, 15), date(2027, 1, 15))
|
||||
annu = KreditDaten(2, "A", D("5000.00"), D("6.0"), 48, date(2026, 1, 1), "annuity")
|
||||
assert regular_rate(annu) == D("117.43")
|
||||
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert regular_rate(bullet) == D("60.00")
|
||||
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert regular_rate(bullet0) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount,hit", [
|
||||
("-100.00", True), ("-103.00", True), ("-103.01", False),
|
||||
("-97.00", True), ("-96.99", False),
|
||||
])
|
||||
def test_toleranz_drei_prozent(amount, hit):
|
||||
assert (find_conflict(P(amount), K100) is not None) is hit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount,hit", [("-21.00", True), ("-21.01", False), ("-19.00", True)])
|
||||
def test_mindesttoleranz_ein_euro(amount, hit):
|
||||
k20 = KreditDaten(5, "Klein", D("240.00"), D("0"), 12, date(2026, 1, 15), "annuity")
|
||||
assert (find_conflict(P(amount), k20) is not None) is hit
|
||||
|
||||
|
||||
def test_endfaellig_zinsrate_und_nullzins():
|
||||
bullet = KreditDaten(3, "B", D("12000.00"), D("6.0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert find_conflict(P("-60.00"), bullet) is not None
|
||||
bullet0 = KreditDaten(4, "B0", D("12000.00"), D("0"), 24, date(2026, 1, 1), "bullet")
|
||||
assert find_conflict(P("-60.00"), bullet0) is None
|
||||
assert find_conflict(P("-0.50"), bullet0) is None
|
||||
|
||||
|
||||
def test_nur_monatliche_ausgaben():
|
||||
assert find_conflict(P("-100.00", rhythm="quarterly"), K100) is None
|
||||
assert find_conflict(P("100.00"), K100) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start,end,hit", [
|
||||
(None, date(2026, 2, 14), False), (None, date(2026, 2, 15), True),
|
||||
(date(2027, 1, 16), None, False), (date(2027, 1, 15), None, True),
|
||||
(None, None, True), (date(2025, 1, 1), date(2030, 1, 1), True),
|
||||
])
|
||||
def test_zeitraeume(start, end, hit):
|
||||
assert (find_conflict(P("-100.00", start=start, end=end), K100) is not None) is hit
|
||||
|
||||
|
||||
def test_konflikt_inhalt():
|
||||
k = find_conflict(P("-101.50", name="Rate Auto"), K100)
|
||||
assert (k.posten_id, k.posten_name, k.posten_betrag) == (7, "Rate Auto", D("-101.50"))
|
||||
assert (k.kredit_id, k.kredit_name, k.kredit_rate) == (1, "Auto", D("100.00"))
|
||||
assert k.paar == (7, 1)
|
||||
|
||||
|
||||
def _seed(db):
|
||||
db.add_all([
|
||||
Loan(name="Auto", principal=D("1200.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||
Loan(name="Möbel", principal=D("240.00"), annual_rate_pct=D("0"), term_months=12,
|
||||
payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||
RecurringItem(name="Rate Auto", amount=D("-100.00"), rhythm="monthly", due_day=15),
|
||||
RecurringItem(name="Rate Möbel", amount=D("-20.00"), rhythm="monthly", due_day=15),
|
||||
RecurringItem(name="Miete", amount=D("-600.00"), rhythm="monthly", due_day=1),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_all_conflicts_findet_paare_sortiert(db):
|
||||
_seed(db)
|
||||
assert [(k.posten_name, k.kredit_name) for k in all_conflicts(db)] == [
|
||||
("Rate Auto", "Auto"), ("Rate Möbel", "Möbel")]
|
||||
|
||||
|
||||
def test_conflicts_for_recurring_und_loan(db):
|
||||
_seed(db)
|
||||
neu = PostenDaten(None, "Neu", D("-99.00"), "monthly", None, None)
|
||||
assert [k.kredit_name for k in conflicts_for_recurring(db, neu)] == ["Auto"]
|
||||
kredit = KreditDaten(None, "Neu", D("7200.00"), D("0"), 12, date(2026, 1, 1), "annuity")
|
||||
assert [k.posten_name for k in conflicts_for_loan(db, kredit)] == ["Miete"]
|
||||
@@ -0,0 +1,91 @@
|
||||
from decimal import Decimal
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
KREDIT = {"name": "Auto", "principal": "1200.00", "annual_rate_pct": "0",
|
||||
"term_months": 12, "payout_date": "2026-01-15", "repayment_type": "annuity"}
|
||||
POSTEN = {"name": "Rate Auto", "amount": "-100.00", "rhythm": "monthly", "due_day": 15}
|
||||
|
||||
|
||||
def _count(client, path):
|
||||
return len(client.get(path, headers=H).json())
|
||||
|
||||
|
||||
def test_posten_zu_bestehendem_kredit_409(client):
|
||||
assert client.post("/api/loans", headers=H, json=KREDIT).status_code == 201
|
||||
r = client.post("/api/recurring", headers=H, json=POSTEN)
|
||||
assert r.status_code == 409
|
||||
d = r.json()["detail"]
|
||||
assert d["code"] == "doppelzaehlung_kredit"
|
||||
assert "doppelt" in d["meldung"]
|
||||
assert d["konflikte"] == [{"posten": "Rate Auto", "posten_betrag": "-100.00",
|
||||
"kredit": "Auto", "kredit_rate": "100.00"}]
|
||||
assert _count(client, "/api/recurring") == 0
|
||||
|
||||
|
||||
def test_posten_mit_bestaetigung_gespeichert(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
r = client.post("/api/recurring", headers=H,
|
||||
json={**POSTEN, "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 201
|
||||
assert "doppelzaehlung_bestaetigt" not in r.json()
|
||||
assert _count(client, "/api/recurring") == 1
|
||||
|
||||
|
||||
def test_kredit_zu_bestehendem_posten_409_und_bestaetigung(client):
|
||||
client.post("/api/recurring", headers=H, json=POSTEN)
|
||||
r = client.post("/api/loans", headers=H, json=KREDIT)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["konflikte"][0]["posten"] == "Rate Auto"
|
||||
assert _count(client, "/api/loans") == 0
|
||||
r = client.post("/api/loans", headers=H, json={**KREDIT, "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 201
|
||||
assert "doppelzaehlung_bestaetigt" not in r.json()
|
||||
|
||||
|
||||
def test_ohne_konflikt_unveraendert(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
r = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"})
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
def test_patch_posten_erzeugt_konflikt_409_ohne_aenderung(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
item = client.post("/api/recurring", headers=H, json={**POSTEN, "amount": "-50.00"}).json()
|
||||
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"amount": "-100.00"})
|
||||
assert r.status_code == 409
|
||||
stored = client.get("/api/recurring", headers=H).json()[0]
|
||||
assert Decimal(stored["amount"]) == Decimal("-50.00")
|
||||
r = client.patch(f"/api/recurring/{item['id']}", headers=H,
|
||||
json={"amount": "-100.00", "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 200
|
||||
assert Decimal(r.json()["amount"]) == Decimal("-100.00")
|
||||
|
||||
|
||||
def test_patch_nur_name_bei_bestehendem_konflikt_ohne_flag(client):
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
item = client.post("/api/recurring", headers=H,
|
||||
json={**POSTEN, "doppelzaehlung_bestaetigt": True}).json()
|
||||
r = client.patch(f"/api/recurring/{item['id']}", headers=H, json={"name": "Rate Auto neu"})
|
||||
assert r.status_code == 200
|
||||
loan = client.get("/api/loans", headers=H).json()[0]
|
||||
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"name": "Auto neu"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_patch_kredit_erzeugt_konflikt_409(client):
|
||||
client.post("/api/recurring", headers=H, json=POSTEN)
|
||||
loan = client.post("/api/loans", headers=H, json={**KREDIT, "principal": "2400.00"}).json()
|
||||
r = client.patch(f"/api/loans/{loan['id']}", headers=H, json={"principal": "1200.00"})
|
||||
assert r.status_code == 409
|
||||
assert Decimal(client.get("/api/loans", headers=H).json()[0]["principal"]) == Decimal("2400.00")
|
||||
r = client.patch(f"/api/loans/{loan['id']}", headers=H,
|
||||
json={"principal": "1200.00", "doppelzaehlung_bestaetigt": True})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_vorschlag_uebernehmen_nutzt_denselben_endpunkt(client):
|
||||
# „Vorschlag übernehmen" sendet dieselben Felder (inkl. start_date/category_id=null).
|
||||
client.post("/api/loans", headers=H, json=KREDIT)
|
||||
r = client.post("/api/recurring", headers=H, json={
|
||||
**POSTEN, "start_date": None, "category_id": None})
|
||||
assert r.status_code == 409
|
||||
@@ -588,3 +588,46 @@ def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
for path in ("/", "/planung"):
|
||||
assert client.get(path).status_code == 200, path
|
||||
|
||||
|
||||
def test_base_hat_doppelzaehlungs_dialog(client):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
html = client.get("/").text
|
||||
assert '<script src="/static/doppelzaehlung.js"></script>' in html
|
||||
assert '<dialog id="doppelzaehlung-dialog">' in html
|
||||
assert 'id="dz-abbrechen" autofocus' in html
|
||||
assert ">Trotzdem speichern</button>" in html
|
||||
assert "FBDoppel.handle(e)" in html
|
||||
assert "doppelzaehlung_bestaetigt = true" in html
|
||||
|
||||
|
||||
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add_all([
|
||||
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
|
||||
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
|
||||
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
|
||||
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
|
||||
])
|
||||
db.commit()
|
||||
html = client.get("/planung").text
|
||||
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
|
||||
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
|
||||
assert html.count("⚠ zählt evtl. doppelt") == 2
|
||||
assert '<td data-sort="Rate Auto">' in html
|
||||
assert '<td data-sort="Auto">' in html
|
||||
|
||||
|
||||
def test_planung_ohne_konflikt_kein_hinweis(client, db):
|
||||
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
|
||||
db.commit()
|
||||
assert "zählt evtl. doppelt" not in client.get("/planung").text
|
||||
|
||||
|
||||
def test_vorschlag_formular_nutzt_json_form_post(client):
|
||||
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
|
||||
import re
|
||||
from pathlib import Path
|
||||
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
|
||||
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from app.models.views import RESERVED_SCENARIO_NAME
|
||||
from app.reserved import SYSTEM_PREFIX, is_reserved_name
|
||||
from app.services.planning_chart import KREDITRATEN
|
||||
|
||||
H = {"Authorization": "Bearer test-key"}
|
||||
|
||||
|
||||
def test_systemnamen_leiten_sich_vom_praefix_ab():
|
||||
assert SYSTEM_PREFIX == "System-"
|
||||
assert RESERVED_SCENARIO_NAME == "System-Ist"
|
||||
assert KREDITRATEN == "System-Kreditraten"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("System-X", True), ("system-kreditraten", True), (" SYSTEM-Ist ", True),
|
||||
("Kreditraten", False), ("Systemkosten", False), ("Ist-System", False),
|
||||
("System", False),
|
||||
])
|
||||
def test_is_reserved_name(name, expected):
|
||||
assert is_reserved_name(name) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["System-X", "system-kreditraten", " SYSTEM-Ist "])
|
||||
def test_kategorie_post_reserviert(client, name):
|
||||
r = client.post("/api/categories", headers=H, json={"name": name})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == ("Namen, die mit »System-« beginnen, sind für "
|
||||
"vom Tool erzeugte Einträge reserviert")
|
||||
|
||||
|
||||
def test_kategorie_patch_reserviert(client):
|
||||
cat = client.post("/api/categories", headers=H, json={"name": "Normal"}).json()
|
||||
r = client.patch(f"/api/categories/{cat['id']}", headers=H, json={"name": "System-Test"})
|
||||
assert r.status_code == 422
|
||||
names = [c["name"] for c in client.get("/api/categories", headers=H).json()]
|
||||
assert "Normal" in names and "System-Test" not in names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["Kreditraten", "Systemkosten", "Ist-System"])
|
||||
def test_kategorie_normale_namen_erlaubt(client, name):
|
||||
assert client.post("/api/categories", headers=H, json={"name": name}).status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["System-Plan", "system-x"])
|
||||
def test_szenario_praefix_reserviert(client, name):
|
||||
r = client.post("/api/scenarios", headers=H, json={"name": name})
|
||||
assert r.status_code == 422
|
||||
assert "reserviert" in r.json()["detail"]
|
||||
Reference in New Issue
Block a user