diff --git a/SPEZIFIKATION.md b/SPEZIFIKATION.md index 1f28935..dca1561 100644 --- a/SPEZIFIKATION.md +++ b/SPEZIFIKATION.md @@ -4,7 +4,7 @@ | | | |---|---| | **Dokument** | Funktionale und Technische Spezifikation FPT | -| **Version** | 0.22 | +| **Version** | 0.23 | | **Datum** | 2026-07-18 | | **Status** | Lebendes Dokument | | **Codestand** | Arbeitsstand nach `1d046e9` inkl. zwei Monte-Carlo-Fragestellungen (Branch `main`) | @@ -17,6 +17,7 @@ | Version | Datum | Autor | Änderung | |---|---|---|---| +| 0.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.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//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. | @@ -2681,7 +2682,7 @@ Referenz: `src/lib/livesim.ts`, `src/lib/sensitivity.ts` (`applyElementDriver`, FPT/ ├── prisma/ │ ├── schema.prisma Datenmodell -│ └── migrations/ 14 Migrationen (chronologisch, siehe 5.4.6) +│ └── migrations/ 15 Migrationen (chronologisch, siehe 5.4.6) ├── src/ │ ├── app/ │ │ ├── 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 | | `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) | +| `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 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 | | `bridges.test.ts` | 10 | Vermögens- und Cash-Brücke gehen über sieben Plankonstellationen ohne Restgrösse auf; Umbuchungen bleiben aus der Vermögensbrücke heraus | | `diff.test.ts` | 9 | Abweichungs-Erkennung gegen das Eltern-Szenario | -| `migrations.test.ts` | 1 | spielt alle Migrationen gegen echtes PostgreSQL (PGlite) ein | -| **Total** | **210** | | +| `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** | **212** | | ## 8.2 Testfälle diff --git a/prisma/migrations/20260720140000_plan_level_profile/migration.sql b/prisma/migrations/20260720140000_plan_level_profile/migration.sql new file mode 100644 index 0000000..cd7a58c --- /dev/null +++ b/prisma/migrations/20260720140000_plan_level_profile/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20fed9f..3a9dee1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,18 +66,31 @@ enum ElementCategory { // Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter -- // 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 { id String @id @default(cuid()) scenarioId String scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) role PersonRole - name String? - age Int retirementAge Int @@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. model Plan { id String @id @default(cuid()) @@ -85,11 +98,19 @@ model Plan { user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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()) updatedAt DateTime @updatedAt scenarios Scenario[] actuals ActualsSet[] + persons PlanPerson[] } // 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) children Scenario[] @relation("ScenarioTree") - householdType HouseholdType + // Szenario-eigene Annahmen. Haushaltsform, Personen und Startjahr liegen seit V7 am Plan. inflationRateDefault Float 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()) updatedAt DateTime @updatedAt diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts index 22b0014..63ff2aa 100644 --- a/src/app/api/plans/route.ts +++ b/src/app/api/plans/route.ts @@ -64,18 +64,24 @@ export async function POST(request: NextRequest) { const error = validatePersonsForType(parsed.data); if (error) return NextResponse.json({ error }, { status: 400 }); + // Haushaltsform, Personen und Startjahr am PLAN; das Pensionsalter je Szenario. const plan = await prisma.plan.create({ data: { userId, 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: { create: { name: "Basisszenario", isBase: true, - householdType: parsed.data.householdType, inflationRateDefault: parsed.data.inflationRateDefault, - startYear: parsed.data.startYear ?? new Date().getFullYear(), - persons: { create: parsed.data.persons }, + persons: { + create: parsed.data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })), + }, }, }, }, diff --git a/src/app/api/scenarios/[scenarioId]/copy/route.ts b/src/app/api/scenarios/[scenarioId]/copy/route.ts index 14b43e4..61c66be 100644 --- a/src/app/api/scenarios/[scenarioId]/copy/route.ts +++ b/src/app/api/scenarios/[scenarioId]/copy/route.ts @@ -29,17 +29,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ name: parsed.data.name, isBase: false, 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, initialCash: source.initialCash, - startYear: source.startYear, persons: { - create: source.persons.map((p) => ({ - role: p.role, - name: p.name, - age: p.age, - retirementAge: p.retirementAge, - })), + create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })), }, }, }); diff --git a/src/app/api/scenarios/[scenarioId]/route.ts b/src/app/api/scenarios/[scenarioId]/route.ts index 9fbecf6..a31e9b2 100644 --- a/src/app/api/scenarios/[scenarioId]/route.ts +++ b/src/app/api/scenarios/[scenarioId]/route.ts @@ -88,16 +88,34 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< if ("householdType" in data) { const error = validatePersonsForType(data); 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) => { + 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 } }); return tx.scenario.update({ where: { id: scenario.id }, data: { name: data.name ?? undefined, - householdType: data.householdType, inflationRateDefault: data.inflationRateDefault, - startYear: data.startYear ?? undefined, - persons: { create: data.persons }, + persons: { + create: data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })), + }, }, }); }); diff --git a/src/components/PlanProfileFields.tsx b/src/components/PlanProfileFields.tsx index 6f4cffa..91ac000 100644 --- a/src/components/PlanProfileFields.tsx +++ b/src/components/PlanProfileFields.tsx @@ -49,8 +49,17 @@ export function PlanProfileFields({ return (
+ {/* 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. */} +

