V7: Haushalt auf Plan-Ebene, Annahmen bleiben am Szenario
Deploy App / deploy (push) Successful in 1m52s

Haushaltsform, Personen (Name/Alter) und Startjahr wandern vom Szenario
auf den Plan. Das Pensionsalter bleibt szenario-eigen -- es ist der Kern
jedes Frueh-/Spaetpensionierungs-Szenarios.

Neue Tabelle PlanPerson; Person behaelt nur Rolle + Pensionsalter;
Plan bekommt householdType und startYear.

Der Rechenkern bleibt unberuehrt: toPlanInput fuegt beide Ebenen wieder
zu einem unveraenderten PlanInput zusammen. 43 Golden Tests unveraendert.

Nebeneffekt: Ein Ist-Satz trifft jetzt in ALLEN Szenarien dasselbe
Planjahr -- vorher war das nicht garantiert.

Wiederherstellen einer Version setzt nur noch Szenario-Eigenes zurueck.
Profil-Dialog kennzeichnet plan-weite vs. szenario-eigene Felder.

Zwei Tests spielen echte V6-Daten ein und pruefen die Uebernahme.
Spezifikation 0.23 (210 -> 212).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:53:12 +02:00
parent 44c6c81f5d
commit ce5f83823f
10 changed files with 290 additions and 51 deletions
+6 -4
View File
@@ -4,7 +4,7 @@
| | | | | |
|---|---| |---|---|
| **Dokument** | Funktionale und Technische Spezifikation FPT | | **Dokument** | Funktionale und Technische Spezifikation FPT |
| **Version** | 0.22 | | **Version** | 0.23 |
| **Datum** | 2026-07-18 | | **Datum** | 2026-07-18 |
| **Status** | Lebendes Dokument | | **Status** | Lebendes Dokument |
| **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | | **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) |
@@ -17,6 +17,7 @@
| Version | Datum | Autor | Änderung | | Version | Datum | Autor | Änderung |
|---|---|---|---| |---|---|---|---|
| 0.23 | 2026-07-20 | Claude (Opus 4.8) | **V7: Der Haushalt liegt am Plan, die Annahmen am Szenario.** **Haushaltsform**, **Personen** (Name, Alter) und **Planstartjahr** wandern vom Szenario auf den **Plan**; das **Pensionsalter** bleibt szenario-eigen (es ist der Kern jedes Früh-/Spätpensionierungs-Szenarios), ebenso Inflation und Cash-Anfangswert. Begründung: Diese Angaben beschreiben den Haushalt, nicht eine Planungsvariante unterscheiden sie sich, ist es ein anderer **Plan**, kein anderes Szenario. Neue Tabelle `PlanPerson` (Rolle, Name, Alter je Plan); `Person` behält nur noch Rolle und Pensionsalter; `Plan` bekommt `householdType` und `startYear`. **Der Rechenkern bleibt unberührt:** `toPlanInput()` fügt Plan- und Szenario-Ebene wieder zu einem unveränderten `PlanInput` zusammen, die 43 Golden Tests laufen durch. **Nebeneffekt, der ein reales Problem löst:** Weil das Startjahr nun plan-weit ist, landet ein erfasster Ist-Satz für 2031 in **allen** Szenarien zwingend auf demselben Planjahr vorher war das nicht garantiert. Das **Wiederherstellen einer Szenario-Version** setzt folgerichtig nur noch das Szenario-Eigene zurück; plan-weite Angaben über eine Version *eines* Szenarios zu überschreiben, hätte die übrigen stillschweigend mitverändert. Der Profil-Dialog kennzeichnet neu je Feld, ob es **plan-weit** oder **nur dieses Szenario** gilt. Die Migration übernimmt die Werte aus dem **Basisszenario**; zwei neue Tests spielen dafür echte V6-Daten ein und prüfen die Übernahme inkl. abweichender Nebenszenarien (210 → 212). |
| 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt neu ein Feld `cash` (Stand am Jahresende), analog zu `wealthNominal`; der Wizard liest daraus. Die Vorbelegung der ELEMENTE war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. | | 0.22 | 2026-07-20 | Claude (Opus 4.8) | **Fehlerbehebung: Cash-Vorbelegung im Ist-Wizard.** Der Wizard für die effektiven Werte zeigte als geplanten Cash-Bestand den Stand am **Phasenende** statt am gewählten Stichtag -- in einer Phase von 2026 bis 2036 also für 2031 den Wert von 2036. Ursache: Der Dialog las `cashBridge.cashEnd`, weil `computePlan` den Cash-Bestand bisher nur **je Phase** auswies. `YearPoint` trägt neu ein Feld `cash` (Stand am Jahresende), analog zu `wealthNominal`; der Wizard liest daraus. Die Vorbelegung der ELEMENTE war nie betroffen -- die stammte schon immer aus dem Jahresverlauf. Zwei Regressionstests decken den gemeldeten Fall ab (208 -> 210). Rein additiv, die 43 Golden Tests laufen unverändert. |
| 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). | | 0.21 | 2026-07-20 | Claude (Opus 4.8) | **Effektive Werte / Plan-Ist-Vergleich** (Roadmap Nr. 5, neue Kapitel 3.9 und 9.29). Macht aus dem Planer ein Monitoring-Werkzeug. Neuer Knopf **«Effektive Werte»** auf Plan-Ebene: Liste der Erfassungen plus Wizard in zwei Schritten (Stichtag, dann alle Elemente **aller** Szenarien inkl. **Cash**, Einkommen und Ausgaben, vorbelegt mit dem Planwert für dieses Jahr). Ein Ist-Satz hängt am **Plan**, nicht am Szenario die Wirklichkeit ist dieselbe, egal wogegen man sie hält; die Zuordnung läuft über die Herkunfts-Kette `sourceElementId`. Das exakte Datum steht in Liste und Zeitachse, für die Rechnung zählt nur die **Jahreszahl**. **Zweiter Rechenlauf:** `computePlan` nimmt neu `{ actuals }`; die Werte schnappen in **jedem** erfassten Jahr auf die Realität und laufen von dort planmässig weiter (Lücken fallen auf die Plandaten zurück). Ohne die Option verhält sich die Funktion exakt wie bisher die 43 Golden Tests laufen unverändert. Der Sprung wird als eigene Brückenposition `actualsCorrection` geführt (Vermögens- **und** Cash-Brücke): Eine Planabweichung ist keine Rendite, und ohne diese Zeile ginge die Zerlegung im Ist-Jahr nicht mehr auf. **Matrix:** zweiter Umschalter «Plan» / «Effektiv»; im Ist-Modus steht neben dem Wert die **Abweichung** zum Plan, farbig bewusst kein «beide», das wären mit nominal/real acht Zahlen je Zelle (Begründung 9.29). **Zeitachse:** Marker je erfasstem Jahr, der jüngste farbig, ältere blass. **Alle vier Analysewerkzeuge** erhalten eine einheitliche Leiste (nominal/real als **Einfach**auswahl, Plan/Effektiv); im Vermögensverlauf kommt die Planlinie **gestrichelt** als Referenz dazu, max. vier Serien. **Monte-Carlo:** Der Zielbetrag dreht mit real/nominal mit und wird entsprechend beschriftet; eine Zeile weist aus, ab welchem Jahr simuliert wird die Jahre davor sind durch Ist-Werte belegt und werden nicht gewürfelt. Das Startjahr ist **abgeleitet, nicht eingebbar**: Ein frei gesetztes Jahr würde Jahre als sicher behandeln, die nie erfasst wurden. Ein Ist-Satz erzeugt **keine** Szenario-Version er ist eine Beobachtung, keine Planänderung. Neue Tabelle `ActualsSet` (Migration gegen echtes Postgres verifiziert), neue Endpunkte unter `/api/plans/<id>/actuals`, neue Module `actuals.ts` und `dataview.ts`; 27 Tests ergänzt (181 → 208). |
| 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. | | 0.20 | 2026-07-19 | Claude (Opus 4.8) | **Raten über Lebensphasen übernehmen** (neues Kapitel 3.6.11) und **Rate in der Verlaufsgrafik**. (1) Ändert man ein **Ratenfeld**, fragt das Bearbeitungspanel neu nach der Reichweite: **nur diese Phase** (Vorgabe, bisheriges Verhalten), **diese + folgende** oder **alle Phasen**. Anlass war, dass eine geänderte Rendite bisher nur für die eine Phase galt und viermal eingetippt werden musste. Als Ratenfelder gelten `expectedReturn` (PK, 3a, Sonstiges Vermögen), `valueGrowth` und `interestRate` (Immobilie) sowie `teuerungsausgleich` (Einkommen, Ausgaben); AHV und Schulden haben keine. Die Rückfrage erscheint **inline und erst beim Speichern wirksam**, nicht als Modal das Zahlenfeld löst bei jedem Tastendruck aus, ein Dialog erschiene bei «5.2» viermal. Sie erscheint nur bei **tatsächlich veränderten** Raten («nicht gesetzt» und 0 gelten als gleich). Beim Übertragen bleiben die **übrigen Werte der Zielphasen erhalten** der Endpunkt ersetzt den ganzen Werte-Satz, ein blosses Kopieren des Entwurfs hätte dort Beträge, Sparraten und Bezüge gelöscht (durch Test abgesichert). Phasen, in denen der Wert schon stimmt, werden übersprungen. Kein neuer Schreibpfad: ein PUT je Zielphase über den bestehenden Endpunkt, alle in einer Bearbeitungssitzung und damit in **einer** Nebenversion. (2) `ElementYearPoint` führt neu ein Feld **`rate`** mit additiv, es wird nur durchgereicht, was die Rechnung ohnehin benutzt; die 43 Golden Tests laufen unverändert. Die Verlaufsgrafik der Element-Detailansicht zeigt die Rate damit auf einer **zweiten Y-Achse rechts** in Prozent, als **Stufenlinie** (innerhalb einer Phase konstant, Sprung an der Phasengrenze). Neues Modul `ratefields.ts`; 17 Tests ergänzt (164 → 181). Keine DB- oder API-Änderung. |
@@ -2681,7 +2682,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`,
FPT/ FPT/
├── prisma/ ├── prisma/
│ ├── schema.prisma Datenmodell │ ├── schema.prisma Datenmodell
│ └── migrations/ 14 Migrationen (chronologisch, siehe 5.4.6) │ └── migrations/ 15 Migrationen (chronologisch, siehe 5.4.6)
├── src/ ├── src/
│ ├── app/ │ ├── app/
│ │ ├── api/ Route Handlers (siehe Kapitel 6) │ │ ├── api/ Route Handlers (siehe Kapitel 6)
@@ -2940,6 +2941,7 @@ sondern zu leeren Werten.
| `20260718140000_scenario_start_year` | `Scenario.startYear` (Kalenderjahr des Planbeginns), bestehende auf das laufende Jahr gesetzt | | `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` | | `20260719210000_scenario_versioning` | Tabelle `ScenarioVersion` (Snapshot als JSONB, A.B eindeutig je Szenario) und `Scenario.currentMajor` |
| `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) | | `20260720090000_actuals` | Tabelle `ActualsSet` (effektive Werte je Plan, Werte als JSONB, Cash separat) |
| `20260720140000_plan_level_profile` | **V7**: Haushaltsform, Personen (Name/Alter) und Startjahr vom Szenario auf den Plan; neue Tabelle `PlanPerson`; `Person` behaelt nur das Pensionsalter. Datenübernahme aus dem Basisszenario. |
**Zur V6-Migration:** Sie benennt die bisherige `Plan`-Tabelle in `Scenario` um dadurch **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 bleiben alle IDs und damit sämtliche Kind-Fremdschlüssel gültig. Für jedes bisherige
@@ -3286,8 +3288,8 @@ Include `src/**/*.test.ts`). Es gibt **keine** Komponenten-, API- oder E2E-Tests
| `phaseplan.test.ts` | 8 | Ableitung der Lebensabschnitte aus den Pensionierungszeitpunkten (Einzel/Paar/bereits pensioniert); letzter Teil immer offen | | `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 | | `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 | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario |
| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | | `migrations.test.ts` | 3 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein; prüft zusätzlich die V7-**Datenübernahme** (Basisszenario gewinnt, Pensionsalter bleiben szenario-eigen) |
| **Total** | **210** | | | **Total** | **212** | |
## 8.2 Testfälle ## 8.2 Testfälle
@@ -0,0 +1,71 @@
-- V7: Haushaltsform, Personen (Name/Alter) und Planstartjahr wandern auf PLAN-Ebene.
--
-- Begruendung (SPEZIFIKATION 2.1, 9.30): Diese Angaben beschreiben den HAUSHALT, nicht eine
-- Planungsvariante. Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario.
-- Solange sie am Szenario hingen, konnten zwei Szenarien desselben Plans verschiedene
-- Startjahre tragen; derselbe Ist-Satz waere dann je Szenario auf einem anderen Planjahr
-- gelandet.
--
-- Das PENSIONSALTER bleibt bewusst am Szenario -- es ist der Kern jedes
-- Frueh-/Spaetpensionierungs-Szenarios.
ALTER TABLE "Plan" ADD COLUMN "householdType" "HouseholdType";
ALTER TABLE "Plan" ADD COLUMN "startYear" INTEGER;
CREATE TABLE "PlanPerson" (
"id" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"role" "PersonRole" NOT NULL,
"name" TEXT,
"age" INTEGER NOT NULL,
CONSTRAINT "PlanPerson_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PlanPerson_planId_role_key" ON "PlanPerson"("planId", "role");
ALTER TABLE "PlanPerson" ADD CONSTRAINT "PlanPerson_planId_fkey"
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- --- Datenuebernahme aus dem BASISSZENARIO ------------------------------------------------
-- Das Basisszenario ist der kanonische Stand. Weichen Nebenszenarien ab, gewinnt die Basis.
UPDATE "Plan" p
SET "householdType" = s."householdType",
"startYear" = s."startYear"
FROM "Scenario" s
WHERE s."planId" = p."id" AND s."isBase" = true;
-- Sicherheitsnetz: Plaene ohne Basisszenario (sollte es nicht geben) nehmen irgendein Szenario.
UPDATE "Plan" p
SET "householdType" = s."householdType",
"startYear" = COALESCE(p."startYear", s."startYear")
FROM "Scenario" s
WHERE s."planId" = p."id" AND p."householdType" IS NULL;
INSERT INTO "PlanPerson" ("id", "planId", "role", "name", "age")
SELECT 'pp_' || pe."id", s."planId", pe."role", pe."name", pe."age"
FROM "Person" pe
JOIN "Scenario" s ON s."id" = pe."scenarioId"
WHERE s."isBase" = true
ON CONFLICT ("planId", "role") DO NOTHING;
-- Netz fuer Plaene, deren Basisszenario keine Personen traegt.
INSERT INTO "PlanPerson" ("id", "planId", "role", "name", "age")
SELECT DISTINCT ON (s."planId", pe."role") 'pp_' || pe."id", s."planId", pe."role", pe."name", pe."age"
FROM "Person" pe
JOIN "Scenario" s ON s."id" = pe."scenarioId"
ORDER BY s."planId", pe."role", s."isBase" DESC
ON CONFLICT ("planId", "role") DO NOTHING;
-- Ein Plan ohne Haushaltsform waere nicht rechenbar.
UPDATE "Plan" SET "householdType" = 'SINGLE' WHERE "householdType" IS NULL;
ALTER TABLE "Plan" ALTER COLUMN "householdType" SET NOT NULL;
-- --- Szenario-Ebene verschlanken ----------------------------------------------------------
-- Person traegt nur noch Rolle + Pensionsalter; Name und Alter kommen vom Plan.
ALTER TABLE "Person" DROP COLUMN "name";
ALTER TABLE "Person" DROP COLUMN "age";
ALTER TABLE "Scenario" DROP COLUMN "householdType";
ALTER TABLE "Scenario" DROP COLUMN "startYear";
+24 -6
View File
@@ -66,18 +66,31 @@ enum ElementCategory {
// Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter -- // Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter --
// dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich. // dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich.
// Szenario-EIGENE Angabe zu einer Person. Seit V7 nur noch das Pensionsalter -- es ist der
// Kern jedes Frueh-/Spaetpensionierungs-Szenarios. Name und Alter beschreiben den Haushalt
// und liegen deshalb am Plan (PlanPerson).
model Person { model Person {
id String @id @default(cuid()) id String @id @default(cuid())
scenarioId String scenarioId String
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
role PersonRole role PersonRole
name String?
age Int
retirementAge Int retirementAge Int
@@unique([scenarioId, role]) @@unique([scenarioId, role])
} }
// Eine Person des HAUSHALTS. Gilt fuer alle Szenarien des Plans.
model PlanPerson {
id String @id @default(cuid())
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
role PersonRole
name String?
age Int
@@unique([planId, role])
}
// Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien. // Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien.
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -85,11 +98,19 @@ model Plan {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String name String
// Seit V7 am Plan: Diese Angaben beschreiben den HAUSHALT, nicht eine Planungsvariante.
// Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario.
householdType HouseholdType
// Kalenderjahr, in dem die Planung beginnt (Jahr 1). Rein fuer die Darstellung --
// die Berechnung rechnet weiterhin in relativen Jahren ab Planbeginn.
startYear Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
scenarios Scenario[] scenarios Scenario[]
actuals ActualsSet[] actuals ActualsSet[]
persons PlanPerson[]
} }
// Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5). // Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5).
@@ -136,12 +157,9 @@ model Scenario {
parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull) parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull)
children Scenario[] @relation("ScenarioTree") children Scenario[] @relation("ScenarioTree")
householdType HouseholdType // Szenario-eigene Annahmen. Haushaltsform, Personen und Startjahr liegen seit V7 am Plan.
inflationRateDefault Float inflationRateDefault Float
initialCash Float @default(0) initialCash Float @default(0)
// Kalenderjahr, in dem die Planung beginnt (Jahr 1). Rein fuer die Darstellung --
// die Berechnung rechnet weiterhin in relativen Jahren ab Planbeginn.
startYear Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+9 -3
View File
@@ -64,18 +64,24 @@ export async function POST(request: NextRequest) {
const error = validatePersonsForType(parsed.data); const error = validatePersonsForType(parsed.data);
if (error) return NextResponse.json({ error }, { status: 400 }); if (error) return NextResponse.json({ error }, { status: 400 });
// Haushaltsform, Personen und Startjahr am PLAN; das Pensionsalter je Szenario.
const plan = await prisma.plan.create({ const plan = await prisma.plan.create({
data: { data: {
userId, userId,
name: parsed.data.name, name: parsed.data.name,
householdType: parsed.data.householdType,
startYear: parsed.data.startYear ?? new Date().getFullYear(),
persons: {
create: parsed.data.persons.map((p) => ({ role: p.role, name: p.name ?? null, age: p.age })),
},
scenarios: { scenarios: {
create: { create: {
name: "Basisszenario", name: "Basisszenario",
isBase: true, isBase: true,
householdType: parsed.data.householdType,
inflationRateDefault: parsed.data.inflationRateDefault, inflationRateDefault: parsed.data.inflationRateDefault,
startYear: parsed.data.startYear ?? new Date().getFullYear(), persons: {
persons: { create: parsed.data.persons }, create: parsed.data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
}, },
}, },
}, },
@@ -29,17 +29,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
name: parsed.data.name, name: parsed.data.name,
isBase: false, isBase: false,
parentScenarioId: source.id, parentScenarioId: source.id,
householdType: source.householdType, // Haushaltsform, Personen und Startjahr liegen am Plan und werden mitbenutzt, nicht
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
inflationRateDefault: source.inflationRateDefault, inflationRateDefault: source.inflationRateDefault,
initialCash: source.initialCash, initialCash: source.initialCash,
startYear: source.startYear,
persons: { persons: {
create: source.persons.map((p) => ({ create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
role: p.role,
name: p.name,
age: p.age,
retirementAge: p.retirementAge,
})),
}, },
}, },
}); });
+21 -3
View File
@@ -88,16 +88,34 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if ("householdType" in data) { if ("householdType" in data) {
const error = validatePersonsForType(data); const error = validatePersonsForType(data);
if (error) return NextResponse.json({ error }, { status: 400 }); if (error) return NextResponse.json({ error }, { status: 400 });
// Haushaltsform, Personen (Name/Alter) und Startjahr gehen an den PLAN -- sie gelten
// fuer alle Szenarien. Am Szenario bleiben Inflation und die Pensionsalter.
const updated = await prisma.$transaction(async (tx) => { const updated = await prisma.$transaction(async (tx) => {
await tx.plan.update({
where: { id: scenario.planId },
data: {
householdType: data.householdType,
startYear: data.startYear ?? undefined,
},
});
await tx.planPerson.deleteMany({ where: { planId: scenario.planId } });
await tx.planPerson.createMany({
data: data.persons.map((p) => ({
planId: scenario.planId,
role: p.role,
name: p.name ?? null,
age: p.age,
})),
});
await tx.person.deleteMany({ where: { scenarioId: scenario.id } }); await tx.person.deleteMany({ where: { scenarioId: scenario.id } });
return tx.scenario.update({ return tx.scenario.update({
where: { id: scenario.id }, where: { id: scenario.id },
data: { data: {
name: data.name ?? undefined, name: data.name ?? undefined,
householdType: data.householdType,
inflationRateDefault: data.inflationRateDefault, inflationRateDefault: data.inflationRateDefault,
startYear: data.startYear ?? undefined, persons: {
persons: { create: data.persons }, create: data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
},
}, },
}); });
}); });
+16 -7
View File
@@ -49,8 +49,17 @@ export function PlanProfileFields({
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
Nebenszenarios unbemerkt auch alle anderen. */}
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "}
<strong>Pensionsalter</strong> und die <strong>Inflation</strong>.
</p>
<SelectField <SelectField
label="Haushaltsform" label="Haushaltsform (plan-weit)"
help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?" help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
value={draft.householdType} value={draft.householdType}
onChange={setType} onChange={setType}
@@ -67,22 +76,22 @@ export function PlanProfileFields({
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<TextField <TextField
label="Name (optional)" label="Name (optional, plan-weit)"
value={person.name} value={person.name}
placeholder={person.role === "PERSON_A" ? "Person A" : "Person B"} placeholder={person.role === "PERSON_A" ? "Person A" : "Person B"}
onChange={(v) => updatePerson(index, { name: v })} onChange={(v) => updatePerson(index, { name: v })}
/> />
</div> </div>
<NumberField <NumberField
label="Aktuelles Alter" label="Aktuelles Alter (plan-weit)"
value={person.age} value={person.age}
min={0} min={0}
max={120} max={120}
onChange={(v) => updatePerson(index, { age: Math.round(v) })} onChange={(v) => updatePerson(index, { age: Math.round(v) })}
/> />
<NumberField <NumberField
label="Pensionierungsalter" label="Pensionierungsalter (nur dieses Szenario)"
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)." help="Steuert die Ableitung des Phasentyps (Erwerb/Pension). Als einzige Personenangabe szenario-eigen -- das ist der Kern jedes Früh-/Spätpensionierungs-Szenarios."
value={person.retirementAge} value={person.retirementAge}
min={30} min={30}
max={100} max={100}
@@ -92,7 +101,7 @@ export function PlanProfileFields({
))} ))}
<NumberField <NumberField
label="Planstart (Jahr)" label="Planstart (Jahr, plan-weit)"
help="Kalenderjahr, in dem die Planung beginnt (Jahr 1). Wird auf der Zeitachse und in den Grafiken angezeigt; die Berechnung selbst rechnet in Jahren ab Planbeginn." help="Kalenderjahr, in dem die Planung beginnt (Jahr 1). Wird auf der Zeitachse und in den Grafiken angezeigt; die Berechnung selbst rechnet in Jahren ab Planbeginn."
value={draft.startYear} value={draft.startYear}
min={1900} min={1900}
@@ -101,7 +110,7 @@ export function PlanProfileFields({
/> />
<NumberField <NumberField
label="Erwartete Inflationsrate (%)" label="Erwartete Inflationsrate (%, nur dieses Szenario)"
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen." help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
value={draft.inflationRateDefault} value={draft.inflationRateDefault}
step={0.1} step={0.1}
+114 -4
View File
@@ -48,18 +48,39 @@ describe("Datenbank-Migrationen", () => {
expect(await cols("Phase")).toContain("sourcePhaseId"); expect(await cols("Phase")).toContain("sourcePhaseId");
expect(await cols("FinancialElement")).toContain("sourceElementId"); expect(await cols("FinancialElement")).toContain("sourceElementId");
// Der Plan trägt keine Finanzdaten mehr. // --- V7: der HAUSHALT liegt am Plan, die ANNAHMEN am Szenario ---
const planCols = await cols("Plan"); const planCols = await cols("Plan");
expect(planCols).not.toContain("householdType");
expect(planCols).toContain("userId"); expect(planCols).toContain("userId");
for (const c of ["householdType", "startYear"]) {
expect(planCols, `Plan.${c} fehlt`).toContain(c);
}
expect(tables, "Tabelle PlanPerson fehlt").toContain("PlanPerson");
for (const c of ["planId", "role", "name", "age"]) {
expect(await cols("PlanPerson"), `PlanPerson.${c} fehlt`).toContain(c);
}
// Das Szenario trägt sie. // Das Szenario trägt nur noch die Annahmen -- Haushaltsform und Startjahr sind weg.
const scenCols = await cols("Scenario"); const scenCols = await cols("Scenario");
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) { for (const c of ["planId", "isBase", "parentScenarioId", "initialCash", "inflationRateDefault"]) {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c); expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
} }
expect(scenCols).not.toContain("householdType");
expect(scenCols).not.toContain("startYear");
expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan
// Person trägt nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt.
const personCols = await cols("Person");
expect(personCols).toContain("retirementAge");
expect(personCols).not.toContain("name");
expect(personCols).not.toContain("age");
// Ein Plan ohne Haushaltsform wäre nicht rechenbar.
const hh = await db.query<{ is_nullable: string }>(
`SELECT is_nullable FROM information_schema.columns
WHERE table_name='Plan' AND column_name='householdType'`
);
expect(hh.rows[0].is_nullable).toBe("NO");
// --- Versionierung --- // --- Versionierung ---
expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion"); expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion");
expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor"); expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor");
@@ -107,3 +128,92 @@ describe("Datenbank-Migrationen", () => {
expect(cashCol.rows[0].is_nullable).toBe("YES"); expect(cashCol.rows[0].is_nullable).toBe("YES");
}, 60000); }, 60000);
}); });
// Die V7-Migration verschiebt BESTEHENDE Daten (Haushaltsform, Personen, Startjahr) vom
// Szenario auf den Plan. Das Schema allein zu prüfen genügt dafür nicht -- ein Fehler im
// UPDATE/INSERT fiele erst auf, wenn live Daten verloren gingen.
describe("V7-Datenübernahme", () => {
const V7 = "20260720140000_plan_level_profile";
it("holt Haushaltsform, Startjahr und Personen aus dem Basisszenario an den Plan", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG)
.filter((d) => !d.endsWith(".toml"))
.sort();
// Alle Migrationen VOR V7 -- danach steht das alte V6-Schema.
for (const d of dirs.filter((d) => d < V7)) {
await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8"));
}
await db.exec(`
INSERT INTO "User" ("id","username","passwordHash") VALUES ('u1','kelle','x');
INSERT INTO "Plan" ("id","userId","name","updatedAt") VALUES ('p1','u1','Hauptplan',NOW());
-- Basisszenario: der kanonische Stand.
INSERT INTO "Scenario" ("id","planId","name","isBase","householdType","inflationRateDefault","initialCash","startYear","updatedAt")
VALUES ('s1','p1','Basis',true,'COUPLE',1.5,5000,2026,NOW());
INSERT INTO "Person" ("id","scenarioId","role","name","age","retirementAge")
VALUES ('pa','s1','PERSON_A','Anna',40,65), ('pb','s1','PERSON_B','Beat',38,64);
-- Nebenszenario mit ABWEICHENDEN Werten: Die Basis muss gewinnen.
INSERT INTO "Scenario" ("id","planId","name","isBase","parentScenarioId","householdType","inflationRateDefault","initialCash","startYear","updatedAt")
VALUES ('s2','p1','Frühpension',false,'s1','SINGLE',2.0,5000,2030,NOW());
INSERT INTO "Person" ("id","scenarioId","role","name","age","retirementAge")
VALUES ('pa2','s2','PERSON_A','Anna (alt)',99,60);
`);
await db.exec(readFileSync(path.join(MIG, V7, "migration.sql"), "utf8"));
const plan = (
await db.query<{ householdType: string; startYear: number }>(
`SELECT "householdType", "startYear" FROM "Plan" WHERE "id"='p1'`
)
).rows[0];
expect(plan.householdType).toBe("COUPLE"); // aus der Basis, nicht aus s2
expect(plan.startYear).toBe(2026);
const persons = (
await db.query<{ role: string; name: string; age: number }>(
`SELECT "role","name","age" FROM "PlanPerson" WHERE "planId"='p1' ORDER BY "role"`
)
).rows;
expect(persons).toEqual([
{ role: "PERSON_A", name: "Anna", age: 40 },
{ role: "PERSON_B", name: "Beat", age: 38 },
]);
// Die Pensionsalter bleiben SZENARIO-eigen -- das ist der Kern jedes
// Frühpensionierungs-Szenarios und darf nicht eingeebnet werden.
const retire = (
await db.query<{ scenarioId: string; role: string; retirementAge: number }>(
`SELECT "scenarioId","role","retirementAge" FROM "Person" ORDER BY "scenarioId","role"`
)
).rows;
expect(retire).toEqual([
{ scenarioId: "s1", role: "PERSON_A", retirementAge: 65 },
{ scenarioId: "s1", role: "PERSON_B", retirementAge: 64 },
{ scenarioId: "s2", role: "PERSON_A", retirementAge: 60 },
]);
}, 60000);
it("lässt keinen Plan ohne Haushaltsform zurück", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
for (const d of dirs.filter((d) => d < V7)) {
await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8"));
}
// Grenzfall: ein Plan ganz ohne Szenarien (sollte es nicht geben, darf die Migration
// aber nicht zum Absturz bringen -- die Spalte wird NOT NULL gesetzt).
await db.exec(`
INSERT INTO "User" ("id","username","passwordHash") VALUES ('u1','kelle','x');
INSERT INTO "Plan" ("id","userId","name","updatedAt") VALUES ('leer','u1','Leer',NOW());
`);
await db.exec(readFileSync(path.join(MIG, V7, "migration.sql"), "utf8"));
const row = (
await db.query<{ householdType: string }>(`SELECT "householdType" FROM "Plan" WHERE "id"='leer'`)
).rows[0];
expect(row.householdType).toBe("SINGLE");
}, 60000);
});
+19 -10
View File
@@ -5,6 +5,10 @@ import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elemen
import type { PlanInput } from "@/lib/types"; import type { PlanInput } from "@/lib/types";
export const planInclude = { export const planInclude = {
// Der Haushalt (Form, Personen, Startjahr) liegt seit V7 am PLAN -- `toPlanInput` fügt ihn
// mit den szenario-eigenen Pensionsaltern wieder zu einem `PlanInput` zusammen. Der
// Rechenkern sieht davon nichts.
plan: { include: { persons: { orderBy: { role: "asc" } } } },
persons: { orderBy: { role: "asc" } }, persons: { orderBy: { role: "asc" } },
phases: { orderBy: { sequenceNumber: "asc" } }, phases: { orderBy: { sequenceNumber: "asc" } },
elements: { elements: {
@@ -35,17 +39,22 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
return { return {
id: plan.id, id: plan.id,
name: plan.name, name: plan.name,
householdType: plan.householdType, householdType: plan.plan.householdType,
inflationRateDefault: plan.inflationRateDefault, inflationRateDefault: plan.inflationRateDefault,
initialCash: plan.initialCash, initialCash: plan.initialCash,
startYear: plan.startYear, startYear: plan.plan.startYear,
persons: plan.persons.map((p) => ({ // Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die
id: p.id, // Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern.
role: p.role, persons: plan.persons.map((p) => {
name: p.name, const hh = plan.plan.persons.find((x) => x.role === p.role);
age: p.age, return {
retirementAge: p.retirementAge, id: p.id,
})), role: p.role,
name: hh?.name ?? null,
age: hh?.age ?? 0,
retirementAge: p.retirementAge,
};
}),
phases: plan.phases.map((phase) => ({ phases: plan.phases.map((phase) => ({
id: phase.id, id: phase.id,
sequenceNumber: phase.sequenceNumber, sequenceNumber: phase.sequenceNumber,
@@ -88,7 +97,7 @@ export async function getOwnedScenario(scenarioId: string, userId: string) {
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) { export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({ return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } }, where: { id: scenarioId, plan: { userId } },
include: { ...planInclude, plan: true }, include: planInclude,
}); });
} }
+7 -6
View File
@@ -165,23 +165,24 @@ export async function restoreVersion(
const elementById = new Map(snap.elements.map((e) => [e.id, e])); const elementById = new Map(snap.elements.map((e) => [e.id, e]));
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Profil. // Profil. Seit V7 wird hier NUR das Szenario-Eigene zurueckgesetzt: Haushaltsform,
// Personennamen/-alter und Startjahr gehoeren dem Plan und gelten fuer alle Szenarien --
// sie ueber eine Version EINES Szenarios zu ueberschreiben, wuerde die anderen
// stillschweigend mitveraendern.
await tx.scenario.update({ await tx.scenario.update({
where: { id: scenarioId }, where: { id: scenarioId },
data: { data: {
householdType: snap.householdType,
inflationRateDefault: snap.inflationRateDefault, inflationRateDefault: snap.inflationRateDefault,
initialCash: snap.initialCash, initialCash: snap.initialCash,
startYear: snap.startYear ?? null,
}, },
}); });
// Personen: an der Rolle festgemacht (je Szenario eindeutig). // Pensionsalter: an der Rolle festgemacht (je Szenario eindeutig).
for (const p of snap.persons) { for (const p of snap.persons) {
await tx.person.upsert({ await tx.person.upsert({
where: { scenarioId_role: { scenarioId, role: p.role } }, where: { scenarioId_role: { scenarioId, role: p.role } },
create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge }, create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge },
update: { name: p.name, age: p.age, retirementAge: p.retirementAge }, update: { retirementAge: p.retirementAge },
}); });
} }
if (plan.deletePersonRoles.length > 0) { if (plan.deletePersonRoles.length > 0) {