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:
@@ -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 })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,8 +49,17 @@ export function PlanProfileFields({
|
||||
|
||||
return (
|
||||
<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
|
||||
label="Haushaltsform"
|
||||
label="Haushaltsform (plan-weit)"
|
||||
help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
|
||||
value={draft.householdType}
|
||||
onChange={setType}
|
||||
@@ -67,22 +76,22 @@ export function PlanProfileFields({
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
label="Name (optional, plan-weit)"
|
||||
value={person.name}
|
||||
placeholder={person.role === "PERSON_A" ? "Person A" : "Person B"}
|
||||
onChange={(v) => updatePerson(index, { name: v })}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Aktuelles Alter"
|
||||
label="Aktuelles Alter (plan-weit)"
|
||||
value={person.age}
|
||||
min={0}
|
||||
max={120}
|
||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Pensionierungsalter"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)."
|
||||
label="Pensionierungsalter (nur dieses Szenario)"
|
||||
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}
|
||||
min={30}
|
||||
max={100}
|
||||
@@ -92,7 +101,7 @@ export function PlanProfileFields({
|
||||
))}
|
||||
|
||||
<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."
|
||||
value={draft.startYear}
|
||||
min={1900}
|
||||
@@ -101,7 +110,7 @@ export function PlanProfileFields({
|
||||
/>
|
||||
|
||||
<NumberField
|
||||
label="Erwartete Inflationsrate (%)"
|
||||
label="Erwartete Inflationsrate (%, nur dieses Szenario)"
|
||||
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
|
||||
value={draft.inflationRateDefault}
|
||||
step={0.1}
|
||||
|
||||
+114
-4
@@ -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);
|
||||
});
|
||||
|
||||
+19
-10
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user