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
+114 -4
View File
@@ -48,18 +48,39 @@ describe("Datenbank-Migrationen", () => {
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);
});