V7: Haushalt auf Plan-Ebene, Annahmen bleiben am Szenario
Deploy App / deploy (push) Successful in 1m52s
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:
+6
-4
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user