Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster, unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar. Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos, bei Monte-Carlo je Szenario einzeln. Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug trotzdem bricht, warnt der Dialog vorher namentlich. Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus. Migration gegen echtes Postgres verifiziert. Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+171
-4
@@ -4,7 +4,7 @@
|
||||
| | |
|
||||
|---|---|
|
||||
| **Dokument** | Funktionale und Technische Spezifikation FPT |
|
||||
| **Version** | 0.18 |
|
||||
| **Version** | 0.19 |
|
||||
| **Datum** | 2026-07-18 |
|
||||
| **Status** | Lebendes Dokument |
|
||||
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
| Version | Datum | Autor | Änderung |
|
||||
|---|---|---|---|
|
||||
| 0.19 | 2026-07-19 | Claude (Opus 4.8) | **Versionierung und Änderungshistorie je Szenario** (neues Kapitel 3.8). Jedes Szenario trägt eine Version **A.B**: **B** entsteht automatisch, **A** manuell mit Pflichtkommentar. **Der zentrale Entwurfsentscheid:** FPT hat keinen Speichern-Knopf – jede Änderung schreibt sofort, ein Assistenten-Durchlauf macht ~14 Schreibvorgänge, ein Verteil-Klick einen je Zielelement. Eine Version je Schreibvorgang wäre ein Tastenprotokoll gewesen; stattdessen werden alle Schreibvorgänge innerhalb von **10 Minuten zu einer** Nebenversion zusammengefasst, inhaltlich unveränderte Stände erzeugen gar keine, und verschiedene Benutzer laufen nie in einer Version zusammen. Eine Version hält den **vollständigen** Zustand als JSON in der Form `PlanInput` – dadurch ist die **Versionsauswahl in allen vier Analysewerkzeugen** (Grafiken, Live-Simulation, Monte-Carlo, Einflussfaktoren) fast kostenlos; bei Monte-Carlo **je Szenario einzeln**, weil dort mehrere gleichzeitig laufen. **Wiederherstellen** ist ungefährlich gebaut: Es legt den zurückgesetzten Stand selbst als neue Version an («Wiederhergestellt aus A.B»), löscht also nichts, und **erhält die IDs** von Phasen und Elementen – sonst verlören alle Kind-Szenarien ihre Diff-Basis und zeigten schlagartig alles als «neu». Wo ein Bezug trotzdem bricht (der alte Stand kannte das Element noch nicht), **warnt der Dialog vorher namentlich**. Die destruktive Logik liegt als reine Funktion `planRestore` vor und ist dort getestet; `versioning-db.ts` führt sie nur aus. Ein **statischer Wächter-Test** liest alle Route-Dateien und verlangt, dass jeder schreibende Endpunkt eine Version auslöst – eine vergessene Stelle wäre eine stille Lücke. Neue Tabelle `ScenarioVersion` + `Scenario.currentMajor` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/scenarios/<id>/versions`. Neue Kapitel 3.8 und 9.28; 25 Tests ergänzt (139 → 164). |
|
||||
| 0.18 | 2026-07-19 | Claude (Opus 4.8) | **Live-Simulation** (Roadmap Nr. 22). Neuer Button und Dialog als Zweispalter: links Schieberegler, rechts eine wählbare Grafik, darüber eine Kennzahlenleiste. Dreht man an einem Regler, wird der Plan **sofort** neu gerechnet – ohne für jede Variante eine Szenario-Kopie anzulegen. **Keine eigene Rechenlogik:** Die Regler benutzen dieselben Transformationen wie der Tornado (`applyDriver`), können also gar nicht etwas anderes zeigen als die Einflussfaktoren-Analyse. Neu ist nur `applyElementDriver` – dieselbe Verschiebung auf ein **einzelnes** Element statt auf eine ganze Kategorie: Standardmässig gibt es einen Sammelregler «Rendite», ein Klick auf «Renditen einzeln aufschlüsseln» ersetzt ihn durch je einen Regler pro Anlage (der Sammelregler wird dabei **entfernt**, nicht ergänzt, sonst zählte eine Bewegung doppelt; ein Test sichert ab, dass beide Ansichten denselben Plan beschreiben). Der unveränderte Plan wird als **Referenzlinie** mitgezeichnet, und die Kennzahlenleiste weist Endvermögen nominal/real **mit Differenz zum Plan** aus sowie – als eigene Karte – ob das Kapital reicht; ein gekippter Plan ist einer Verlaufslinie sonst nicht anzusehen. Gemessene Laufzeit von `computePlan`: **0.2 ms** auf einem 60-Jahres-Plan mit 10 Elementen, also rund 1 % des 16-ms-Frame-Budgets – deshalb wird synchron gerechnet, **ohne Debounce und ohne Worker**. Anders als der Tornado haben die Regler **Standardbereiche** (Begründung des scheinbaren Widerspruchs zu 9.18: neues Kapitel 9.27), beide Enden editierbar. **Das Pensionsalter fehlt weiterhin** (9.18, eigener Roadmap-Punkt); «Als Szenario speichern» ist bewusst zurückgestellt, ersatzweise zeigt der Dialog die aktive Einstellung als lesbare Zeile. Die Vermögensaufteilung wurde als `AllocationChart` aus dem Dashboard herausgelöst, damit beide sie nutzen. Neue Kapitel 4.15 und 9.27; 15 Tests ergänzt (124 → 139). Keine API-, DB- oder Schreib-Änderung – das Feature liest ausschliesslich. Nebenbei dieselbe vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) wie in 0.17, diesmal im Dashboard. |
|
||||
| 0.17 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo: zwei Welten, vier Fälle.** Behebt einen Darstellungs-Widerspruch: Zuvor konnte «Planung 69 % erreicht» neben «Ziel 3 Mio nur 41 %» stehen, obwohl 3 Mio unter dem Plan-Endbetrag von 3.7 Mio lag – die beiden Zahlen stammten aus **verschiedenen simulierten Welten**. Neu läuft die Simulation **immer zweimal** (historische Renditen / geplante Werte, gemeinsamer Seed) und liest aus **jeder** Verteilung **beide** Schwellen ab: Plan-Endbetrag und Zielbetrag. Fall 1 und Fall 3 stammen damit aus derselben Verteilung, wodurch ein tieferes Ziel **nie** unwahrscheinlicher sein kann als ein höheres – der Widerspruch ist strukturell ausgeschlossen (Test). Zweite Korrektur: Der Nullpunkt für das Urteil ist **nicht 50 %**, sondern **Fall 2** (derselbe Schwellwert in der eigenen geplanten Welt); durch den Volatilitäts-Drag liegt der je nach Streuung bei 27–48 %. Verglichen wird Fall 1 gegen Fall 2 mit ± 5 pp Toleranzband → «zurückhaltend / realistisch / zu optimistisch». Darstellung: Fall 1 prominent mit Urteil, Fall 3+4 als Satzpaar untergeordnet, Fall 2 und die Mediane klein als Referenz. Der Drei-Wege-Umschalter aus 0.16 entfällt; historische Mittelwerte **und** Zielbetrag sind jetzt beide Pflicht. Technisch: `MonteCarloResult.finalWealthSorted` (alle Endvermögen sortiert) plus neuer Helfer `probabilityAtLeast` (Binärsuche) – vier Zahlen aus zwei Läufen statt vier Läufen. Kapitel 4.12.7 und 9.26 neu gefasst; 3 Tests ergänzt (121 → 124). Keine Änderung am Rechenkern. |
|
||||
| 0.16 | 2026-07-19 | Claude (Opus 4.8) | **Monte-Carlo mit zwei Fragestellungen** (Roadmap Nr. 46). Ein Umschalter oben trennt: **«Planung prüfen»** (Fall 1, wie bisher) würfelt um die **historischen** Renditen und prüft gegen den **Planungs-Endbetrag** (read-only) – «wie realistisch ist meine Planung?». **«Ziel prüfen»** (Fall 2, neu) würfelt um die **geplanten** Werte aus dem Plan und prüft gegen einen **manuellen Zielbetrag** – «erreiche ich mein Ziel?»; hier sind keine historischen Mittelwerte nötig, nur die Streuung. **«Beides»** rechnet beide Durchgänge gleichzeitig (gemeinsamer Seed). Neue Ergebnis-**Deutungstexte** je Fall (weich formuliert wegen des Volatilitäts-Drags). Der Fächer stammt aus dem historischen Durchgang; der Zielbetrag ist in Fall 2 **einer für alle** Szenarien. `runMonteCarloMulti` nimmt neu die Inflation **je Szenario** (`inflationMeanFor`); neuer Helfer `plannedReturnOf`. Kapitel 4.12 überarbeitet, 4.12.7 und 9.26 neu; 8 Tests ergänzt (119 → 121). Nebenbei eine vom Umlaut-Sweep verstümmelte Hex-Farbe (`#7c3aed`) korrigiert. Keine Änderung am Rechenkern. |
|
||||
@@ -1204,6 +1205,104 @@ Jede Element-Zeile der Matrix trägt eine **Sparkline** (`Sparkline.tsx`): der W
|
||||
(`ElementPhaseComputed.yearly`, seit 0.11) – keine Neuberechnung, reine Darstellung. Flache
|
||||
Verläufe werden nicht gezeichnet (keine Information).
|
||||
|
||||
## 3.8 Versionierung und Änderungshistorie
|
||||
|
||||
Jedes **Szenario** trägt eine Version **A.B** und eine vollständige Änderungshistorie. Zugang
|
||||
über den Knopf **«Änderungshistorie»** in der Szenario-Leiste.
|
||||
|
||||
| | Bedeutung | Entsteht |
|
||||
|---|---|---|
|
||||
| **B** (Nebenversion) | ein Bearbeitungsstand | **automatisch**, eine je Bearbeitungssitzung |
|
||||
| **A** (Hauptversion) | ein bewusst gesetzter Meilenstein | **manuell**, mit Pflichtkommentar; setzt B auf 0 |
|
||||
|
||||
### 3.8.1 Eine Version je Sitzung – nicht je Änderung
|
||||
|
||||
FPT hat **keinen Speichern-Knopf**: Jede Änderung schreibt sofort. Eine Version je
|
||||
Schreibvorgang wäre deshalb ein Tastenprotokoll und keine Historie – ein Durchlauf des
|
||||
Plan-Assistenten macht rund **14** Schreibvorgänge, ein Klick im Verteil-Dialog einen **je
|
||||
Zielelement**.
|
||||
|
||||
Stattdessen werden alle Schreibvorgänge innerhalb eines **Zeitfensters von 10 Minuten** zu
|
||||
**einer** Nebenversion zusammengefasst: Der erste legt sie an, alle weiteren aktualisieren
|
||||
sie. Zwei Sicherungen ergänzen das:
|
||||
|
||||
- **Unverändert = keine Version.** Ergibt ein Schreibvorgang inhaltlich denselben Stand
|
||||
(Dialog geöffnet und unverändert geschlossen), entsteht nichts. Verglichen wird über eine
|
||||
Serialisierung mit **sortierten Schlüsseln** – ohne das würden identische Stände als
|
||||
verschieden gelten, weil die Reihenfolge der Werte aus der Datenbank-Zeilenfolge stammt.
|
||||
- **Benutzerwechsel trennt immer.** Änderungen verschiedener Benutzer laufen nie in einer
|
||||
Version zusammen, auch nicht innerhalb des Fensters – Vorbereitung auf die spätere Freigabe
|
||||
an einen Finanzberater.
|
||||
|
||||
Eine **Hauptversion** wird nie zusammengefasst und nie nachträglich verändert; die nächste
|
||||
Änderung beginnt bei A.1.
|
||||
|
||||
### 3.8.2 Was die Historie zeigt
|
||||
|
||||
Je Version: **wer** (Benutzername), **wann** (Zeitpunkt der letzten Änderung dieser Sitzung)
|
||||
und – bei Hauptversionen – der **Kommentar**. Die oberste Zeile ist der aktuelle Stand.
|
||||
|
||||
Zwei Aktionen je Version:
|
||||
|
||||
- **Anzeigen** – ein Nur-Lese-Fenster mit der Matrix dieses Standes ([VersionMatrix](#552-komponenten)),
|
||||
Phasen als Spalten, Elemente als Zeilen, dazu Endvermögen und Ruinalter. Bewusst **nicht**
|
||||
die Bearbeitungs-Matrix: Inspector, Übergangs-Ampeln und Diff-Markierung haben für einen
|
||||
alten Stand keine Bedeutung.
|
||||
- **Wiederherstellen** – setzt das Szenario vollständig auf diesen Stand zurück.
|
||||
|
||||
### 3.8.3 Wiederherstellen
|
||||
|
||||
Zwei Eigenschaften machen den Knopf ungefährlich:
|
||||
|
||||
**Es geht nichts verloren.** Der wiederhergestellte Stand wird selbst als **neue Version**
|
||||
festgehalten, mit dem Kommentar «Wiederhergestellt aus A.B». Die bisherige Historie bleibt
|
||||
vollständig – auch alles, was nach dem Zielstand kam.
|
||||
|
||||
**IDs bleiben erhalten.** Phasen und Elemente behalten ihre IDs (der Snapshot trägt sie mit).
|
||||
Das ist keine Kosmetik: Kind-Szenarien zeigen über `sourceElementId` / `sourcePhaseId` auf
|
||||
genau diese IDs, und daran hängt die Abweichungs-Markierung
|
||||
([3.2.6](#326-abweichungs-markierung-diff)). Würden neue IDs entstehen, erschiene in
|
||||
jedem Kind-Szenario schlagartig **alles als «neu»** statt als «geändert».
|
||||
|
||||
Restlos vermeiden lässt sich das nicht: Geht man auf einen Stand zurück, in dem ein Element
|
||||
noch gar nicht existierte, auf das ein Kind verweist, bricht dieser eine Bezug zwangsläufig.
|
||||
Der Dialog **prüft das vorher** und benennt die betroffenen Szenarien namentlich, statt es
|
||||
stillschweigend zu tun.
|
||||
|
||||
### 3.8.4 Analyse auf einer bestimmten Version
|
||||
|
||||
Alle vier Analysewerkzeuge – **Grafiken**, **Live-Simulation**, **Monte-Carlo** und
|
||||
**Einflussfaktoren** – haben oben einen Wähler **«Berechnungsgrundlage»**: aktueller Stand
|
||||
(Vorgabe) oder eine festgehaltene Version.
|
||||
|
||||
In der **Monte-Carlo-Simulation** ist die Auswahl **zweidimensional**: Sie rechnet mehrere
|
||||
Szenarien gleichzeitig, und der Wähler hängt deshalb an der jeweiligen Szenario-Zeile – man
|
||||
kann Szenario A in Version 1.2 gegen Szenario B in Version 2.0 stellen.
|
||||
|
||||
Das war fast kostenlos: Der Snapshot ist bereits ein **`PlanInput`**, also genau der Typ, den
|
||||
alle Werkzeuge ohnehin entgegennehmen. Es genügt, den Arbeitsstand durch den Snapshot zu
|
||||
ersetzen; das Gerechnete entsteht lokal über `computePlan`.
|
||||
|
||||
**Eine Ausnahme ist beschriftet:** Der CSV-Export liest immer das Szenario aus der Datenbank
|
||||
und liefert deshalb den **aktuellen** Stand, nicht die betrachtete Version. Der Knopf sagt das
|
||||
in diesem Fall ausdrücklich.
|
||||
|
||||
### 3.8.5 Vollständigkeit der Historie
|
||||
|
||||
Die Versionierung hängt daran, dass **jeder** inhaltsverändernde Endpunkt sie auslöst
|
||||
(`touchScenario`). Ein vergessener Pfad fiele nicht auf – er erzeugte still keine Version, und
|
||||
die Lücke bemerkte man erst Wochen später. Ein **Test liest deshalb alle Route-Dateien** und
|
||||
verlangt, dass jeder schreibende Endpunkt entweder `touchScenario` aufruft oder mit Begründung
|
||||
in einer Ausnahmeliste steht; verwaiste Ausnahmen meldet er ebenfalls.
|
||||
|
||||
Das Festhalten einer Version ist bewusst **fehlertolerant**: Schlägt es fehl, wird es
|
||||
protokolliert, die eigentliche Änderung des Nutzers aber nicht zurückgewiesen. Die Historie
|
||||
ist Begleitinformation, nicht der Zweck der Anfrage.
|
||||
|
||||
Referenz: `src/lib/versioning.ts` (reine Logik), `src/lib/versioning-db.ts` (Datenbank),
|
||||
`src/components/VersionHistoryDialog.tsx`, `src/components/VersionMatrix.tsx`,
|
||||
`src/components/VersionPicker.tsx`.
|
||||
|
||||
---
|
||||
|
||||
# 4. Berechnungsmodell
|
||||
@@ -2406,7 +2505,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
|
||||
FPT/
|
||||
├── prisma/
|
||||
│ ├── schema.prisma Datenmodell
|
||||
│ └── migrations/ 12 Migrationen (chronologisch, siehe 5.4.6)
|
||||
│ └── migrations/ 13 Migrationen (chronologisch, siehe 5.4.6)
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── api/ Route Handlers (siehe Kapitel 6)
|
||||
@@ -2447,6 +2546,8 @@ PlanComputed ← an den Client geliefert
|
||||
| `constants.ts` | Schweizer Systemparameter |
|
||||
| `montecarlo.ts` | Monte-Carlo-Simulation (Sampler + Treiber), Szenario-Vergleich und Element-Gruppierung über die Herkunfts-Kette. Keine I/O, läuft im Browser. |
|
||||
| `sensitivity.ts` | Sensitivitätsanalyse / Tornado: Treiber-Katalog, Parameter-Transformationen, `computeTornado`; zusätzlich `applyElementDriver` / `tunableElements` für die einzeln regelbaren Element-Renditen. Rein, läuft im Browser. |
|
||||
| `versioning.ts` | Versionierung: Nummerierung A.B, Zusammenfassung je Sitzung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien. Rein, ohne I/O. |
|
||||
| `versioning-db.ts` | Datenbank-Anbindung der Versionierung: Snapshot festhalten, Hauptversion, Wiederherstellen. Führt den Bauplan aus `versioning.ts` nur aus. |
|
||||
| `livesim.ts` | Live-Simulation: Reglerkatalog mit Standardbereichen, Anwenden mehrerer Regler, Kennzahlen (Kap. 4.15). Rein, läuft im Browser. |
|
||||
| `distribution.ts` | Kapitaltopf und Quoten-Zerlegung, Anwenden von Entwurfswerten für die Verteil-Werkzeuge (Kap. 3.6.9/3.6.10). Rein. |
|
||||
| `phaseplan.ts` | Ableitung der Lebensabschnitte (Erwerb/Misch/Pension) aus den fixen Pensionierungszeitpunkten für den Assistenten (Kap. 3.2.8). Rein. |
|
||||
@@ -2658,6 +2759,7 @@ sondern zu leeren Werten.
|
||||
| `20260716230000_phase_cash_transition` | `Phase.cashTransition` (JSONB) für einmalige Sonderein-/ausgaben |
|
||||
| `20260718090000_plan_scenario_hierarchy` | **V6**: `Plan` → `Scenario` (IDs erhalten), neuer Behälter `Plan`, `planId` → `scenarioId`, Herkunfts-Verweise |
|
||||
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt |
|
||||
| `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
|
||||
|
||||
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um – dadurch
|
||||
bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
|
||||
@@ -2705,6 +2807,9 @@ wird der Plan neu geladen; die Berechnung kommt immer vom Server.
|
||||
| `PhaseDetail` | 95 | Phase bearbeiten/löschen |
|
||||
| `SparquoteChart` | 64 | Einkommen/Ausgaben pro Jahr mit Spar-/Verzehrbändern |
|
||||
| `MonteCarloDialog` | ~600 | Monte-Carlo-Dialog: Erklärung, Szenario-Auswahl, Eingaben, Lauf, Vergleichstabelle + Fächer |
|
||||
| `VersionHistoryDialog` | ~290 | Änderungshistorie: Liste, Hauptversion festlegen, Anzeigen, Wiederherstellen mit Warnung ([3.8](#38-versionierung-und-änderungshistorie)) |
|
||||
| `VersionMatrix` | ~110 | Nur-Lese-Matrix eines festgehaltenen Standes |
|
||||
| `VersionPicker` | ~140 | Wahl der Berechnungsgrundlage in den vier Analysewerkzeugen |
|
||||
| `LiveSimDialog` | ~330 | Live-Simulation: Regler links, Grafikwahl rechts, Kennzahlenleiste mit Differenz zum Plan ([4.15](#415-live-simulation-was-wäre-wenn-regler)) |
|
||||
| `AllocationChart` | ~80 | Gestapelte Vermögensaufteilung je Phase; aus dem Dashboard herausgelöst, damit die Live-Simulation sie mitbenutzen kann |
|
||||
| `SensitivityDialog` | ~330 | Einflussfaktoren: Erklärung, Treiber-Auswahl mit Bandbreiten, Tornado + Tabelle |
|
||||
@@ -2838,7 +2943,34 @@ Akzeptiert eine **Union** von zwei Formen:
|
||||
→ 201 `{ scenarioId: "<neue Id>" }`
|
||||
|
||||
### `GET /api/scenarios/<scenarioId>/export`
|
||||
→ `text/csv; charset=utf-8`, `Content-Disposition: attachment`.
|
||||
→ `text/csv; charset=utf-8`, `Content-Disposition: attachment`. Liefert immer den **aktuellen**
|
||||
Stand, auch wenn im Client eine ältere Version betrachtet wird ([3.8.4](#384-analyse-auf-einer-bestimmten-version)).
|
||||
|
||||
### `GET /api/scenarios/<scenarioId>/versions`
|
||||
Änderungshistorie, neueste zuerst – **ohne** die Snapshots (je zig Kilobyte).
|
||||
```json
|
||||
{ "currentMajor": 2,
|
||||
"versions": [ { "id", "major", "minor", "comment", "isMajor",
|
||||
"createdAt", "updatedAt", "author" } ] }
|
||||
```
|
||||
|
||||
### `POST /api/scenarios/<scenarioId>/versions`
|
||||
`{ comment }` (Pflicht, ≥ 3 Zeichen) – legt den aktuellen Stand als **Hauptversion** fest und
|
||||
setzt `Scenario.currentMajor`. → 201 `{ version: { major, minor } }` · 400 ohne Kommentar.
|
||||
|
||||
### `GET /api/scenarios/<scenarioId>/versions/<versionId>`
|
||||
Ein einzelner Stand samt Berechnung und der Vorwarnung für das Wiederherstellen:
|
||||
```json
|
||||
{ "version": { "id", "major", "minor", "comment", "isMajor", "createdAt" },
|
||||
"plan": <PlanInput>, "computed": <PlanComputed>,
|
||||
"impact": { "lostElementIds": [], "lostPhaseIds": [], "affectedChildren": [] } }
|
||||
```
|
||||
`impact` benennt die Kind-Szenarien, die durch ein Wiederherstellen ihre Diff-Basis verlören.
|
||||
|
||||
### `POST /api/scenarios/<scenarioId>/versions/<versionId>`
|
||||
Setzt das Szenario auf diesen Stand zurück – **IDs bleiben erhalten**, und der wiederhergestellte
|
||||
Stand wird selbst als neue Version festgehalten («Wiederhergestellt aus A.B»).
|
||||
→ 200 `{ version: { major, minor } }`
|
||||
|
||||
## 6.4 Phasen
|
||||
|
||||
@@ -2949,12 +3081,14 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
|
||||
| `explain.test.ts` | 12 | Verlaufswerte je Element, Vollständigkeit beider Wasserfall-Zerlegungen, Rechenweg-Protokoll, Gültigkeit der Spezifikations-Verweise |
|
||||
| `montecarlo.test.ts` | 18 | Determinismus, Volatilität/Vol-Drag, Böden, Reproduzierbarkeit; Element-Gruppierung, Szenario-Vergleich, Inflation je Szenario, plannedReturnOf; Schwellen-Ablesung (probabilityAtLeast), Monotonie über Schwellen, Fall 2 systematisch unter 50 % |
|
||||
| `distribution.test.ts` | 8 | Kapitaltopf und Quoten-Zerlegung gegen die Cash-Brücke; Entwurfswerte anwenden ohne Verlust bestehender Felder |
|
||||
| `versioning.test.ts` | 22 | Nummerierung und Zusammenfassung je Sitzung, unveränderte Stände, Benutzertrennung, Bauplan des Wiederherstellens, Auswirkung auf Kind-Szenarien |
|
||||
| `versioning-coverage.test.ts` | 3 | statischer Wächter: jeder schreibende Endpunkt löst eine Version aus |
|
||||
| `livesim.test.ts` | 15 | Element-Regler bewegt genau ein Element; Aufschlüsselung deckt sich mit dem Sammelregler; neutrale Stellung verändert den Plan nicht; Ruinmeldung |
|
||||
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen |
|
||||
| `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus |
|
||||
| `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
|
||||
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein |
|
||||
| **Total** | **139** | |
|
||||
| **Total** | **164** | |
|
||||
|
||||
## 8.2 Testfälle
|
||||
|
||||
@@ -3416,6 +3550,39 @@ neben jedem Regler steht sein **Neutralpunkt** – der Wert, bei dem der Plan un
|
||||
Bei allen Verschiebungs-Treibern ist das 0, bei der Inflation der Planwert selbst, weil sie als
|
||||
einzige absolut und nicht als Differenz eingegeben wird.
|
||||
|
||||
## 9.28 Was die Versionierung nicht leistet
|
||||
|
||||
**Das Zeitfenster ist eine Konvention, keine Wahrheit.** Zehn Minuten sind gesetzt, weil FPT
|
||||
keinen Speichern-Knopf hat und der Nutzer den Schnitt sonst nie selbst zieht
|
||||
([3.8.1](#381-eine-version-je-sitzung--nicht-je-änderung)). Wer nach acht Minuten Pause
|
||||
weiterarbeitet, landet in derselben Version; wer nach zwölf Minuten eine Kleinigkeit ändert,
|
||||
bekommt eine neue. Beides ist gelegentlich nicht das, was man gemeint hätte. Ein
|
||||
Speichern-Knopf wäre die exaktere Lösung, würde aber die Bedienung des ganzen Werkzeugs
|
||||
umkrempeln.
|
||||
|
||||
**Eine Nebenversion hält den Stand am ENDE der Sitzung fest**, nicht jeden Zwischenschritt
|
||||
darin. Wer innerhalb einer Sitzung etwas ändert und wieder zurücknimmt, findet den
|
||||
Zwischenstand nirgends. Die Historie ist eine Folge von Arbeitsständen, kein Undo.
|
||||
|
||||
**Es gibt keinen Versionsvergleich.** Zwei Stände lassen sich nur nacheinander ansehen, nicht
|
||||
nebeneinander stellen. Der bestehende Diff ([3.2.6](#326-abweichungs-markierung-diff))
|
||||
vergleicht Szenarien gegen ihr Eltern-Szenario, nicht Versionen gegeneinander – technisch
|
||||
wäre beides verwandt, aber es ist bewusst nicht Teil dieser Stufe.
|
||||
|
||||
**Die Historie wird nie beschnitten.** Jede Version hält den vollständigen Zustand als JSON
|
||||
(Grössenordnung 40 KB bei sechs Phasen und zwölf Elementen). Bei intensiver Nutzung wächst
|
||||
das linear; eine Aufräumregel (etwa: Nebenversionen älter als ein Jahr verdichten, Haupt-
|
||||
versionen behalten) gibt es noch nicht. Bei den heutigen Datenmengen ist das unkritisch.
|
||||
|
||||
**«Wer» unterscheidet heute nichts.** Ein Plan gehört genau einem Benutzer, es gibt keine
|
||||
Freigabe und keine Rollen – die Spalte zeigt also immer denselben Namen. Sie ist Vorbereitung
|
||||
auf den Finanzberater, und die Logik trennt Benutzer bereits sauber (Änderungen verschiedener
|
||||
Benutzer werden nie in einer Version zusammengefasst).
|
||||
|
||||
**Der Plan selbst ist nicht versioniert**, nur seine Szenarien. Wird ein Szenario gelöscht,
|
||||
verschwindet seine Historie mit ihm (Cascade). Das ist gewollt: Eine Historie ohne das Objekt,
|
||||
das sie beschreibt, wäre nicht wiederherstellbar.
|
||||
|
||||
---
|
||||
|
||||
# 10. Glossar
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
|
||||
--
|
||||
-- Eine Version haelt den VOLLSTAENDIGEN Zustand des Szenarios als JSON (Form: PlanInput),
|
||||
-- nicht eine Differenz. Grund: Alle Analysewerkzeuge nehmen ohnehin PlanInput entgegen --
|
||||
-- damit lassen sich alte Staende ohne Umbau rechnen, anzeigen und wiederherstellen.
|
||||
|
||||
-- Laufende Hauptversion des Szenarios. Nebenversionen zaehlen in ScenarioVersion.
|
||||
ALTER TABLE "Scenario" ADD COLUMN "currentMajor" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
CREATE TABLE "ScenarioVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scenarioId" TEXT NOT NULL,
|
||||
-- Version A.B: major wird manuell hochgezogen, minor je Bearbeitungssitzung.
|
||||
"major" INTEGER NOT NULL,
|
||||
"minor" INTEGER NOT NULL,
|
||||
-- Pflicht bei Hauptversionen, sonst leer. Wiederherstellungen tragen hier ihre Herkunft.
|
||||
"comment" TEXT,
|
||||
"isMajor" BOOLEAN NOT NULL DEFAULT false,
|
||||
-- Wer die Version erzeugt hat. Heute immer der Eigentuemer; vorbereitet auf spaetere
|
||||
-- Freigabe an einen Finanzberater.
|
||||
"createdById" TEXT NOT NULL,
|
||||
-- Vollstaendiger Zustand (PlanInput als JSON).
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ScenarioVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ScenarioVersion_scenarioId_major_minor_key"
|
||||
ON "ScenarioVersion"("scenarioId", "major", "minor");
|
||||
|
||||
-- Die Historie wird immer neuestzuerst gelesen.
|
||||
CREATE INDEX "ScenarioVersion_scenarioId_createdAt_idx"
|
||||
ON "ScenarioVersion"("scenarioId", "createdAt");
|
||||
|
||||
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_scenarioId_fkey"
|
||||
FOREIGN KEY ("scenarioId") REFERENCES "Scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Beim Loeschen eines Benutzers verschwinden dessen Plaene ohnehin per Cascade; der
|
||||
-- Verweis bleibt bewusst RESTRICT-frei, damit die Historie nie ein Loeschen blockiert.
|
||||
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+33
-1
@@ -32,7 +32,8 @@ model User {
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
plans Plan[]
|
||||
plans Plan[]
|
||||
versions ScenarioVersion[]
|
||||
}
|
||||
|
||||
enum HouseholdType {
|
||||
@@ -114,9 +115,40 @@ model Scenario {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
|
||||
currentMajor Int @default(1)
|
||||
|
||||
persons Person[]
|
||||
phases Phase[]
|
||||
elements FinancialElement[]
|
||||
versions ScenarioVersion[]
|
||||
}
|
||||
|
||||
// Ein festgehaltener Stand eines Szenarios (Version A.B). Haelt den VOLLSTAENDIGEN Zustand
|
||||
// als JSON in der Form PlanInput -- nicht eine Differenz. Dadurch laesst sich jeder alte
|
||||
// Stand ohne Umbau rechnen (computePlan), anzeigen und wiederherstellen.
|
||||
model ScenarioVersion {
|
||||
id String @id @default(cuid())
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
|
||||
major Int
|
||||
minor Int
|
||||
// Pflicht bei Hauptversionen; Wiederherstellungen tragen hier ihre Herkunft.
|
||||
comment String?
|
||||
isMajor Boolean @default(false)
|
||||
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
snapshot Json
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
// Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scenarioId, major, minor])
|
||||
@@index([scenarioId, createdAt])
|
||||
}
|
||||
|
||||
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { phaseDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
@@ -22,6 +23,7 @@ export async function PATCH(
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -37,5 +39,6 @@ export async function DELETE(
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.financialElement.delete({ where: { id: element.id } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { transitionDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { cashTransitionSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
|
||||
@@ -27,5 +28,6 @@ export async function PUT(
|
||||
data: { cashTransition: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
|
||||
const updatePhaseSchema = z.object({
|
||||
@@ -47,6 +48,7 @@ export async function PUT(
|
||||
durationYears: duration ?? undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(existing.scenarioId, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } });
|
||||
}
|
||||
|
||||
@@ -70,5 +72,6 @@ export async function DELETE(
|
||||
}
|
||||
|
||||
await prisma.phase.delete({ where: { id: phaseId } });
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const personSchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
@@ -81,6 +82,8 @@ export async function POST(request: NextRequest) {
|
||||
include: { scenarios: true },
|
||||
});
|
||||
|
||||
// Das frisch angelegte Basisszenario startet mit Version 1.0.
|
||||
await touchScenario(plan.scenarios[0].id, userId);
|
||||
return NextResponse.json(
|
||||
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
|
||||
{ status: 201 }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
@@ -90,5 +91,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return created.id;
|
||||
});
|
||||
|
||||
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
|
||||
await touchScenario(newId, userId);
|
||||
return NextResponse.json({ scenarioId: newId }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -65,5 +66,6 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||
import { num, type PhaseData } from "@/lib/elements";
|
||||
@@ -74,6 +75,7 @@ export async function POST(
|
||||
return created;
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
@@ -77,6 +78,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
},
|
||||
});
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
|
||||
return prisma.scenarioVersion.findFirst({
|
||||
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
|
||||
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const snapshot = version.snapshot as unknown as PlanInput;
|
||||
|
||||
return NextResponse.json({
|
||||
version: {
|
||||
id: version.id,
|
||||
major: version.major,
|
||||
minor: version.minor,
|
||||
comment: version.comment,
|
||||
isMajor: version.isMajor,
|
||||
createdAt: version.createdAt,
|
||||
},
|
||||
plan: snapshot,
|
||||
computed: computePlan(snapshot),
|
||||
impact: await restoreImpact(scenarioId, snapshot),
|
||||
});
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
|
||||
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const ref = await restoreVersion(scenarioId, versionId, userId);
|
||||
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { createMajorVersion } from "@/lib/versioning-db";
|
||||
import { isValidMajorComment } from "@/lib/versioning";
|
||||
|
||||
async function ownedScenario(scenarioId: string, userId: string) {
|
||||
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
}
|
||||
|
||||
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
|
||||
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.scenarioVersion.findMany({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
major: true,
|
||||
minor: true,
|
||||
comment: true,
|
||||
isMajor: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
currentMajor: scenario.currentMajor,
|
||||
versions: rows.map((r) => ({
|
||||
id: r.id,
|
||||
major: r.major,
|
||||
minor: r.minor,
|
||||
comment: r.comment,
|
||||
isMajor: r.isMajor,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
|
||||
// Hauptversion ohne Begründung wäre nur eine Zahl.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = majorSchema.safeParse(await request.json());
|
||||
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
|
||||
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
|
||||
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref }, { status: 201 });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
FolderKanban,
|
||||
GitBranch,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
PiggyBank,
|
||||
@@ -25,6 +26,7 @@ import { Dashboard } from "@/components/Dashboard";
|
||||
import { MonteCarloDialog } from "@/components/MonteCarloDialog";
|
||||
import { SensitivityDialog } from "@/components/SensitivityDialog";
|
||||
import { LiveSimDialog } from "@/components/LiveSimDialog";
|
||||
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
|
||||
import { SpecView } from "@/components/SpecView";
|
||||
import { SystemParametersView } from "@/components/SystemParametersView";
|
||||
import { PlanTraceDialog } from "@/components/DetailView";
|
||||
@@ -87,6 +89,7 @@ function AppShellInner({ username }: { username: string }) {
|
||||
const [showMonteCarlo, setShowMonteCarlo] = useState(false);
|
||||
const [showSensitivity, setShowSensitivity] = useState(false);
|
||||
const [showLiveSim, setShowLiveSim] = useState(false);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [showSystemParams, setShowSystemParams] = useState(false);
|
||||
const [showPlanTraces, setShowPlanTraces] = useState(false);
|
||||
const [showPalette, setShowPalette] = useState(false);
|
||||
@@ -423,6 +426,10 @@ function AppShellInner({ username }: { username: string }) {
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Grafiken
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowHistory(true)}>
|
||||
<History className="h-4 w-4" />
|
||||
Änderungshistorie
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowLiveSim(true)}>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Live-Simulation
|
||||
@@ -540,6 +547,15 @@ function AppShellInner({ username }: { username: string }) {
|
||||
<LiveSimDialog plan={detail.plan} onClose={() => setShowLiveSim(false)} />
|
||||
)}
|
||||
|
||||
{showHistory && detail && (
|
||||
<VersionHistoryDialog
|
||||
scenarioId={detail.meta.id}
|
||||
scenarioName={detail.meta.name}
|
||||
onClose={() => setShowHistory(false)}
|
||||
onRestored={refreshCurrent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPlanTraces && detail && (
|
||||
<PlanTraceDialog
|
||||
computed={computePlan(detail.plan, undefined, { explain: true })}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -16,8 +18,8 @@ interface PlanListItem {
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
plan,
|
||||
computed,
|
||||
plan: currentPlan,
|
||||
computed: currentComputed,
|
||||
siblings,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
@@ -25,6 +27,14 @@ export function Dashboard({
|
||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||
siblings: PlanListItem[];
|
||||
}) {
|
||||
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||
const computed = useMemo(
|
||||
() => (versionId === "current" ? currentComputed : computePlan(plan)),
|
||||
[versionId, currentComputed, plan]
|
||||
);
|
||||
|
||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
|
||||
|
||||
@@ -55,6 +65,13 @@ export function Dashboard({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<VersionBar
|
||||
scenarioId={currentPlan.id}
|
||||
versionId={versionId}
|
||||
onChange={setVersionId}
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||
@@ -78,12 +95,19 @@ export function Dashboard({
|
||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||
Vermögensverlauf nach Alter
|
||||
</h3>
|
||||
{/* Der Export liest immer das Szenario aus der Datenbank -- also den aktuellen
|
||||
Stand, nicht die betrachtete Version. Das wird beschriftet statt verschwiegen. */}
|
||||
<a
|
||||
href={`/api/scenarios/${plan.id}/export`}
|
||||
href={`/api/scenarios/${currentPlan.id}/export`}
|
||||
title={
|
||||
versionId === "current"
|
||||
? undefined
|
||||
: "Der CSV-Export liefert immer den aktuellen Stand, nicht die betrachtete Version."
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
CSV-Export
|
||||
CSV-Export{versionId === "current" ? "" : " (aktueller Stand)"}
|
||||
</a>
|
||||
</div>
|
||||
{otherPlans.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AllocationChart } from "@/components/AllocationChart";
|
||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
import { WealthChart } from "@/components/WealthChart";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
@@ -43,7 +44,9 @@ const CHARTS: { id: ChartId; label: string; hint: string }[] = [
|
||||
const BASE_COLOR = "#9ca3af";
|
||||
const LIVE_COLOR = "#4f46e5";
|
||||
|
||||
export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
||||
export function LiveSimDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
||||
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
const [expandReturns, setExpandReturns] = useState(false);
|
||||
const [values, setValues] = useState<SliderValues>({});
|
||||
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
|
||||
@@ -108,6 +111,16 @@ export function LiveSimDialog({ plan, onClose }: { plan: PlanInput; onClose: ()
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VersionBar
|
||||
scenarioId={currentPlan.id}
|
||||
versionId={versionId}
|
||||
onChange={(id) => {
|
||||
setVersionId(id);
|
||||
setValues({});
|
||||
}}
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
|
||||
gespeichert</strong> – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, Responsive
|
||||
import { Dices, X } from "lucide-react";
|
||||
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
@@ -137,6 +139,10 @@ export function MonteCarloDialog({
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
||||
// Gewaehlter Stand je Szenario (Default: Arbeitsstand) und die dazu geladenen Snapshots.
|
||||
const [versionByScenario, setVersionByScenario] = useState<Record<string, string>>({});
|
||||
const [versionPlans, setVersionPlans] = useState<Record<string, PlanInput>>({});
|
||||
|
||||
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
|
||||
// Fächer/Bänder stammen aus der historischen Welt.
|
||||
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
|
||||
@@ -169,12 +175,40 @@ export function MonteCarloDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scenarioKey, meta.id]);
|
||||
|
||||
async function chooseVersion(scenarioId: string, versionId: string) {
|
||||
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: versionId }));
|
||||
clearResults();
|
||||
if (versionId === CURRENT) return;
|
||||
const key = `${scenarioId}:${versionId}`;
|
||||
if (versionPlans[key]) return;
|
||||
try {
|
||||
const loadedPlan = await loadVersionPlan(scenarioId, versionId);
|
||||
if (loadedPlan) setVersionPlans((prev) => ({ ...prev, [key]: loadedPlan }));
|
||||
} catch {
|
||||
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: CURRENT }));
|
||||
}
|
||||
}
|
||||
|
||||
const allLoaded = useMemo(
|
||||
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
|
||||
[scenarios, loaded]
|
||||
);
|
||||
|
||||
const groups = useMemo(() => buildElementGroups(allLoaded, selectedIds), [allLoaded, selectedIds]);
|
||||
// Ersetzt je Szenario den Arbeitsstand durch den gewaehlten Snapshot. Der Snapshot ist
|
||||
// bereits ein PlanInput, das Gerechnete entsteht lokal.
|
||||
const resolved = useMemo(() => {
|
||||
const out: Record<string, LoadedScenario> = {};
|
||||
for (const sc of allLoaded) {
|
||||
const vid = versionByScenario[sc.id] ?? CURRENT;
|
||||
const snap = vid === CURRENT ? null : versionPlans[`${sc.id}:${vid}`];
|
||||
out[sc.id] = snap ? { ...sc, plan: snap, computed: computePlan(snap) } : sc;
|
||||
}
|
||||
return out;
|
||||
}, [allLoaded, versionByScenario, versionPlans]);
|
||||
|
||||
const resolvedList = useMemo(() => Object.values(resolved), [resolved]);
|
||||
|
||||
const groups = useMemo(() => buildElementGroups(resolvedList, selectedIds), [resolvedList, selectedIds]);
|
||||
|
||||
function draftFor(g: ElementGroup): ElementDraft {
|
||||
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
|
||||
@@ -193,7 +227,7 @@ export function MonteCarloDialog({
|
||||
clearResults();
|
||||
}
|
||||
|
||||
const selected = selectedIds.map((id) => loaded[id]).filter((s): s is LoadedScenario => !!s);
|
||||
const selected = selectedIds.map((id) => resolved[id]).filter((s): s is LoadedScenario => !!s);
|
||||
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
|
||||
|
||||
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
|
||||
@@ -362,6 +396,14 @@ export function MonteCarloDialog({
|
||||
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
|
||||
{!isLoaded && <span className="text-[11px] text-faint">lädt…</span>}
|
||||
</label>
|
||||
{checked && (
|
||||
<VersionSelect
|
||||
compact
|
||||
scenarioId={s.id}
|
||||
value={versionByScenario[s.id] ?? CURRENT}
|
||||
onChange={(vid) => void chooseVersion(s.id, vid)}
|
||||
/>
|
||||
)}
|
||||
{checked && typeof nachlass === "number" && (
|
||||
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
|
||||
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Toolt
|
||||
import { Tornado, X } from "lucide-react";
|
||||
import { RequiredNumberField, SelectField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
computeTornado,
|
||||
@@ -34,7 +35,9 @@ function formatRange(low: number, high: number, unit: DriverDef["unit"]): string
|
||||
return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`;
|
||||
}
|
||||
|
||||
export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
||||
export function SensitivityDialog({ plan: currentPlan, onClose }: { plan: PlanInput; onClose: () => void }) {
|
||||
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
|
||||
|
||||
const [metric, setMetric] = useState<TornadoMetric>("real");
|
||||
@@ -103,6 +106,16 @@ export function SensitivityDialog({ plan, onClose }: { plan: PlanInput; onClose:
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VersionBar
|
||||
scenarioId={currentPlan.id}
|
||||
versionId={versionId}
|
||||
onChange={(id) => {
|
||||
setVersionId(id);
|
||||
setResult(null);
|
||||
}}
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertTriangle, Eye, History, RotateCcw, Tag, X } from "lucide-react";
|
||||
import { VersionMatrix } from "@/components/VersionMatrix";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { RestoreImpact } from "@/lib/versioning";
|
||||
|
||||
export interface VersionRow {
|
||||
id: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
comment: string | null;
|
||||
isMajor: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
interface VersionDetail {
|
||||
version: { id: string; major: number; minor: number; comment: string | null; isMajor: boolean };
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
impact: RestoreImpact;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(iso).toLocaleString("de-CH", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
export function VersionHistoryDialog({
|
||||
scenarioId,
|
||||
scenarioName,
|
||||
onClose,
|
||||
onRestored,
|
||||
}: {
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
onClose: () => void;
|
||||
onRestored: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<VersionRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<VersionDetail | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [majorComment, setMajorComment] = useState("");
|
||||
const [showMajorForm, setShowMajorForm] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Nachladen nach einer Aktion (Wiederherstellen, Hauptversion) -- nicht beim Öffnen.
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
|
||||
setRows(data.versions);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
|
||||
}
|
||||
}, [scenarioId]);
|
||||
|
||||
// Erstes Laden. Das Cancel-Flag verhindert ein setState nach dem Schliessen des Dialogs.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
|
||||
if (!cancelled) setRows(data.versions);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scenarioId]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setDetail(await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${id}`));
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(row: VersionRow) {
|
||||
// Erst die Auswirkung holen: Kind-Szenarien können ihre Diff-Basis verlieren, und das
|
||||
// muss VOR dem Klick auf dem Tisch liegen, nicht danach.
|
||||
setBusy(true);
|
||||
let impact: RestoreImpact;
|
||||
try {
|
||||
const d = await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${row.id}`);
|
||||
impact = d.impact;
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
|
||||
const warn =
|
||||
impact.affectedChildren.length > 0
|
||||
? `\n\nAchtung: ${impact.affectedChildren.length === 1 ? "Das Szenario" : "Die Szenarien"} ` +
|
||||
`«${impact.affectedChildren.join("», «")}» ${impact.affectedChildren.length === 1 ? "hängt" : "hängen"} ` +
|
||||
`an diesem Szenario. Dieser Stand kannte ${impact.lostElementIds.length} Element(e) und ` +
|
||||
`${impact.lostPhaseIds.length} Phase(n) noch nicht, auf die dort verwiesen wird – die Abweichungs-Markierung ` +
|
||||
`zeigt sie danach als «neu» statt als «geändert».`
|
||||
: "";
|
||||
|
||||
const ok = await confirm({
|
||||
title: `Auf Version ${row.major}.${row.minor} zurücksetzen?`,
|
||||
message:
|
||||
`Das Szenario «${scenarioName}» wird vollständig auf diesen Stand zurückgesetzt. ` +
|
||||
`Es geht nichts verloren: Der wiederhergestellte Stand wird als neue Version festgehalten, ` +
|
||||
`die bisherige Historie bleibt vollständig erhalten.${warn}`,
|
||||
confirmLabel: "Wiederherstellen",
|
||||
danger: impact.affectedChildren.length > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.post<{ version: { major: number; minor: number } }>(
|
||||
`/api/scenarios/${scenarioId}/versions/${row.id}`,
|
||||
{}
|
||||
);
|
||||
toast("success", `Wiederhergestellt aus ${row.major}.${row.minor} – neue Version ${res.version.major}.${res.version.minor}.`);
|
||||
await load();
|
||||
onRestored();
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Wiederherstellen fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMajor() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.post<{ version: { major: number; minor: number } }>(
|
||||
`/api/scenarios/${scenarioId}/versions`,
|
||||
{ comment: majorComment }
|
||||
);
|
||||
toast("success", `Hauptversion ${res.version.major}.${res.version.minor} festgelegt.`);
|
||||
setMajorComment("");
|
||||
setShowMajorForm(false);
|
||||
await load();
|
||||
onRestored();
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Hauptversion konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detailansicht (nur lesen) ---
|
||||
if (detail) {
|
||||
const last = detail.computed.phases[detail.computed.phases.length - 1];
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={() => setDetail(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-5xl flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Eye className="h-5 w-5 text-accent" />
|
||||
Version {detail.version.major}.{detail.version.minor}
|
||||
{detail.version.isMajor && (
|
||||
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[10px] font-semibold text-accent-soft-fg">
|
||||
Hauptversion
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDetail(null)} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{last && (
|
||||
<div className="flex flex-wrap gap-4 rounded-xl border border-border bg-surface-2 p-3 text-xs">
|
||||
<span className="text-muted">
|
||||
Endvermögen nominal: <strong className="text-fg">{formatChf(last.endWealthNominal)}</strong>
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
real: <strong className="text-fg">{formatChf(last.endWealthReal)}</strong>
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
Kapital reicht:{" "}
|
||||
<strong className={detail.computed.ruinAge === null ? "text-success" : "text-danger"}>
|
||||
{detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VersionMatrix computed={detail.computed} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Liste ---
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<History className="h-5 w-5 text-accent" /> Änderungshistorie
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je
|
||||
einzelner Änderung – sonst wäre die Liste ein Tastenprotokoll.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hauptversion festlegen */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
{showMajorForm ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-fg">
|
||||
Wofür steht diese Hauptversion? <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={majorComment}
|
||||
onChange={(e) => setMajorComment(e.target.value)}
|
||||
placeholder="z. B. «Stand nach Beratungsgespräch, vor dem Hauskauf»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" disabled={majorComment.trim().length < 3 || busy} onClick={createMajor}>
|
||||
<Tag className="h-3.5 w-3.5" /> Als Hauptversion festlegen
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted">
|
||||
Einen bewusst gesetzten Meilenstein festhalten – mit Begründung.
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(true)}>
|
||||
<Tag className="h-3.5 w-3.5" /> Aktuellen Stand als Hauptversion festlegen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!rows && !error && <p className="text-xs text-muted">Historie wird geladen…</p>}
|
||||
|
||||
{rows && rows.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der
|
||||
nächsten Änderung.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
|
||||
r.isMajor ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<span className="flex w-16 shrink-0 items-center gap-1.5 font-semibold text-fg">
|
||||
{r.isMajor && <Tag className="h-3.5 w-3.5 text-accent" />}
|
||||
{r.major}.{r.minor}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted">
|
||||
{r.author} · {dt(r.updatedAt)}
|
||||
{i === 0 && (
|
||||
<span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-faint">
|
||||
aktueller Stand
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.comment && <div className="mt-0.5 truncate text-xs text-fg">«{r.comment}»</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => openDetail(r.id)}>
|
||||
<Eye className="h-3.5 w-3.5" /> Anzeigen
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={busy || i === 0} onClick={() => restore(r)}>
|
||||
<RotateCcw className="h-3.5 w-3.5" /> Wiederherstellen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="flex items-start gap-1.5 text-[11px] text-faint">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version
|
||||
festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
|
||||
// Nur-Lese-Matrix eines festgehaltenen Standes. Bewusst NICHT die Bearbeitungs-Matrix aus
|
||||
// PlanView: Die trägt Inspector-Anbindung, Übergangs-Ampeln, Diff-Markierung und Dialoge --
|
||||
// alles ohne Bedeutung für einen alten Stand, den man nur ansehen kann. Diese Ansicht zeigt
|
||||
// stattdessen genau das, was eine Version ausmacht: Phasen, Elemente, Werte.
|
||||
export function VersionMatrix({ computed }: { computed: PlanComputed }) {
|
||||
const phases = computed.phases;
|
||||
if (phases.length === 0) {
|
||||
return <p className="text-sm text-muted">Dieser Stand enthielt noch keine Lebensphasen.</p>;
|
||||
}
|
||||
|
||||
// Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt.
|
||||
const elements = (() => {
|
||||
const seen = new Map<string, { name: string; category: ElementCategory; ownerRole: string | null }>();
|
||||
for (const p of phases) {
|
||||
for (const e of p.elements) {
|
||||
if (!seen.has(e.elementId)) {
|
||||
seen.set(e.elementId, { name: e.name, category: e.category, ownerRole: e.ownerRole });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen.entries()]
|
||||
.map(([id, v]) => ({ id, ...v }))
|
||||
.sort((a, b) => {
|
||||
const ca = CATEGORY_ORDER.indexOf(a.category);
|
||||
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
|
||||
});
|
||||
})();
|
||||
|
||||
// Kategoriewechsel als Zwischenüberschrift -- dieselbe Gliederung wie in der Planansicht.
|
||||
// Vorab bestimmt statt während des Renderns mitgezählt: Eine Variable, die der Render-Lauf
|
||||
// fortschreibt, verhält sich bei erneutem Rendern nicht mehr gleich.
|
||||
const headerAt = new Map<string, ElementCategory>();
|
||||
elements.forEach((el, i) => {
|
||||
if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="sticky left-0 z-20 bg-surface-2 px-3 py-2 text-left font-semibold">Element</th>
|
||||
{phases.map((p) => (
|
||||
<th key={p.id} className="whitespace-nowrap px-3 py-2 text-right font-semibold">
|
||||
<div className="text-fg">{p.name}</div>
|
||||
<div className="font-normal text-faint">{p.durationYears} J.</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{elements.map((el) => {
|
||||
const header = headerAt.get(el.id) ?? null;
|
||||
return (
|
||||
<Fragment key={el.id}>
|
||||
{header && (
|
||||
<tr className="border-t border-border bg-surface-2/60">
|
||||
<td
|
||||
colSpan={phases.length + 1}
|
||||
className="sticky left-0 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint"
|
||||
>
|
||||
{CATEGORY_LABELS[header]}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-border">
|
||||
<td className="sticky left-0 z-10 bg-surface px-3 py-2">
|
||||
<span className="font-medium text-fg">{el.name}</span>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<span className="ml-1.5 text-[10px] text-faint">
|
||||
{el.ownerRole === "PERSON_A" ? "A" : "B"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{phases.map((p) => {
|
||||
const c = p.elements.find((x) => x.elementId === el.id);
|
||||
if (!c) return <td key={p.id} className="px-3 py-2 text-right text-faint">–</td>;
|
||||
return (
|
||||
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right">
|
||||
<div className={c.status === "ACTIVE" ? "text-fg" : "text-faint line-through"}>
|
||||
{formatChf(c.startValue)}
|
||||
</div>
|
||||
{c.endValue !== c.startValue && (
|
||||
<div className="text-[11px] text-muted">→ {formatChf(c.endValue)}</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */}
|
||||
<tr className="border-t-2 border-border bg-surface-2">
|
||||
<td className="sticky left-0 z-10 bg-surface-2 px-3 py-2 text-xs font-semibold text-fg">
|
||||
Vermögen am Phasenende
|
||||
</td>
|
||||
{phases.map((p) => (
|
||||
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right text-xs font-semibold text-fg">
|
||||
{formatChf(p.endWealthNominal)}
|
||||
<div className="font-normal text-faint">({formatChf(p.endWealthReal)} real)</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { History } from "lucide-react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Kennzeichnet den aktuellen (ungespeicherten) Arbeitsstand -- im Gegensatz zu einer
|
||||
// festgehaltenen Version.
|
||||
export const CURRENT = "current";
|
||||
|
||||
export interface VersionOption {
|
||||
id: string;
|
||||
label: string; // "1.4" bzw. "1.4 (Hauptversion)"
|
||||
isMajor: boolean;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
// Lädt die Versionsliste eines Szenarios. Bewusst ohne Snapshots -- die kommen erst beim
|
||||
// tatsächlichen Auswählen dazu.
|
||||
export function useVersionOptions(scenarioId: string | null): VersionOption[] {
|
||||
const [options, setOptions] = useState<VersionOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scenarioId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<{
|
||||
versions: { id: string; major: number; minor: number; isMajor: boolean; comment: string | null }[];
|
||||
}>(`/api/scenarios/${scenarioId}/versions`);
|
||||
if (cancelled) return;
|
||||
setOptions(
|
||||
data.versions.map((v) => ({
|
||||
id: v.id,
|
||||
label: `${v.major}.${v.minor}${v.isMajor ? " (Hauptversion)" : ""}`,
|
||||
isMajor: v.isMajor,
|
||||
comment: v.comment,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setOptions([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scenarioId]);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Holt den Plan-Stand einer Version. `CURRENT` liefert null -- dann gilt der Arbeitsstand.
|
||||
export async function loadVersionPlan(scenarioId: string, versionId: string): Promise<PlanInput | null> {
|
||||
if (versionId === CURRENT) return null;
|
||||
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${scenarioId}/versions/${versionId}`);
|
||||
return data.plan;
|
||||
}
|
||||
|
||||
// Ein kompakter Wähler je Szenario. Wird in allen vier Analysewerkzeugen verwendet, damit
|
||||
// die Bedienung überall dieselbe ist.
|
||||
export function VersionSelect({
|
||||
scenarioId,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
compact,
|
||||
}: {
|
||||
scenarioId: string;
|
||||
value: string;
|
||||
onChange: (versionId: string) => void;
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const options = useVersionOptions(scenarioId);
|
||||
|
||||
return (
|
||||
<label className={`flex items-center gap-1.5 ${compact ? "text-[11px]" : "text-xs"} text-muted`}>
|
||||
{label && (
|
||||
<span className="flex items-center font-medium">
|
||||
<History className="mr-1 h-3.5 w-3.5 text-faint" />
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`rounded-lg border border-border bg-surface px-2 py-1 text-fg ${compact ? "text-[11px]" : "text-xs"}`}
|
||||
>
|
||||
<option value={CURRENT}>Aktueller Stand</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
{o.comment ? ` – ${o.comment.slice(0, 40)}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Hält die Auswahl "welcher Stand" für ein einzelnes Szenario. Solange `CURRENT` gewählt
|
||||
// ist, wird der übergebene Arbeitsstand durchgereicht -- ohne Netzwerkzugriff.
|
||||
//
|
||||
// Der Snapshot ist bereits ein PlanInput, deshalb rechnen die Werkzeuge damit unverändert
|
||||
// weiter; das Gerechnete entsteht bei ihnen lokal (computePlan ist rein und kostet ~0.2 ms).
|
||||
export function useVersionedPlan(scenarioId: string, currentPlan: PlanInput) {
|
||||
const [versionId, setVersionId] = useState<string>(CURRENT);
|
||||
// Nur die geladenen Snapshots liegen im Zustand. Der Arbeitsstand ist bereits da und wird
|
||||
// abgeleitet -- ihn in den Zustand zu spiegeln, hiesse ihn doppelt zu führen.
|
||||
const [snapshots, setSnapshots] = useState<Record<string, PlanInput>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionId === CURRENT || snapshots[versionId]) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await loadVersionPlan(scenarioId, versionId);
|
||||
if (cancelled) return;
|
||||
if (loaded) setSnapshots((prev) => ({ ...prev, [versionId]: loaded }));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scenarioId, versionId, snapshots]);
|
||||
|
||||
const plan = versionId === CURRENT ? currentPlan : (snapshots[versionId] ?? currentPlan);
|
||||
// Solange der Snapshot fehlt und kein Fehler vorliegt, laeuft der Abruf noch. Abgeleitet
|
||||
// statt als eigener Zustand -- ein Ladeflag, das nur den Abruf spiegelt, ist redundant.
|
||||
const loading = versionId !== CURRENT && !snapshots[versionId] && !error;
|
||||
|
||||
return { versionId, setVersionId, plan, loading, error };
|
||||
}
|
||||
|
||||
// Kopfzeile für die Analyse-Dialoge mit einem einzelnen Szenario.
|
||||
export function VersionBar({
|
||||
scenarioId,
|
||||
versionId,
|
||||
onChange,
|
||||
loading,
|
||||
}: {
|
||||
scenarioId: string;
|
||||
versionId: string;
|
||||
onChange: (id: string) => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<VersionSelect scenarioId={scenarioId} value={versionId} onChange={onChange} label="Berechnungsgrundlage" />
|
||||
<VersionHint />
|
||||
{loading && <span className="text-[11px] text-muted">wird geladen…</span>}
|
||||
{versionId !== CURRENT && !loading && (
|
||||
<span className="rounded bg-attention-soft px-1.5 py-0.5 text-[10px] font-semibold text-attention-soft-fg">
|
||||
Nicht der aktuelle Stand
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hinweiszeile für die Analyse-Dialoge.
|
||||
export function VersionHint() {
|
||||
return (
|
||||
<InfoBubble text="Standardmässig rechnen die Werkzeuge mit dem aktuellen Arbeitsstand. Du kannst stattdessen jede festgehaltene Version wählen – dann wird genau der damalige Stand gerechnet." />
|
||||
);
|
||||
}
|
||||
@@ -59,5 +59,32 @@ describe("Datenbank-Migrationen", () => {
|
||||
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
|
||||
}
|
||||
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
|
||||
|
||||
// --- Versionierung ---
|
||||
expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion");
|
||||
expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor");
|
||||
|
||||
const verCols = await cols("ScenarioVersion");
|
||||
for (const c of ["scenarioId", "major", "minor", "comment", "isMajor", "createdById", "snapshot"]) {
|
||||
expect(verCols, `ScenarioVersion.${c} fehlt`).toContain(c);
|
||||
}
|
||||
|
||||
// Bestehende Szenarien müssen nach der Migration eine gültige Hauptversion tragen --
|
||||
// sonst stünde ein vor der Migration angelegter Plan ohne Version da.
|
||||
const major = await db.query<{ column_default: string | null; is_nullable: string }>(
|
||||
`SELECT column_default, is_nullable FROM information_schema.columns
|
||||
WHERE table_name='Scenario' AND column_name='currentMajor'`
|
||||
);
|
||||
expect(major.rows[0].is_nullable).toBe("NO");
|
||||
expect(major.rows[0].column_default).toContain("1");
|
||||
|
||||
// A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben
|
||||
// Nummer und die Historie wird mehrdeutig.
|
||||
const idx = (
|
||||
await db.query<{ indexname: string }>(
|
||||
`SELECT indexname FROM pg_indexes WHERE tablename='ScenarioVersion'`
|
||||
)
|
||||
).rows.map((r) => r.indexname);
|
||||
expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Wächter über die Vollständigkeit der Historie.
|
||||
//
|
||||
// Die Versionierung hängt daran, dass JEDER inhaltsverändernde Endpunkt `touchScenario`
|
||||
// aufruft. Ein vergessener Pfad fällt nicht auf -- er erzeugt einfach still keine Version,
|
||||
// und die Historie hat eine Lücke, die man erst Wochen später bemerkt. Dieser Test liest die
|
||||
// Route-Dateien und prüft das statisch.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const API = path.join(process.cwd(), "src", "app", "api");
|
||||
|
||||
function routeFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...routeFiles(full));
|
||||
else if (entry.name === "route.ts") out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Endpunkte, die schreiben, aber bewusst KEINE Version erzeugen -- jeweils mit Begründung.
|
||||
const EXEMPT: Record<string, string> = {
|
||||
"auth/login": "kein Szenario betroffen",
|
||||
"auth/logout": "kein Szenario betroffen",
|
||||
"auth/register": "kein Szenario betroffen",
|
||||
"auth/change-password": "kein Szenario betroffen",
|
||||
"plans/[planId]": "ändert nur den Plan-Namen bzw. löscht den ganzen Plan – kein Szenario-Inhalt",
|
||||
"scenarios/[scenarioId]/versions": "erzeugt Versionen selbst (Hauptversion / Wiederherstellen)",
|
||||
"scenarios/[scenarioId]/versions/[versionId]": "erzeugt Versionen selbst",
|
||||
};
|
||||
|
||||
describe("Versionierung: Abdeckung der Schreibpfade", () => {
|
||||
const files = routeFiles(API);
|
||||
|
||||
it("findet überhaupt Endpunkte", () => {
|
||||
expect(files.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("ruft in jedem inhaltsverändernden Endpunkt touchScenario auf", () => {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
const rel = path
|
||||
.relative(API, file)
|
||||
.replace(/\\/g, "/")
|
||||
.replace(/\/route\.ts$/, "");
|
||||
|
||||
// Schreibt dieser Endpunkt überhaupt?
|
||||
const mutates = /export async function (POST|PUT|PATCH|DELETE)/.test(src);
|
||||
if (!mutates) continue;
|
||||
if (rel in EXEMPT) continue;
|
||||
|
||||
// Ein DELETE, das das Szenario selbst entfernt, braucht keine Version mehr.
|
||||
const onlyDeletesScenario =
|
||||
rel === "scenarios/[scenarioId]" && !/export async function (POST|PUT|PATCH)/.test(src);
|
||||
if (onlyDeletesScenario) continue;
|
||||
|
||||
if (!src.includes("touchScenario(")) missing.push(rel);
|
||||
}
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`Diese Endpunkte verändern Inhalte, erzeugen aber keine Version:\n ${missing.join(
|
||||
"\n "
|
||||
)}\nEntweder touchScenario ergänzen oder in EXEMPT mit Begründung eintragen.`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("hält die Ausnahmeliste frei von Karteileichen", () => {
|
||||
// Eine Ausnahme für einen Endpunkt, den es nicht mehr gibt, verschleiert später eine
|
||||
// echte Lücke.
|
||||
const known = new Set(
|
||||
files.map((f) => path.relative(API, f).replace(/\\/g, "/").replace(/\/route\.ts$/, ""))
|
||||
);
|
||||
const stale = Object.keys(EXEMPT).filter((k) => !known.has(k));
|
||||
expect(stale, `Ausnahmen ohne zugehörigen Endpunkt: ${stale.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht,
|
||||
// wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet.
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import {
|
||||
assessRestore,
|
||||
decideVersion,
|
||||
nextMajor,
|
||||
planRestore,
|
||||
restoreComment,
|
||||
type LatestVersion,
|
||||
type RestoreImpact,
|
||||
type VersionRef,
|
||||
} from "@/lib/versioning";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue;
|
||||
|
||||
async function loadPlanInput(scenarioId: string): Promise<PlanInput | null> {
|
||||
const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude });
|
||||
return scenario ? toPlanInput(scenario) : null;
|
||||
}
|
||||
|
||||
async function loadLatest(scenarioId: string): Promise<LatestVersion | null> {
|
||||
const v = await prisma.scenarioVersion.findFirst({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
});
|
||||
if (!v) return null;
|
||||
return {
|
||||
id: v.id,
|
||||
major: v.major,
|
||||
minor: v.minor,
|
||||
isMajor: v.isMajor,
|
||||
createdById: v.createdById,
|
||||
updatedAt: v.updatedAt,
|
||||
snapshot: v.snapshot as unknown as PlanInput,
|
||||
};
|
||||
}
|
||||
|
||||
// Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung
|
||||
// eine neue Nebenversion an, aktualisiert die laufende oder tut nichts.
|
||||
//
|
||||
// Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche
|
||||
// Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht
|
||||
// der Zweck der Anfrage.
|
||||
export async function touchScenario(scenarioId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
const next = await loadPlanInput(scenarioId);
|
||||
if (!next) return;
|
||||
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const decision = decideVersion(latest, next, userId, new Date());
|
||||
|
||||
if (decision.action === "skip") return;
|
||||
|
||||
if (decision.action === "update") {
|
||||
await prisma.scenarioVersion.update({
|
||||
where: { id: decision.versionId },
|
||||
data: { snapshot: asJson(next) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: decision.major,
|
||||
minor: decision.minor,
|
||||
createdById: userId,
|
||||
snapshot: asJson(next),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Versionierung fehlgeschlagen", { scenarioId, err });
|
||||
}
|
||||
}
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie
|
||||
// zusammengefasst und nie nachtraeglich veraendert.
|
||||
export async function createMajorVersion(
|
||||
scenarioId: string,
|
||||
userId: string,
|
||||
comment: string
|
||||
): Promise<VersionRef | null> {
|
||||
const snapshot = await loadPlanInput(scenarioId);
|
||||
if (!snapshot) return null;
|
||||
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const ref = nextMajor(latest);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: ref.major,
|
||||
minor: ref.minor,
|
||||
comment: comment.trim(),
|
||||
isMajor: true,
|
||||
createdById: userId,
|
||||
snapshot: asJson(snapshot),
|
||||
},
|
||||
}),
|
||||
prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }),
|
||||
]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
// Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem
|
||||
// eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann.
|
||||
export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise<RestoreImpact> {
|
||||
const children = await prisma.scenario.findMany({
|
||||
where: { parentScenarioId: scenarioId },
|
||||
select: {
|
||||
name: true,
|
||||
elements: { select: { sourceElementId: true } },
|
||||
phases: { select: { sourcePhaseId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return assessRestore(
|
||||
snapshot,
|
||||
children.map((c) => ({
|
||||
name: c.name,
|
||||
elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x),
|
||||
phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf den Stand einer Version zurueck.
|
||||
//
|
||||
// IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier
|
||||
// neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt
|
||||
// auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als
|
||||
// "geaendert" gelten.
|
||||
//
|
||||
// Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an.
|
||||
export async function restoreVersion(
|
||||
scenarioId: string,
|
||||
versionId: string,
|
||||
userId: string
|
||||
): Promise<VersionRef | null> {
|
||||
const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } });
|
||||
if (!version) return null;
|
||||
const snap = version.snapshot as unknown as PlanInput;
|
||||
|
||||
// Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird
|
||||
// der Plan nur noch ausgefuehrt.
|
||||
const [curPhases, curElements, curPersons] = await Promise.all([
|
||||
prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }),
|
||||
prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }),
|
||||
prisma.person.findMany({ where: { scenarioId }, select: { role: true } }),
|
||||
]);
|
||||
const plan = planRestore(snap, {
|
||||
phaseIds: curPhases.map((p) => p.id),
|
||||
elementIds: curElements.map((e) => e.id),
|
||||
personRoles: curPersons.map((p) => p.role),
|
||||
});
|
||||
|
||||
const phaseById = new Map(snap.phases.map((p) => [p.id, p]));
|
||||
const elementById = new Map(snap.elements.map((e) => [e.id, e]));
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Profil.
|
||||
await tx.scenario.update({
|
||||
where: { id: scenarioId },
|
||||
data: {
|
||||
householdType: snap.householdType,
|
||||
inflationRateDefault: snap.inflationRateDefault,
|
||||
initialCash: snap.initialCash,
|
||||
startYear: snap.startYear ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Personen: an der Rolle festgemacht (je Szenario eindeutig).
|
||||
for (const p of snap.persons) {
|
||||
await tx.person.upsert({
|
||||
where: { scenarioId_role: { scenarioId, role: p.role } },
|
||||
create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge },
|
||||
update: { name: p.name, age: p.age, retirementAge: p.retirementAge },
|
||||
});
|
||||
}
|
||||
if (plan.deletePersonRoles.length > 0) {
|
||||
await tx.person.deleteMany({
|
||||
where: { scenarioId, role: { in: plan.deletePersonRoles as never } },
|
||||
});
|
||||
}
|
||||
|
||||
// Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade).
|
||||
if (plan.deletePhaseIds.length > 0) {
|
||||
await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } });
|
||||
}
|
||||
if (plan.deleteElementIds.length > 0) {
|
||||
await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } });
|
||||
}
|
||||
|
||||
// Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und
|
||||
// beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden.
|
||||
for (const [i, id] of plan.parkPhaseIds.entries()) {
|
||||
await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } });
|
||||
}
|
||||
|
||||
const phaseData = (id: string) => {
|
||||
const ph = phaseById.get(id)!;
|
||||
return {
|
||||
sequenceNumber: ph.sequenceNumber,
|
||||
name: ph.name,
|
||||
durationYears: ph.durationYears,
|
||||
cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue,
|
||||
sourcePhaseId: ph.sourcePhaseId ?? null,
|
||||
};
|
||||
};
|
||||
for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) });
|
||||
for (const id of plan.createPhaseIds) {
|
||||
await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } });
|
||||
}
|
||||
|
||||
const elementData = (id: string) => {
|
||||
const el = elementById.get(id)!;
|
||||
return {
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole ?? null,
|
||||
orderIndex: el.orderIndex,
|
||||
sourceElementId: el.sourceElementId ?? null,
|
||||
};
|
||||
};
|
||||
for (const id of plan.updateElementIds) {
|
||||
await tx.financialElement.update({ where: { id }, data: elementData(id) });
|
||||
}
|
||||
for (const id of plan.createElementIds) {
|
||||
await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } });
|
||||
}
|
||||
|
||||
// Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit.
|
||||
const allElementIds = [...plan.updateElementIds, ...plan.createElementIds];
|
||||
if (allElementIds.length > 0) {
|
||||
await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } });
|
||||
await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } });
|
||||
}
|
||||
for (const { elementId, phaseId } of plan.phaseValueKeys) {
|
||||
await tx.elementPhaseValue.create({
|
||||
data: {
|
||||
elementId,
|
||||
phaseId,
|
||||
data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const { elementId, fromPhaseId } of plan.transitionValueKeys) {
|
||||
await tx.elementTransitionValue.create({
|
||||
data: {
|
||||
elementId,
|
||||
fromPhaseId,
|
||||
data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und
|
||||
// ohne dass irgendetwas aus der Historie verloren geht.
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const current = await loadPlanInput(scenarioId);
|
||||
if (!current) return null;
|
||||
|
||||
const created = await prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: latest?.major ?? 1,
|
||||
minor: (latest?.minor ?? 0) + 1,
|
||||
comment: restoreComment({ major: version.major, minor: version.minor }),
|
||||
createdById: userId,
|
||||
snapshot: asJson(current),
|
||||
},
|
||||
});
|
||||
|
||||
return { major: created.major, minor: created.minor };
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
assessRestore,
|
||||
canonicalJson,
|
||||
COALESCE_WINDOW_MS,
|
||||
decideVersion,
|
||||
formatVersion,
|
||||
isValidMajorComment,
|
||||
nextMajor,
|
||||
planRestore,
|
||||
restoreComment,
|
||||
sameSnapshot,
|
||||
type LatestVersion,
|
||||
} from "@/lib/versioning";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
function snap(overrides: Partial<PlanInput> = {}): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "Szenario",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 1.5,
|
||||
initialCash: 0,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "e1",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000 } },
|
||||
transitionValues: {},
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
function latest(over: Partial<LatestVersion> = {}): LatestVersion {
|
||||
return {
|
||||
id: "v1",
|
||||
major: 1,
|
||||
minor: 3,
|
||||
isMajor: false,
|
||||
createdById: "u1",
|
||||
updatedAt: new Date("2026-07-19T10:00:00Z"),
|
||||
snapshot: snap(),
|
||||
...over,
|
||||
} as LatestVersion;
|
||||
}
|
||||
|
||||
const T0 = new Date("2026-07-19T10:00:00Z");
|
||||
const after = (ms: number) => new Date(T0.getTime() + ms);
|
||||
|
||||
describe("canonicalJson", () => {
|
||||
it("ist unabhängig von der Schlüsselreihenfolge", () => {
|
||||
// Genau der Fall aus der Praxis: `toPlanInput` baut phaseValues aus Datenbankzeilen auf,
|
||||
// deren Reihenfolge nicht garantiert ist.
|
||||
const a = { phaseValues: { p1: { amount: 1 }, p2: { amount: 2 } } };
|
||||
const b = { phaseValues: { p2: { amount: 2 }, p1: { amount: 1 } } };
|
||||
expect(canonicalJson(a)).toBe(canonicalJson(b));
|
||||
// Ein echter Unterschied bleibt aber einer.
|
||||
expect(canonicalJson(a)).not.toBe(canonicalJson({ phaseValues: { p1: { amount: 9 } } }));
|
||||
});
|
||||
|
||||
it("lässt die Reihenfolge von Listen in Ruhe", () => {
|
||||
// Bei Phasen ist die Reihenfolge inhaltlich bedeutsam.
|
||||
expect(canonicalJson([1, 2])).not.toBe(canonicalJson([2, 1]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("decideVersion", () => {
|
||||
it("legt für den allerersten Stand 1.0 an", () => {
|
||||
expect(decideVersion(null, snap(), "u1", T0)).toEqual({ action: "create", major: 1, minor: 0 });
|
||||
});
|
||||
|
||||
it("tut nichts, wenn sich inhaltlich nichts geändert hat", () => {
|
||||
// Dialog geöffnet, unverändert gespeichert -- darf keine Version erzeugen.
|
||||
const d = decideVersion(latest(), snap(), "u1", after(1000));
|
||||
expect(d.action).toBe("skip");
|
||||
});
|
||||
|
||||
it("fasst Schreibvorgänge derselben Sitzung zu EINER Nebenversion zusammen", () => {
|
||||
// Der Kern der Entscheidung: Der Verteil-Dialog schreibt einmal je Zielelement, der
|
||||
// Assistent ~14 Mal. Ohne Zusammenfassung wäre die Historie ein Tastenprotokoll.
|
||||
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(60_000));
|
||||
expect(d).toEqual({ action: "update", versionId: "v1" });
|
||||
});
|
||||
|
||||
it("beginnt nach Ablauf des Fensters eine neue Nebenversion", () => {
|
||||
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u1", after(COALESCE_WINDOW_MS + 1));
|
||||
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
|
||||
});
|
||||
|
||||
it("fasst Änderungen verschiedener Benutzer nie zusammen", () => {
|
||||
// Vorbereitung auf den Finanzberater: fremde Änderungen dürfen nicht unter einem Namen
|
||||
// zusammenlaufen, auch nicht innerhalb des Zeitfensters.
|
||||
const d = decideVersion(latest(), snap({ initialCash: 5000 }), "u2", after(1000));
|
||||
expect(d).toEqual({ action: "create", major: 1, minor: 4 });
|
||||
});
|
||||
|
||||
it("verändert eine Hauptversion nie nachträglich", () => {
|
||||
// Eine Hauptversion ist ein bewusst gesetzter Schnitt. Die nächste Änderung beginnt A.1,
|
||||
// auch wenn sie eine Sekunde später kommt.
|
||||
const d = decideVersion(
|
||||
latest({ isMajor: true, major: 2, minor: 0 }),
|
||||
snap({ initialCash: 5000 }),
|
||||
"u1",
|
||||
after(1000)
|
||||
);
|
||||
expect(d).toEqual({ action: "create", major: 2, minor: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hauptversionen", () => {
|
||||
it("zählt die Hauptversion hoch und setzt die Nebenversion zurück", () => {
|
||||
expect(nextMajor({ major: 1, minor: 7 })).toEqual({ major: 2, minor: 0 });
|
||||
expect(nextMajor(null)).toEqual({ major: 1, minor: 0 });
|
||||
});
|
||||
|
||||
it("verlangt einen echten Kommentar", () => {
|
||||
expect(isValidMajorComment("Vor dem Hauskauf")).toBe(true);
|
||||
expect(isValidMajorComment(" ")).toBe(false);
|
||||
expect(isValidMajorComment("ok")).toBe(false);
|
||||
});
|
||||
|
||||
it("formatiert A.B", () => {
|
||||
expect(formatVersion({ major: 2, minor: 0 })).toBe("2.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assessRestore", () => {
|
||||
const s = snap(); // kennt Phase p1 und Element e1
|
||||
|
||||
it("meldet nichts, wenn alle Bezüge der Kinder im alten Stand existieren", () => {
|
||||
const impact = assessRestore(s, [
|
||||
{ name: "Frühpension", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
|
||||
]);
|
||||
expect(impact.affectedChildren).toEqual([]);
|
||||
expect(impact.lostElementIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet Kind-Szenarien, deren Diff-Basis wegfällt", () => {
|
||||
// Das Kind zeigt auf ein Element, das es im wiederhergestellten Stand noch nicht gab.
|
||||
// Dann erschiene es dort schlagartig als "neu" statt als "geändert".
|
||||
const impact = assessRestore(s, [
|
||||
{ name: "Frühpension", elementSourceIds: ["e1", "e99"], phaseSourceIds: ["p1"] },
|
||||
{ name: "Umzug", elementSourceIds: ["e1"], phaseSourceIds: ["p1"] },
|
||||
]);
|
||||
expect(impact.affectedChildren).toEqual(["Frühpension"]);
|
||||
expect(impact.lostElementIds).toEqual(["e99"]);
|
||||
expect(impact.lostPhaseIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("erkennt auch verlorene Phasenbezüge und zählt jede ID nur einmal", () => {
|
||||
const impact = assessRestore(s, [
|
||||
{ name: "A", elementSourceIds: [], phaseSourceIds: ["p9"] },
|
||||
{ name: "B", elementSourceIds: [], phaseSourceIds: ["p9"] },
|
||||
]);
|
||||
expect(impact.lostPhaseIds).toEqual(["p9"]);
|
||||
expect(impact.affectedChildren).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRestore", () => {
|
||||
// Alter Stand: zwei Phasen, ein Element mit Werten in beiden.
|
||||
const old = snap({
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "e1",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000 }, p2: { amount: 0 } },
|
||||
transitionValues: { p1: {} },
|
||||
},
|
||||
],
|
||||
} as unknown as Partial<PlanInput>);
|
||||
|
||||
it("erhält bestehende IDs, statt neu anzulegen", () => {
|
||||
// Der Kern: Kind-Szenarien zeigen über sourceElementId auf genau diese IDs.
|
||||
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: ["PERSON_A"] });
|
||||
expect(plan.updatePhaseIds).toEqual(["p1", "p2"]);
|
||||
expect(plan.updateElementIds).toEqual(["e1"]);
|
||||
expect(plan.createPhaseIds).toEqual([]);
|
||||
expect(plan.createElementIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("entfernt, was der alte Stand nicht kannte", () => {
|
||||
const plan = planRestore(old, {
|
||||
phaseIds: ["p1", "p2", "p3"],
|
||||
elementIds: ["e1", "e2"],
|
||||
personRoles: ["PERSON_A", "PERSON_B"],
|
||||
});
|
||||
expect(plan.deletePhaseIds).toEqual(["p3"]);
|
||||
expect(plan.deleteElementIds).toEqual(["e2"]);
|
||||
// Der Haushalt war damals eine Einzelperson -- Person B muss weg.
|
||||
expect(plan.deletePersonRoles).toEqual(["PERSON_B"]);
|
||||
});
|
||||
|
||||
it("legt neu an, was seither gelöscht wurde", () => {
|
||||
const plan = planRestore(old, { phaseIds: ["p1"], elementIds: [], personRoles: ["PERSON_A"] });
|
||||
expect(plan.createPhaseIds).toEqual(["p2"]);
|
||||
expect(plan.createElementIds).toEqual(["e1"]);
|
||||
expect(plan.updatePhaseIds).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("parkt nur die überlebenden Phasen zum Umnummerieren", () => {
|
||||
// Die zu löschenden sind vorher schon weg -- sie zu parken wäre ein Fehler.
|
||||
const plan = planRestore(old, { phaseIds: ["p2", "p3"], elementIds: [], personRoles: [] });
|
||||
expect(plan.parkPhaseIds).toEqual(["p2"]);
|
||||
expect(plan.deletePhaseIds).toEqual(["p3"]);
|
||||
});
|
||||
|
||||
it("verwirft Werte, die auf inzwischen entfernte Phasen zeigen", () => {
|
||||
// Ein Element trägt einen Wert für eine Phase, die dieser Stand gar nicht kannte.
|
||||
const withOrphan = snap({
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "e1",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 1 }, pX: { amount: 2 } },
|
||||
transitionValues: { pX: {} },
|
||||
},
|
||||
],
|
||||
} as unknown as Partial<PlanInput>);
|
||||
|
||||
const plan = planRestore(withOrphan, { phaseIds: ["p1"], elementIds: ["e1"], personRoles: [] });
|
||||
expect(plan.phaseValueKeys).toEqual([{ elementId: "e1", phaseId: "p1" }]);
|
||||
// Ein Übergangswert nach einer nicht existierenden Phase würde beim Schreiben auf einen
|
||||
// Fremdschlüsselfehler laufen.
|
||||
expect(plan.transitionValueKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it("führt alle Werte des alten Standes auf", () => {
|
||||
const plan = planRestore(old, { phaseIds: ["p1", "p2"], elementIds: ["e1"], personRoles: [] });
|
||||
expect(plan.phaseValueKeys).toHaveLength(2);
|
||||
expect(plan.transitionValueKeys).toEqual([{ elementId: "e1", fromPhaseId: "p1" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreComment", () => {
|
||||
it("hält die Herkunft fest", () => {
|
||||
expect(restoreComment({ major: 1, minor: 3 })).toBe("Wiederhergestellt aus 1.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameSnapshot", () => {
|
||||
it("erkennt inhaltliche Gleichheit trotz anderer Schlüsselreihenfolge", () => {
|
||||
const a = snap();
|
||||
const b = snap({ elements: [{ ...snap().elements[0] }] });
|
||||
expect(sameSnapshot(a, b)).toBe(true);
|
||||
expect(sameSnapshot(a, snap({ inflationRateDefault: 2 }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
|
||||
//
|
||||
// Version A.B:
|
||||
// B (Nebenversion) -- automatisch, EINE je Bearbeitungssitzung.
|
||||
// A (Hauptversion) -- manuell, mit Pflichtkommentar; setzt B auf 0 zurueck.
|
||||
//
|
||||
// Warum nicht je Schreibvorgang: FPT hat keinen Speichern-Knopf, jede Aenderung schreibt
|
||||
// sofort. Ein Durchlauf des Plan-Assistenten macht ~14 Schreibvorgaenge, ein Klick im
|
||||
// Verteil-Dialog einen pro Zielelement. Eine Version je Schreibvorgang waere ein
|
||||
// Tastenprotokoll, keine Historie. Stattdessen fassen wir alle Schreibvorgaenge innerhalb
|
||||
// eines Zeitfensters zu EINER Nebenversion zusammen (siehe 9.28).
|
||||
//
|
||||
// Dieses Modul enthaelt die reine Entscheidungslogik (ohne I/O), damit sie ohne Datenbank
|
||||
// testbar ist. Die Datenbank-Anbindung liegt in `versioning-db.ts`.
|
||||
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Innerhalb dieses Fensters aktualisiert ein weiterer Schreibvorgang die bestehende
|
||||
// Nebenversion, statt eine neue anzulegen.
|
||||
export const COALESCE_WINDOW_MS = 10 * 60 * 1000; // 10 Minuten
|
||||
|
||||
export interface VersionRef {
|
||||
major: number;
|
||||
minor: number;
|
||||
}
|
||||
|
||||
export function formatVersion(v: VersionRef): string {
|
||||
return `${v.major}.${v.minor}`;
|
||||
}
|
||||
|
||||
// Der jeweils letzte Stand, gegen den entschieden wird.
|
||||
export interface LatestVersion extends VersionRef {
|
||||
id: string;
|
||||
isMajor: boolean;
|
||||
createdById: string;
|
||||
updatedAt: Date;
|
||||
snapshot: PlanInput;
|
||||
}
|
||||
|
||||
export type VersionDecision =
|
||||
| { action: "skip"; reason: string }
|
||||
| { action: "update"; versionId: string }
|
||||
| { action: "create"; major: number; minor: number };
|
||||
|
||||
// Serialisiert mit SORTIERTEN Schluesseln. Noetig, weil `toPlanInput` die Werte je Phase als
|
||||
// Objekt aufbaut (`phaseValues[phaseId] = ...`) und die Reihenfolge dieser Schluessel aus der
|
||||
// Datenbank-Zeilenfolge stammt -- die ist nicht garantiert. Ohne Sortierung wuerden zwei
|
||||
// inhaltlich identische Staende als verschieden gelten und die Historie mit Scheinversionen
|
||||
// fluten.
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (val === null || typeof val !== "object" || Array.isArray(val)) return val;
|
||||
return Object.fromEntries(Object.entries(val as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
});
|
||||
}
|
||||
|
||||
// Vergleicht zwei Staende inhaltlich.
|
||||
export function sameSnapshot(a: PlanInput, b: PlanInput): boolean {
|
||||
return canonicalJson(a) === canonicalJson(b);
|
||||
}
|
||||
|
||||
// Entscheidet, was ein Schreibvorgang mit der Historie macht.
|
||||
//
|
||||
// `latest` ist die zuletzt angelegte Version des Szenarios (oder null, wenn es noch keine
|
||||
// gibt). `now` und `userId` beschreiben den aktuellen Schreibvorgang.
|
||||
export function decideVersion(
|
||||
latest: LatestVersion | null,
|
||||
next: PlanInput,
|
||||
userId: string,
|
||||
now: Date
|
||||
): VersionDecision {
|
||||
// Allererster Stand: 1.0.
|
||||
if (!latest) return { action: "create", major: 1, minor: 0 };
|
||||
|
||||
// Nichts veraendert -- z. B. Dialog geoeffnet und unveraendert gespeichert. Ohne diese
|
||||
// Pruefung sammelt die Historie Versionen, die nichts unterscheiden.
|
||||
if (sameSnapshot(latest.snapshot, next)) {
|
||||
return { action: "skip", reason: "unveraendert" };
|
||||
}
|
||||
|
||||
const withinWindow = now.getTime() - latest.updatedAt.getTime() < COALESCE_WINDOW_MS;
|
||||
|
||||
// Eine Hauptversion ist ein bewusst gesetzter Schnitt -- sie wird nie nachtraeglich
|
||||
// veraendert, auch nicht innerhalb des Fensters. Die naechste Aenderung beginnt A.1.
|
||||
if (latest.isMajor) {
|
||||
return { action: "create", major: latest.major, minor: latest.minor + 1 };
|
||||
}
|
||||
|
||||
// Fortsetzung derselben Sitzung: bestehende Nebenversion aktualisieren. An den Benutzer
|
||||
// gebunden, damit spaeter (Finanzberater) nicht fremde Aenderungen unter einem Namen
|
||||
// zusammenlaufen.
|
||||
if (withinWindow && latest.createdById === userId) {
|
||||
return { action: "update", versionId: latest.id };
|
||||
}
|
||||
|
||||
return { action: "create", major: latest.major, minor: latest.minor + 1 };
|
||||
}
|
||||
|
||||
// Naechste Hauptversion. Der Kommentar ist Pflicht -- eine Hauptversion ohne Begruendung
|
||||
// waere nur eine Zahl.
|
||||
export function nextMajor(latest: VersionRef | null): VersionRef {
|
||||
return { major: (latest?.major ?? 0) + 1, minor: 0 };
|
||||
}
|
||||
|
||||
export function isValidMajorComment(comment: string): boolean {
|
||||
return comment.trim().length >= 3;
|
||||
}
|
||||
|
||||
// --- Wiederherstellen ------------------------------------------------------------------
|
||||
|
||||
// Kind-Szenarien zeigen ueber `sourceElementId` / `sourcePhaseId` auf die IDs DIESES
|
||||
// Szenarios -- darauf beruht die Abweichungs-Markierung. Beim Wiederherstellen bleiben die
|
||||
// IDs erhalten; trotzdem kann ein Bezug brechen, wenn der alte Stand ein Element oder eine
|
||||
// Phase noch gar nicht enthielt. Das laesst sich nicht verhindern, aber ankuendigen.
|
||||
export interface RestoreImpact {
|
||||
lostElementIds: string[];
|
||||
lostPhaseIds: string[];
|
||||
affectedChildren: string[]; // Namen der betroffenen Kind-Szenarien
|
||||
}
|
||||
|
||||
export function assessRestore(
|
||||
snapshot: PlanInput,
|
||||
children: { name: string; elementSourceIds: string[]; phaseSourceIds: string[] }[]
|
||||
): RestoreImpact {
|
||||
const haveElements = new Set(snapshot.elements.map((e) => e.id));
|
||||
const havePhases = new Set(snapshot.phases.map((p) => p.id));
|
||||
|
||||
const lostElementIds = new Set<string>();
|
||||
const lostPhaseIds = new Set<string>();
|
||||
const affectedChildren: string[] = [];
|
||||
|
||||
for (const child of children) {
|
||||
const lostE = child.elementSourceIds.filter((id) => !haveElements.has(id));
|
||||
const lostP = child.phaseSourceIds.filter((id) => !havePhases.has(id));
|
||||
if (lostE.length === 0 && lostP.length === 0) continue;
|
||||
lostE.forEach((id) => lostElementIds.add(id));
|
||||
lostP.forEach((id) => lostPhaseIds.add(id));
|
||||
affectedChildren.push(child.name);
|
||||
}
|
||||
|
||||
return {
|
||||
lostElementIds: [...lostElementIds],
|
||||
lostPhaseIds: [...lostPhaseIds],
|
||||
affectedChildren,
|
||||
};
|
||||
}
|
||||
|
||||
// Kommentar der Version, die durch ein Wiederherstellen entsteht. Das Wiederherstellen
|
||||
// LOESCHT keine Historie -- es legt einen neuen Stand oben drauf.
|
||||
export function restoreComment(from: VersionRef): string {
|
||||
return `Wiederhergestellt aus ${formatVersion(from)}`;
|
||||
}
|
||||
|
||||
// --- Bauplan des Wiederherstellens -----------------------------------------------------
|
||||
//
|
||||
// Das Wiederherstellen ist der einzige destruktive Pfad der Anwendung. Damit er pruefbar
|
||||
// ist, entscheidet diese reine Funktion, WAS geschehen soll; `versioning-db.ts` fuehrt den
|
||||
// Plan nur noch aus. Drei Feinheiten stecken darin:
|
||||
//
|
||||
// 1. IDs bleiben erhalten -- sonst verlieren Kind-Szenarien ihre Diff-Basis.
|
||||
// 2. `Phase.sequenceNumber` ist je Szenario eindeutig. Beim Zurueckgehen koennen sich alte
|
||||
// und neue Nummern ueberschneiden (z. B. Phase X hatte 2, soll wieder 1 werden, waehrend
|
||||
// eine andere noch auf 1 sitzt). Deshalb werden bestehende Phasen zuerst auf negative
|
||||
// Nummern geparkt und danach auf ihre Zielnummer gesetzt.
|
||||
// 3. Werte zu inzwischen entfernten Phasen werden verworfen, nicht wiederhergestellt.
|
||||
|
||||
export interface RestorePlan {
|
||||
deletePhaseIds: string[];
|
||||
deleteElementIds: string[];
|
||||
parkPhaseIds: string[]; // vor dem Umnummerieren auf negative Werte schieben
|
||||
createPhaseIds: string[];
|
||||
updatePhaseIds: string[];
|
||||
createElementIds: string[];
|
||||
updateElementIds: string[];
|
||||
deletePersonRoles: string[];
|
||||
// Werte je Element, bereits auf die ueberlebenden Phasen gefiltert.
|
||||
phaseValueKeys: { elementId: string; phaseId: string }[];
|
||||
transitionValueKeys: { elementId: string; fromPhaseId: string }[];
|
||||
}
|
||||
|
||||
export interface CurrentState {
|
||||
phaseIds: string[];
|
||||
elementIds: string[];
|
||||
personRoles: string[];
|
||||
}
|
||||
|
||||
export function planRestore(snapshot: PlanInput, current: CurrentState): RestorePlan {
|
||||
const keepPhaseIds = snapshot.phases.map((p) => p.id);
|
||||
const keepElementIds = snapshot.elements.map((e) => e.id);
|
||||
const keepRoles = snapshot.persons.map((p) => p.role);
|
||||
|
||||
const keepPhaseSet = new Set(keepPhaseIds);
|
||||
const havePhase = new Set(current.phaseIds);
|
||||
const haveElement = new Set(current.elementIds);
|
||||
|
||||
const phaseValueKeys: { elementId: string; phaseId: string }[] = [];
|
||||
const transitionValueKeys: { elementId: string; fromPhaseId: string }[] = [];
|
||||
for (const el of snapshot.elements) {
|
||||
for (const phaseId of Object.keys(el.phaseValues)) {
|
||||
// Werte zu einer Phase, die dieser Stand nicht kannte, gehoeren nirgendwohin.
|
||||
if (keepPhaseSet.has(phaseId)) phaseValueKeys.push({ elementId: el.id, phaseId });
|
||||
}
|
||||
for (const fromPhaseId of Object.keys(el.transitionValues)) {
|
||||
if (keepPhaseSet.has(fromPhaseId)) transitionValueKeys.push({ elementId: el.id, fromPhaseId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletePhaseIds: current.phaseIds.filter((id) => !keepPhaseSet.has(id)),
|
||||
deleteElementIds: current.elementIds.filter((id) => !keepElementIds.includes(id)),
|
||||
// Nur die Phasen parken, die bleiben -- die anderen sind vorher schon weg.
|
||||
parkPhaseIds: current.phaseIds.filter((id) => keepPhaseSet.has(id)),
|
||||
createPhaseIds: keepPhaseIds.filter((id) => !havePhase.has(id)),
|
||||
updatePhaseIds: keepPhaseIds.filter((id) => havePhase.has(id)),
|
||||
createElementIds: keepElementIds.filter((id) => !haveElement.has(id)),
|
||||
updateElementIds: keepElementIds.filter((id) => haveElement.has(id)),
|
||||
deletePersonRoles: current.personRoles.filter((r) => !keepRoles.includes(r as never)),
|
||||
phaseValueKeys,
|
||||
transitionValueKeys,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user