+ Haushaltsform, Personen und Planstart gelten für alle Szenarien dieses + Plans. Unterscheiden sie sich, ist es ein anderer Plan. Szenario-eigen sind nur das{" "} + Pensionsalter und die Inflation. +

+
updatePerson(index, { name: v })} />
updatePerson(index, { age: Math.round(v) })} /> { expect(await cols("Phase")).toContain("sourcePhaseId"); 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"); - expect(planCols).not.toContain("householdType"); 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"); - 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).not.toContain("householdType"); + expect(scenCols).not.toContain("startYear"); 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 --- expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion"); expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor"); @@ -107,3 +128,92 @@ describe("Datenbank-Migrationen", () => { expect(cashCol.rows[0].is_nullable).toBe("YES"); }, 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); +}); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 72ee21a..830b6c1 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -5,6 +5,10 @@ import type { CashTransitionData, PhaseData, TransitionData } from "@/lib/elemen import type { PlanInput } from "@/lib/types"; 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" } }, phases: { orderBy: { sequenceNumber: "asc" } }, elements: { @@ -35,17 +39,22 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput { return { id: plan.id, name: plan.name, - householdType: plan.householdType, + householdType: plan.plan.householdType, inflationRateDefault: plan.inflationRateDefault, initialCash: plan.initialCash, - startYear: plan.startYear, - persons: plan.persons.map((p) => ({ - id: p.id, - role: p.role, - name: p.name, - age: p.age, - retirementAge: p.retirementAge, - })), + startYear: plan.plan.startYear, + // Name und Alter vom Plan, Pensionsalter vom Szenario. Fehlt zu einer Rolle die + // Plan-Person, greift ein Notbehelf -- die Berechnung darf daran nicht scheitern. + persons: plan.persons.map((p) => { + const hh = plan.plan.persons.find((x) => x.role === p.role); + return { + id: p.id, + role: p.role, + name: hh?.name ?? null, + age: hh?.age ?? 0, + retirementAge: p.retirementAge, + }; + }), phases: plan.phases.map((phase) => ({ id: phase.id, sequenceNumber: phase.sequenceNumber, @@ -88,7 +97,7 @@ export async function getOwnedScenario(scenarioId: string, userId: string) { export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) { return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } }, - include: { ...planInclude, plan: true }, + include: planInclude, }); } diff --git a/src/lib/versioning-db.ts b/src/lib/versioning-db.ts index b6c8e60..34dc080 100644 --- a/src/lib/versioning-db.ts +++ b/src/lib/versioning-db.ts @@ -165,23 +165,24 @@ export async function restoreVersion( const elementById = new Map(snap.elements.map((e) => [e.id, e])); 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({ where: { id: scenarioId }, data: { - householdType: snap.householdType, inflationRateDefault: snap.inflationRateDefault, initialCash: snap.initialCash, - startYear: snap.startYear ?? null, }, }); - // Personen: an der Rolle festgemacht (je Szenario eindeutig). + // Pensionsalter: an der Rolle festgemacht (je Szenario eindeutig). for (const p of snap.persons) { await tx.person.upsert({ where: { scenarioId_role: { scenarioId, role: p.role } }, - create: { id: p.id, scenarioId, role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge }, - update: { name: p.name, age: p.age, retirementAge: p.retirementAge }, + create: { id: p.id, scenarioId, role: p.role, retirementAge: p.retirementAge }, + update: { retirementAge: p.retirementAge }, }); } if (plan.deletePersonRoles.length > 0) {