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_projection["projection_service.py<br/>run_projection, refresh_stale_projections"]
|
||||||
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
|
S_planning_chart["planning_chart.py<br/>refresh_planning_chart"]
|
||||||
S_admin["admin.py<br/>change_password, apply_rules_retroactively"]
|
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
|
end
|
||||||
|
|
||||||
|
N_reserved["app/reserved.py<br/>check_reserved_name"]
|
||||||
|
|
||||||
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
|
subgraph ENGINE["engine/ (rein, kein DB-Zugriff)"]
|
||||||
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
|
E_loans["loans.py<br/>loan_schedule, annuity_payment, add_months"]
|
||||||
E_recurrence["recurrence.py<br/>occurrences"]
|
E_recurrence["recurrence.py<br/>occurrences"]
|
||||||
@@ -86,7 +89,11 @@ flowchart TD
|
|||||||
R_imports --> S_importer
|
R_imports --> S_importer
|
||||||
R_planning --> E_loans
|
R_planning --> E_loans
|
||||||
R_planning --> S_suggestions
|
R_planning --> S_suggestions
|
||||||
|
R_planning --> S_doublecount
|
||||||
R_scenarios --> S_projection
|
R_scenarios --> S_projection
|
||||||
|
R_scenarios --> N_reserved
|
||||||
|
R_categories --> N_reserved
|
||||||
|
R_gui --> S_doublecount
|
||||||
R_admin --> S_admin
|
R_admin --> S_admin
|
||||||
|
|
||||||
ROUTERS -.-> M_tables
|
ROUTERS -.-> M_tables
|
||||||
@@ -315,9 +322,36 @@ heute), je bereits durchgerechnetem Szenario einen Startpunkt exakt heute
|
|||||||
Projektionspunkte NACH heute (`projection_points`, `day > CURRENT_DATE`) —
|
Projektionspunkte NACH heute (`projection_points`, `day > CURRENT_DATE`) —
|
||||||
Punkte <= heute aus einer älteren Berechnung erscheinen so nie.
|
Punkte <= heute aus einer älteren Berechnung erscheinen so nie.
|
||||||
`RESERVED_SCENARIO_NAME` ist für Szenario-Namen gesperrt:
|
`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.
|
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
|
**Abgeleitete Tabellen + Advisory-Locks (Ausbaustufe 11).** Zwei Datenstände
|
||||||
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
|
werden bei GUI-Seitenaufrufen automatisch aufgefrischt, nie beim reinen
|
||||||
API-Zugriff (`routers/gui.py::_safe_refresh`, fehlerresistent — ein Fehler
|
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`,
|
schreibt die Tabelle `planning_monthly` (`models/tables.py::PlanningMonthly`,
|
||||||
Migration `d5e8b3c7a410`) komplett neu — Monatssummen je Kategorie aus
|
Migration `d5e8b3c7a410`) komplett neu — Monatssummen je Kategorie aus
|
||||||
Planung (wiederkehrende Posten, Einmalposten, Kreditraten aller Kredite
|
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
|
Kategorie" (`Advisory-Lock PLANNING_CHART_LOCK = 110_002`), aufgerufen von
|
||||||
`index` und `planung_page`. Beide Tabellen sind jederzeit reproduzierbar
|
`index` und `planung_page`. Beide Tabellen sind jederzeit reproduzierbar
|
||||||
(kein Backup-Sonderfall).
|
(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 import text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
from app.reserved import SYSTEM_PREFIX
|
||||||
|
|
||||||
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
|
||||||
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein
|
# Für Szenarien reserviert über den System-Präfix, siehe app/reserved.py.
|
||||||
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11).
|
RESERVED_SCENARIO_NAME = f"{SYSTEM_PREFIX}Ist"
|
||||||
RESERVED_SCENARIO_NAME = "System-Ist"
|
|
||||||
|
|
||||||
# Per-account offset for the Konto-Saldo-Anker (Ausbaustufe 3 Task 1): the
|
# 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
|
# 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.auth import require_auth
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.models.tables import Category, CategoryRule
|
from app.models.tables import Category, CategoryRule
|
||||||
|
from app.reserved import check_reserved_name
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["categories"],
|
router = APIRouter(prefix="/api", tags=["categories"],
|
||||||
dependencies=[Depends(require_auth)])
|
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)
|
@router.post("/categories", response_model=CategoryOut, status_code=201)
|
||||||
def create_category(data: CategoryIn, session: Session = Depends(get_session)):
|
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():
|
if session.execute(select(Category).where(Category.name == data.name)).scalar():
|
||||||
raise HTTPException(409, "Kategorie existiert bereits")
|
raise HTTPException(409, "Kategorie existiert bereits")
|
||||||
cat = Category(**data.model_dump())
|
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)
|
@router.patch("/categories/{category_id}", response_model=CategoryOut)
|
||||||
def patch_category(category_id: int, data: CategoryIn,
|
def patch_category(category_id: int, data: CategoryIn,
|
||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
|
check_reserved_name(data.name)
|
||||||
cat = session.get(Category, category_id)
|
cat = session.get(Category, category_id)
|
||||||
if cat is None:
|
if cat is None:
|
||||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
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.scenarios import list_scenarios as _list_scenarios
|
||||||
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
from app.routers.transactions import count_transactions, list_transactions, normalize_sort
|
||||||
from app.services.balances import account_balance, total_balance
|
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.planning_chart import refresh_planning_chart
|
||||||
from app.services.projection_service import refresh_stale_projections
|
from app.services.projection_service import refresh_stale_projections
|
||||||
from app.version import get_version
|
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)
|
recurring = _list_recurring(session=session)
|
||||||
planned = _list_planned(session=session)
|
planned = _list_planned(session=session)
|
||||||
loans = _list_loans(session=session)
|
loans = _list_loans(session=session)
|
||||||
|
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", {
|
return templates.TemplateResponse(request, "planning.html", {
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"category_names": {c.id: c.name for c in categories},
|
"category_names": {c.id: c.name for c in categories},
|
||||||
@@ -347,6 +353,8 @@ def planung_page(request: Request, session: Session = Depends(get_session)):
|
|||||||
"loans": loans,
|
"loans": loans,
|
||||||
"rhythms": ["monthly", "quarterly", "yearly"],
|
"rhythms": ["monthly", "quarterly", "yearly"],
|
||||||
"repayment_types": ["annuity", "bullet"],
|
"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.engine.loans import loan_schedule
|
||||||
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
from app.models.tables import (Category, Loan, PlannedItem, RecurringItem,
|
||||||
ScenarioLoan)
|
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
|
from app.services.suggestions import suggest_recurring
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["planning"],
|
router = APIRouter(prefix="/api", tags=["planning"],
|
||||||
@@ -36,6 +40,11 @@ class RecurringIn(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringCreate(RecurringIn):
|
||||||
|
# Nur Anfrage: bestätigt eine erkannte Doppelzählung (nicht gespeichert).
|
||||||
|
doppelzaehlung_bestaetigt: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RecurringOut(RecurringIn):
|
class RecurringOut(RecurringIn):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
@@ -49,6 +58,7 @@ class RecurringPatch(BaseModel):
|
|||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
end_date: date | None = None
|
end_date: date | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
|
doppelzaehlung_bestaetigt: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SuggestionOut(BaseModel):
|
class SuggestionOut(BaseModel):
|
||||||
@@ -66,6 +76,26 @@ def _check_category(session: Session, category_id: int | None) -> None:
|
|||||||
raise HTTPException(404, "Kategorie nicht gefunden")
|
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])
|
@router.get("/recurring", response_model=list[RecurringOut])
|
||||||
def list_recurring(session: Session = Depends(get_session)):
|
def list_recurring(session: Session = Depends(get_session)):
|
||||||
return [RecurringOut.model_validate(r)
|
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)
|
@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)
|
_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.add(item)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(item)
|
session.refresh(item)
|
||||||
@@ -94,7 +131,8 @@ def patch_recurring(item_id: int, data: RecurringPatch,
|
|||||||
item = session.get(RecurringItem, item_id)
|
item = session.get(RecurringItem, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(404, "Fixposten nicht gefunden")
|
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:
|
if "category_id" in fields:
|
||||||
_check_category(session, fields["category_id"])
|
_check_category(session, fields["category_id"])
|
||||||
for key, value in fields.items():
|
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):
|
and item.end_date < item.start_date):
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(422, "Ende darf nicht vor Start liegen")
|
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.commit()
|
||||||
session.refresh(item)
|
session.refresh(item)
|
||||||
return RecurringOut.model_validate(item)
|
return RecurringOut.model_validate(item)
|
||||||
@@ -188,6 +232,10 @@ class LoanIn(BaseModel):
|
|||||||
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
repayment_type: Literal["annuity", "bullet"] = "annuity"
|
||||||
|
|
||||||
|
|
||||||
|
class LoanCreate(LoanIn):
|
||||||
|
doppelzaehlung_bestaetigt: bool = False
|
||||||
|
|
||||||
|
|
||||||
class LoanOut(LoanIn):
|
class LoanOut(LoanIn):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
@@ -200,6 +248,7 @@ class LoanPatch(BaseModel):
|
|||||||
term_months: int | None = None
|
term_months: int | None = None
|
||||||
payout_date: date | None = None
|
payout_date: date | None = None
|
||||||
repayment_type: Literal["annuity", "bullet"] | None = None
|
repayment_type: Literal["annuity", "bullet"] | None = None
|
||||||
|
doppelzaehlung_bestaetigt: bool = False
|
||||||
|
|
||||||
|
|
||||||
class InstallmentOut(BaseModel):
|
class InstallmentOut(BaseModel):
|
||||||
@@ -217,8 +266,15 @@ def list_loans(session: Session = Depends(get_session)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/loans", response_model=LoanOut, status_code=201)
|
@router.post("/loans", response_model=LoanOut, status_code=201)
|
||||||
def create_loan(data: LoanIn, session: Session = Depends(get_session)):
|
def create_loan(data: LoanCreate, session: Session = Depends(get_session)):
|
||||||
loan = Loan(**data.model_dump())
|
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.add(loan)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(loan)
|
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)
|
loan = session.get(Loan, loan_id)
|
||||||
if loan is None:
|
if loan is None:
|
||||||
raise HTTPException(404, "Kredit nicht gefunden")
|
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)
|
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.commit()
|
||||||
session.refresh(loan)
|
session.refresh(loan)
|
||||||
return LoanOut.model_validate(loan)
|
return LoanOut.model_validate(loan)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.db import get_session
|
|||||||
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
from app.models.tables import (Loan, ProjectionPoint, ProjectionResult,
|
||||||
Scenario, ScenarioLoan, ScenarioModifier,
|
Scenario, ScenarioLoan, ScenarioModifier,
|
||||||
ScenarioPlannedItem)
|
ScenarioPlannedItem)
|
||||||
from app.models.views import RESERVED_SCENARIO_NAME
|
from app.reserved import check_reserved_name
|
||||||
from app.services.projection_service import run_projection
|
from app.services.projection_service import run_projection
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"],
|
||||||
@@ -86,14 +86,6 @@ def _get_scenario(session: Session, scenario_id: int) -> Scenario:
|
|||||||
return 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])
|
@router.get("", response_model=list[ScenarioOut])
|
||||||
def list_scenarios(session: Session = Depends(get_session)):
|
def list_scenarios(session: Session = Depends(get_session)):
|
||||||
return [ScenarioOut.model_validate(s)
|
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)
|
@router.post("", response_model=ScenarioOut, status_code=201)
|
||||||
def create_scenario(data: ScenarioIn, session: Session = Depends(get_session)):
|
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():
|
if session.execute(select(Scenario).where(Scenario.name == data.name)).scalar():
|
||||||
raise HTTPException(409, "Szenario existiert bereits")
|
raise HTTPException(409, "Szenario existiert bereits")
|
||||||
scenario = Scenario(**data.model_dump())
|
scenario = Scenario(**data.model_dump())
|
||||||
@@ -117,7 +109,7 @@ def patch_scenario(scenario_id: int, data: ScenarioPatch,
|
|||||||
session: Session = Depends(get_session)):
|
session: Session = Depends(get_session)):
|
||||||
scenario = _get_scenario(session, scenario_id)
|
scenario = _get_scenario(session, scenario_id)
|
||||||
fields = data.model_dump(exclude_unset=True)
|
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:
|
if "name" in fields and fields["name"] != scenario.name:
|
||||||
clash = session.execute(
|
clash = session.execute(
|
||||||
select(Scenario).where(Scenario.name == fields["name"])).scalar()
|
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):
|
Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
|
||||||
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
|
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
|
||||||
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen,
|
„System-Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit
|
||||||
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel
|
Vorzeichen, Semantik wie v_monthly_by_category. Kreditraten brauchen die
|
||||||
(engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
Annuitätenformel (engine/loans.py) - daher Python + Tabelle statt SQL-View.
|
||||||
"""
|
"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -19,9 +19,10 @@ from app.engine.loans import loan_schedule
|
|||||||
from app.engine.recurrence import occurrences
|
from app.engine.recurrence import occurrences
|
||||||
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
|
||||||
RecurringItem)
|
RecurringItem)
|
||||||
|
from app.reserved import SYSTEM_PREFIX
|
||||||
|
|
||||||
UNKATEGORISIERT = "unkategorisiert"
|
UNKATEGORISIERT = "unkategorisiert"
|
||||||
KREDITRATEN = "Kreditraten"
|
KREDITRATEN = f"{SYSTEM_PREFIX}Kreditraten"
|
||||||
PLANNING_CHART_LOCK = 110_002
|
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;
|
color: inherit;
|
||||||
font-weight: bold;
|
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">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<script src="/static/htmx.min.js"></script>
|
||||||
<script src="/static/sort.js"></script>
|
<script src="/static/sort.js"></script>
|
||||||
|
<script src="/static/doppelzaehlung.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -26,8 +27,18 @@
|
|||||||
<main>
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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>
|
<script>
|
||||||
document.body.addEventListener('htmx:responseError', function (e) {
|
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));
|
alert('Fehler: ' + (e.detail.xhr.responseText || e.detail.xhr.status));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,6 +83,10 @@
|
|||||||
out[field.name] = field.checked;
|
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');
|
xhr.overrideMimeType('text/json');
|
||||||
return JSON.stringify(out);
|
return JSON.stringify(out);
|
||||||
}
|
}
|
||||||
@@ -82,6 +97,8 @@
|
|||||||
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
document.getElementById(prefix + '-row-' + id).hidden = editing;
|
||||||
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
document.getElementById(prefix + '-edit-' + id).hidden = !editing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.FBDoppel) { window.FBDoppel.init(document); }
|
||||||
</script>
|
</script>
|
||||||
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
<footer class="version">Finanzberatungs-Tool v{{ app_version }}</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -87,12 +87,33 @@
|
|||||||
Kategorie, der Szenario-Vergleich als Kurven (darin die Ist-Kurve
|
Kategorie, der Szenario-Vergleich als Kurven (darin die Ist-Kurve
|
||||||
„System-Ist“ bis heute, ab heute je Szenario weiter) und das Panel
|
„System-Ist“ bis heute, ab heute je Szenario weiter) und das Panel
|
||||||
„Planung nach Kategorie“ (Monatssummen aus der reinen Planung inkl.
|
„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
|
Passwort wie hier anmelden, dann erscheint das Dashboard auch in der
|
||||||
Übersicht.
|
Übersicht.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>
|
<p>
|
||||||
<strong>Tabellen sortieren:</strong> ein Klick auf einen Spaltenkopf
|
<strong>Tabellen sortieren:</strong> ein Klick auf einen Spaltenkopf
|
||||||
sortiert die Tabelle danach (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend);
|
sortiert die Tabelle danach (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for r in recurring %}
|
{% for r in recurring %}
|
||||||
<tr id="rec-row-{{ r.id }}">
|
<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 class="{{ 'neg' if r.amount < 0 else '' }}" data-sort="{{ r.amount }}">{{ r.amount|eur }} €</td>
|
||||||
<td>{{ r.rhythm|de_label }}</td>
|
<td>{{ r.rhythm|de_label }}</td>
|
||||||
<td>{{ r.due_day }}</td>
|
<td>{{ r.due_day }}</td>
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for l in loans %}
|
{% for l in loans %}
|
||||||
<tr id="loan-row-{{ l.id }}">
|
<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 data-sort="{{ l.principal }}">{{ l.principal|eur }} €</td>
|
||||||
<td>{{ l.annual_rate_pct|eur }} %</td>
|
<td>{{ l.annual_rate_pct|eur }} %</td>
|
||||||
<td data-sort="{{ l.term_months }}">{{ l.term_months }} Monate</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"})
|
client.post("/login", data={"username": "admin", "password": "geheim"})
|
||||||
for path in ("/", "/planung"):
|
for path in ("/", "/planung"):
|
||||||
assert client.get(path).status_code == 200, path
|
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