Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s

Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

Modell:
- Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase).
- Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit
  abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt.
- Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen
  als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein.
- Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId,
  sourceElementId). Ueber den Namen zu matchen waere fragil gewesen.

Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live):
- geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile.
- Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert,
  Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix.
- Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im
  Dunkelschema unbrauchbar.

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

Datenmodell/Migration:
- Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle
  Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/
  FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben
  Behaelter zugeordnet, auch mehrfach verschachtelte.
- Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process),
  inkl. verschachtelter Szenarien und Cascade. Der Test ist als
  migrations.test.ts committet und sichert kuenftige Migrationen ab.

API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
+165 -63
View File
@@ -4,10 +4,10 @@
| | |
|---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.7 |
| **Datum** | 2026-07-17 |
| **Version** | 0.8 |
| **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `5bf35bd` inkl. Monte-Carlo-Simulation (Stufe A) (Branch `main`) |
| **Codestand** | Arbeitsstand nach `71137f7` inkl. Szenario-Hierarchie (V6) (Branch `main`) |
| **Ersetzt** | `FDD_TDD_FPT.docx` (v1v5) im Ordner `Info Dateien` diese sind ab Version 0.1 dieses Dokuments obsolet |
| **Geltungsbereich** | Gesamter Code im Verzeichnis `FPT` |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung |
|---|---|---|---|
| 0.8 | 2026-07-18 | Claude (Opus 4.8) | **Szenario-Hierarchie (V6)** grösste Umstrukturierung bisher. Der **Plan** ist neu ein schlanker Behälter ohne Finanzdaten; die berechenbare Einheit ist das **Szenario**, das Grundprofil (inkl. **Pensionsalter** → Frühpensionierungs-Szenarien), Phasen und Elemente trägt. Jeder Plan erhält beim Anlegen automatisch ein **Basisszenario**; weitere Szenarien entstehen als vollständige Kopie eines beliebigen Szenarios und hängen als **Baum** darunter (Sidebar zeigt die Verschachtelung). Kopierte Phasen/Elemente tragen Herkunfts-Verweise (`sourcePhaseId`, `sourceElementId`) darauf beruht die **Abweichungs-Markierung**: geändert = gelb, neu = grün, entfernt = graue Geisterzeile (eigene Theme-Tokens für alle drei Farbschemata). Charts vergleichen neu die Geschwister-Szenarien. Datenmodell: neue Tabelle `Plan`, bisheriger `Plan``Scenario` (IDs erhalten), `planId``scenarioId` in Person/Phase/FinancialElement. API neu unter `/api/scenarios/*`. Neue Kapitel 2.1, 3.2, 4.13; 9 Diff-Tests + Migrations-Test (48 → 58). **Migration mit echtem Postgres (PGlite) verifiziert**, inkl. verschachtelter Szenarien und Cascade. |
| 0.7 | 2026-07-17 | Claude (Opus 4.8) | **Monte-Carlo-Simulation** (Roadmap Nr. 19, Stufe A). Button in der Planansicht → Dialog mit Erklärung, Eingaben und Ergebnis. Statt einer festen Rendite/Inflation werden tausende Zufallspfade gerechnet; ausgewiesen werden **Ruinwahrscheinlichkeit**, **Erfolgswahrscheinlichkeit** (P(Endvermögen ≥ Zielbetrag)) und ein **Fächer** (10 %/Median/90 %) plus die deterministische Linie. Läuft komplett im Browser (`computePlan` ist rein). Modell: fettschwänzige Verteilung (Student-t, ν=5), gemeinsamer Marktschock (ρ=0.7), Böden 0 % für PK/3a und 100 % sonst. Pro renditetragendem Element und für die Inflation je: historischer Ø (Pflicht), Streuungsstufe, σ. Neue Datei `montecarlo.ts` + optionaler `sample`-Parameter in `computePlan` (Inflation neu als kumulatives Array; deterministisch identisch). Neues Kapitel 4.12; 7 MC-Tests + Refactor-Absicherung (41 → 48). Keine Verhaltensänderung, keine DB-Änderung. |
| 0.6 | 2026-07-17 | Claude (Opus 4.8) | **Teilverkauf** von Sonstigem Vermögen (Roadmap Nr. 42) und **Sonderamortisation** der Hypothek (Roadmap Nr. 15). `OTHER_ASSET` am Übergang neu: Halten / Verkaufen / **Teilverkauf** ein Betrag fliesst ins Cash (erscheint als „Kapitalzufluss" im Phasenkopf), der Rest bleibt investiert. `REAL_ESTATE` im Halten-Fall neu mit **Einmaltilgung** aus dem Cash (analog zur Sofort-Tilgung bei Schulden; erscheint als „Kapitalinvestition"). Damit lässt sich die indirekte Amortisation via 3a mechanisch nachbilden. Die eigentliche Roadmap Nr. 15 (Steuerwirkung der indirekten Amortisation) bleibt mit dem Steuer-Bündel 13/14/23 zurückgestellt siehe 9.14. Kapitel 4.9.3/4.9.4 ergänzt. Fünf Regressionstests (36 → 41). Keine Verhaltensänderung für bestehende Pläne. |
| 0.5 | 2026-07-17 | Claude (Opus 4.8) | **Netto/Brutto geklärt** (Roadmap Nr. 9, reduziert) und **Immobilien-Modul erweitert** (Roadmap Nr. 8). Einkommen ist neu explizit als **Nettolohn** definiert (Label und Hilfetext); für die AHV rechnet das Tool intern mit `AHV_GROSS_FROM_NET_FACTOR = 1.12` auf den Bruttolohn hoch die AHV bemisst sich am Brutto, die bisherige Netto-Basis unterschätzte die Rente um bis zu ~1'900/Jahr. Immobilie neu mit **Hypothekarzins** (% der Restschuld, sinkt mit der Amortisation, mit Doppelzählungs-Schalter) und **Wertsteigerung** (auf die **Liegenschaft**, nicht auf das Eigenkapital Hebeleffekt). Grundstückgewinnsteuer bemisst sich neu explizit am ursprünglichen Kaufpreis. Keine Aufschlüsselung bei Einkommen oder Ausgaben, keine Steuerschätzung (Begründung: 9.14). Neues Kapitel 4.4.5; 4.6.5 und 4.9.4 überarbeitet; Abschnitt 9 um zwei Punkte ergänzt. Sechs Regressionstests (30 → 36). **Verhaltensänderung:** siehe 9.13. |
@@ -86,26 +87,39 @@ Referenz: `prisma/schema.prisma` Zeilen 810, `src/lib/types.ts` Zeilen 384
# 2. Fachliche Grundkonzepte
## 2.1 Die vier Ebenen
## 2.1 Die Ebenen
```
User
└── Plan (Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert)
── Person[] (1 bei SINGLE, 2 bei COUPLE)
├── Phase[] (geordnete Kette: sequenceNumber 1..n, je mit Dauer in Jahren)
└── FinancialElement[] (plan-weit, kategorisiert, optional personenzugeordnet)
├── ElementPhaseValue[] (Werte je Phase, JSON)
── ElementTransitionValue[] (Entscheide je Übergang, JSON)
└── Plan (Behälter: nur Name — KEINE Finanzdaten)
── Scenario[] (die berechenbare Einheit)
│ Grundprofil: Haushaltsform, Personen, Inflation, Cash-Startwert
│ isBase = genau eines je Plan · parentScenarioId = Baum + Vergleichsbasis
├── Person[] (1 bei SINGLE, 2 bei COUPLE — je Szenario eigen!)
── Phase[] (Kette 1..n; sourcePhaseId = Gegenstück in der Vorlage)
└── FinancialElement[] (szenario-weit; sourceElementId = Gegenstück in der Vorlage)
├── ElementPhaseValue[] (Werte je Phase, JSON)
└── ElementTransitionValue[] (Entscheide je Übergang, JSON)
```
Das zentrale Designprinzip (Element-Rework 07/2026, Migration `20260713100000_element_model_rework`):
**Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine
Pensionskasse "PK Arbeitgeber" existiert einmal pro Plan; sie hat pro Phase einen Werte-Satz und
pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines Vermögensgegenstands
über die Zeit erhalten Voraussetzung für die Fortschreibung (Carry) und für die
Vermögensaufteilungs-Grafik.
**Zwei Designprinzipien tragen dieses Modell:**
Referenz: `prisma/schema.prisma` Zeilen 110, 114151.
**(1) Ein finanzielles Element ist über alle Lebensphasen hinweg dieselbe Entität.** Eine
Pensionskasse „PK Arbeitgeber" existiert einmal pro Szenario; sie hat pro Phase einen Werte-Satz
und pro Phasenübergang einen Entscheid-Satz. Dadurch bleibt die Identität eines
Vermögensgegenstands über die Zeit erhalten Voraussetzung für die Fortschreibung (Carry) und
die Vermögensaufteilungs-Grafik.
**(2) Das Grundprofil liegt am Szenario, nicht am Plan** (V6). Nur so lassen sich die
wertvollsten Szenario-Fragen abbilden allen voran ein abweichendes **Pensionsalter**
(„Was, wenn ich mit 62 statt 65 aufhöre?"), das in `Person` steckt. Wäre das Profil geteilt,
wären Frühpensionierungs-Szenarien unmöglich.
Der **Plan** ist damit ein reiner Behälter: Er bündelt Szenarien und trägt den Eigentümer
(`userId`). Ownership von Szenario/Phase/Element läuft über die Kette
`Scenario → Plan → User`.
Referenz: `prisma/schema.prisma`.
## 2.2 Die Matrix als Leitmetapher
@@ -263,21 +277,53 @@ Referenz: `prisma/schema.prisma` Zeile 82.
### 3.2.5 Szenarien
Ein Szenario ist eine **Deep-Copy eines Plans bis zu einer gewählten Verzweigungsphase (inklusive)**.
Beim Anlegen eines Plans entsteht **automatisch das Basisszenario** (`isBase = true`,
Name „Basisszenario"). Der beim Anlegen erfasste Profilteil (Haushaltsform, Personen,
Inflation) landet dort, der Name am Plan.
Kopiert werden:
- Grundprofil (Haushaltsform, Inflation, `initialCash`) und alle Personen
- alle Phasen mit `sequenceNumber <= branchPhase.sequenceNumber`
- **alle** Elemente (unabhängig von der Verzweigungsphase)
- Phasenwerte nur für kopierte Phasen; Übergangswerte nur für Übergänge aus kopierten Phasen
Ein weiteres Szenario ist eine **vollständige Kopie eines beliebigen bestehenden Szenarios**
(nicht nur des Basisszenarios). Kopiert werden Grundprofil, alle Personen, alle Phasen, alle
Elemente sowie sämtliche Phasen- und Übergangswerte.
Gesetzt werden `parentPlanId` (Ursprungsplan) und `branchFromPhaseId` (die **neue** ID der
letzten kopierten Phase). Der Benutzer landet direkt im neuen Szenario, das anschliessend
unabhängig weiterentwickelt wird.
Gesetzt werden dabei:
- `parentScenarioId` = das kopierte Szenario → ergibt den **Baum** in der Seitenleiste **und**
die **Vergleichsbasis** für die Abweichungs-Markierung
- `sourcePhaseId` / `sourceElementId` je kopierter Phase bzw. Element → die **Identität**, über
die der Diff „dieselbe Zelle" wiederfindet
In der Planliste erscheint ein Szenario mit dem Zusatz „· Szenario".
Da jede Kopie wieder kopierbar ist, entstehen **Sub-Szenarien** beliebiger Tiefe; die
Seitenleiste rückt sie entsprechend ein.
Referenz: `src/app/api/plans/[planId]/scenario/route.ts`.
**Löschen:** Ein Szenario lässt sich löschen, das **Basisszenario nicht** (dafür den ganzen Plan
löschen). Das Löschen eines Plans entfernt per Cascade alle seine Szenarien.
Referenz: `src/app/api/scenarios/[scenarioId]/copy/route.ts`.
### 3.2.6 Abweichungs-Markierung (Diff)
Jedes Szenario ausser dem Basisszenario wird **live gegen sein Eltern-Szenario** verglichen.
Abweichende Stellen sind in der Matrix farblich markiert:
| Zustand | Darstellung |
|---|---|
| **geändert** | gelb hinterlegt (`--diff` / `--diff-soft`) |
| **neu** (in der Vorlage nicht vorhanden) | grün hinterlegt + Badge „neu" (`--diff-added`) |
| **entfernt** (in der Vorlage vorhanden, hier gelöscht) | graue **Geisterzeile**, durchgestrichen (`--diff-removed`) |
Markiert werden: Phasenzellen, Übergangszellen, Element-Zeilen (Name/Zuordnung), Phasenköpfe
(Name/Dauer), der Cash-Anfangswert, Cash-Übergänge und das Grundprofil-Banner. Über der Matrix
steht die Gesamtzahl der Abweichungen.
Die Farben sind **semantische Tokens** und für Hell, Dunkel und Warm getrennt abgestimmt ein
fester Gelbwert würde im Dunkelschema unbrauchbar aussehen.
**Zwei bewusste Eigenschaften:**
- Der Vergleich läuft gegen das **direkte Eltern-Szenario**, nicht immer gegen die Basis. Bei
einem Sub-Szenario ist das genau „was habe ich gegenüber der Vorlage geändert".
- Der Diff ist **live**: Ändert man die Vorlage, verschiebt sich die Markierung im Kind
rückwirkend (siehe [9.16](#916-diff-ist-live-gegen-die-vorlage)).
Referenz: `src/lib/diff.ts`.
## 3.3 Lebensphasen
@@ -1618,25 +1664,34 @@ PlanComputed ← an den Client geliefert
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** |
| `scenarioId` | String | FK → Scenario, **Cascade** |
| `role` | `PersonRole` | `PERSON_A` \| `PERSON_B` |
| `name` | String? | optional |
| `age` | Int | aktuelles Alter |
| `retirementAge` | Int | plan-eigenes Pensionsalter |
| | | `@@unique([planId, role])` |
| `retirementAge` | Int | **szenario-eigenes** Pensionsalter |
| | | `@@unique([scenarioId, role])` |
**Plan**
**Plan** (Behälter trägt keine Finanzdaten)
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `userId` | String | FK → User, **Cascade** |
| `name` | String | |
| `createdAt` / `updatedAt` | DateTime | |
**Scenario** (die berechenbare Einheit)
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** |
| `name` | String | |
| `isBase` | Boolean | Default false; genau eines je Plan ist `true` |
| `parentScenarioId` | String? | FK → Scenario (Self-Relation „ScenarioTree"), **SetNull** Baum **und** Vergleichsbasis |
| `householdType` | `HouseholdType` | `SINGLE` \| `COUPLE` |
| `inflationRateDefault` | Float | plan-weite Inflation in % |
| `inflationRateDefault` | Float | szenario-weite Inflation in % |
| `initialCash` | Float | Default 0 |
| `parentPlanId` | String? | FK → Plan (Self-Relation „PlanScenarios"), **SetNull** |
| `branchFromPhaseId` | String? | ID der letzten kopierten Phase (**keine** FK-Constraint) |
| `createdAt` / `updatedAt` | DateTime | |
**Phase**
@@ -1644,13 +1699,14 @@ PlanComputed ← an den Client geliefert
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** |
| `scenarioId` | String | FK → Scenario, **Cascade** |
| `sequenceNumber` | Int | 1-basiert, lückenlos |
| `name` | String | |
| `durationYears` | Int | 180 |
| `cashTransition` | Json? | Cash-Entscheid beim Übergang **nach** dieser Phase (siehe 5.4.5) |
| `sourcePhaseId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` / `updatedAt` | DateTime | |
| | | `@@unique([planId, sequenceNumber])` |
| | | `@@unique([scenarioId, sequenceNumber])` |
`cashTransition` liegt an der Phase und nicht in `ElementTransitionValue`, weil Cash kein
`FinancialElement` ist und damit keine `elementId` besitzt. Die Verschlüsselung folgt derselben
@@ -1661,13 +1717,18 @@ Logik wie dort: Der Übergang gehört der **Von**-Phase.
| Feld | Typ | Constraints |
|---|---|---|
| `id` | String | PK, `cuid()` |
| `planId` | String | FK → Plan, **Cascade** |
| `scenarioId` | String | FK → Scenario, **Cascade** |
| `category` | `ElementCategory` | 8 Werte |
| `name` | String | |
| `ownerRole` | `OwnerRole?` | `PERSON_A` \| `PERSON_B` \| `HOUSEHOLD` |
| `orderIndex` | Int | Default 0 |
| `sourceElementId` | String? | Gegenstück in der Vorlage (**lose** Referenz, kein FK) Diff-Grundlage |
| `createdAt` | DateTime | |
Die Herkunfts-Verweise sind bewusst **lose** (kein Fremdschlüssel): Wird das Gegenstück in der
Vorlage gelöscht, soll die Kopie bestehen bleiben und im Diff einfach als „neu" gelten ein
Cascade wäre hier falsch.
**ElementPhaseValue**
| Feld | Typ | Constraints |
@@ -1779,6 +1840,18 @@ sondern zu leeren Werten.
| `20260715120000_plan_initial_cash` | `Plan.initialCash` |
| `20260716210000_drop_phase_inflation_rate` | `Phase.inflationRate` entfernt (Inflation ist plan-weit) |
| `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 |
**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
Wurzel-Element entsteht ein neuer Behälter (`'plan_' || id`, deterministisch ableitbar, daher
ohne Hilfstabelle); der bisherige Plan-Name wandert dorthin, das Szenario heisst „Basisszenario".
Bestehende Szenarien werden per rekursivem CTE demselben Behälter zugeordnet auch mehrfach
verschachtelte. Ein Sicherheitsnetz fängt verwaiste Szenarien ab und macht sie eigenständig.
Die Migration wurde **vor dem Deploy gegen echtes PostgreSQL verifiziert** (PGlite, in-process):
alle Vorgänger-Migrationen einspielen, realistische Daten inkl. verschachtelter Szenarien
anlegen, migrieren, Ergebnis und Cascade prüfen.
## 5.5 Frontend-Architektur
@@ -1893,39 +1966,54 @@ Liste der eigenen Pläne, sortiert nach `createdAt` aufsteigend.
```
Validierung: `name` 1120; `inflationRateDefault` 20…50; `persons` 12 Einträge;
`age` 0120; `retirementAge` 30100; `name` je Person ≤ 60. Zusätzlich Konsistenzregel
SINGLE=1 / COUPLE=2 Personen. → 201 `{ plan: { id } }`
SINGLE=1 / COUPLE=2 Personen. Legt Plan **und Basisszenario** an.
→ 201 `{ plan: { id }, scenario: { id } }`
### `GET /api/plans/<planId>`
Liefert **Eingabe und Berechnung** in einem Zug:
`GET /api/plans` liefert die Pläne inkl. Szenario-Kopfdaten:
```json
{ "plan": <PlanInput>, "computed": <PlanComputed> }
{ "plans": [ { "id", "name", "createdAt",
"scenarios": [ { "id", "planId", "name", "isBase", "parentScenarioId" } ] } ] }
```
Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd.
### `PATCH /api/plans/<planId>`
`{ name }` der Plan trägt nur noch den Namen. → 200 `{ plan: { id, name } }`
### `DELETE /api/plans/<planId>`
→ 200 `{ ok: true }`, Cascade über alle Szenarien.
## 6.3 Szenarien
### `GET /api/scenarios/<scenarioId>`
Liefert Eingabe, Berechnung **und die Vergleichsbasis** in einem Zug:
```json
{ "plan": <PlanInput>, "computed": <PlanComputed>,
"base": <PlanInput|null>,
"meta": { "id", "planId", "planName", "name", "isBase", "parentScenarioId" } }
```
`base` ist das Eltern-Szenario (null beim Basisszenario) daraus rechnet der Client den Diff.
Dies ist der einzige Endpunkt, der die Berechnung ausführt (neben `export`). → 404 wenn fremd.
### `PATCH /api/scenarios/<scenarioId>`
Akzeptiert eine **Union** von zwei Formen:
1. Vollständiges Profil: `{ householdType, inflationRateDefault, persons[], name? }` ersetzt
die Personen in einer Transaktion.
2. Teilaktualisierung: `{ name?, initialCash? }` `initialCash` 0…1'000'000'000, gerundet.
Die Unterscheidung erfolgt über das Vorhandensein von `householdType`.
→ 200 `{ plan: { id, name } }`
→ 200 `{ scenario: { id, name } }`
### `DELETE /api/plans/<planId>`
→ 200 `{ ok: true }`, Cascade-Löschung.
### `DELETE /api/scenarios/<scenarioId>`
→ 200 `{ ok: true }` · 400 wenn es das **Basisszenario** ist.
### `POST /api/plans/<planId>/scenario`
```json
{ "name": "Frühpensionierung", "branchFromPhaseId": "<phaseId>" }
```
→ 201 `{ planId: "<neue Id>" }`
### `POST /api/scenarios/<scenarioId>/copy`
`{ name }` vollständige Kopie; setzt `parentScenarioId` sowie die Herkunfts-Verweise.
→ 201 `{ scenarioId: "<neue Id>" }`
### `GET /api/plans/<planId>/export`
### `GET /api/scenarios/<scenarioId>/export`
`text/csv; charset=utf-8`, `Content-Disposition: attachment`.
## 6.3 Phasen
## 6.4 Phasen
### `POST /api/plans/<planId>/phases`
### `POST /api/scenarios/<scenarioId>/phases`
Body optional: `{ name?, durationYears? }`.
Hängt eine Phase am Ende an, kappt die Dauer, vergibt Default-Name, legt vorbelegte
`ElementPhaseValue` für alle aktiven Elemente an (alles in einer Transaktion).
@@ -1943,9 +2031,9 @@ Body = `CashTransitionData` (siehe 5.4.5). Speichert den Cash-Entscheid für den
dieser Phase (einmalige Sonderein-/ausgaben). Schreibt die Spalte `Phase.cashTransition`.
→ 200 `{ ok }` · 404 wenn die Phase nicht dem Benutzer gehört.
## 6.4 Elemente
## 6.5 Elemente
### `POST /api/plans/<planId>/elements`
### `POST /api/scenarios/<scenarioId>/elements`
`{ category, name, ownerRole? }` → 201 `{ element: { id } }`
- `PERSON_ONLY_CATEGORIES` ohne Person → 400
- fehlendes `ownerRole` sonst → `HOUSEHOLD`
@@ -2024,7 +2112,7 @@ Zielumgebung: Hetzner CX23, Traefik als Reverse Proxy, Domain `fpt.aicds.ch`, Gi
Getestet wird ausschliesslich der Berechnungskern bewusst, da dort die Fachlogik und das
Regressionsrisiko liegen. `src/lib/calculations.test.ts` (41 Tests: AHV-Rentenformel, Immobilie,
Teilverkauf, Sonderamortisation, AHV einkommensabhängig, „V5 Golden Tests") und
`src/lib/montecarlo.test.ts` (7 Tests) ergeben zusammen **48 Tests**, ausgeführt mit Vitest in
`src/lib/montecarlo.test.ts` (7 Tests), `src/lib/diff.test.ts` (9 Tests) und `src/lib/migrations.test.ts` (1 Test, spielt alle Migrationen gegen echtes PostgreSQL ein) ergeben zusammen **58 Tests**, ausgeführt mit Vitest in
der Node-Umgebung (`vitest.config.ts`, Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-,
API- oder E2E-Tests.
@@ -2098,10 +2186,10 @@ Negatives Cash geht mit negativem Vorzeichen ins Gesamtvermögen ein.
## 9.2 Verwaiste `PERSON_B`-Elemente
Die Haushaltsform ist eine **Plan-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen"
auch bei einem bestehenden Plan nachträglich ändern (nicht pro Phase innerhalb eines Plans
gilt sie durchgehend). Wechselt ein Plan dabei von `COUPLE` auf `SINGLE`, schneidet
`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/plans/<id>` löscht Person B
Die Haushaltsform ist eine **Szenario-Eigenschaft** und lässt sich im Dialog „Plan-Einstellungen"
auch bei einem bestehenden Szenario nachträglich ändern (nicht pro Phase innerhalb eines Szenarios
gilt sie durchgehend). Wechselt ein Szenario dabei von `COUPLE` auf `SINGLE`, schneidet
`PlanProfileFields` die Personen auf eine zusammen und `PATCH /api/scenarios/<id>` löscht Person B
aus der Datenbank. Elemente mit `ownerRole = "PERSON_B"` bleiben bestehen. In der Berechnung
liefert `personByRole` dann `null`:
@@ -2247,7 +2335,21 @@ angeboten (fette Ränder fest eingebaut); die Simulationsparameter werden **nich
(ephemer im Dialog). Ein historischer Backtest (Stufe B) und korrelierte/vollständigere Modelle
(Stufe C) sind offen.
## 9.16 Kleinere Beobachtungen
## 9.16 Diff ist live gegen die Vorlage
Die Abweichungs-Markierung vergleicht **immer den aktuellen Stand** des Eltern-Szenarios. Ändert
man die Vorlage nachträglich, verschiebt sich die Markierung in allen Kindern rückwirkend: Setzt
man in der Vorlage einen Wert auf das, was ein Szenario ohnehin hatte, verschwindet dort die
gelbe Markierung, ohne dass das Szenario angefasst wurde.
Das ist logisch korrekt („weicht ab von der Vorlage"), kann aber überraschen. Die Alternative
ein eingefrorener Snapshot beim Kopieren wäre schnell veraltet und würde Abweichungen anzeigen,
die keine mehr sind. Bewusster Entscheid zugunsten des Live-Vergleichs.
Verwandt: Wird ein Element in der Vorlage gelöscht, gilt das Gegenstück im Kind ab dann als
**neu** (der Herkunfts-Verweis zeigt ins Leere). Auch das folgt aus dem Live-Vergleich.
## 9.17 Kleinere Beobachtungen
- `planToCsv(plan, computed)` erhält den `plan`-Parameter, verwendet ihn aber nicht.
- Der Typ `Selection` in `PlanView` hat nur eine Variante (`{ type: "phase" }`) ein Rest der