Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s

Groesste Umstrukturierung bisher. Der PLAN ist neu ein schlanker Behaelter ohne
Finanzdaten; die berechenbare Einheit ist das SZENARIO.

Modell:
- Jeder Plan bekommt beim Anlegen automatisch ein Basisszenario (isBase).
- Das Grundprofil liegt am Szenario, nicht am Plan -- nur so sind Szenarien mit
  abweichendem PENSIONSALTER moeglich (Fruehpensionierung), das in Person steckt.
- Neue Szenarien sind vollstaendige Kopien eines BELIEBIGEN Szenarios und haengen
  als Baum darunter (parentScenarioId); die Seitenleiste rueckt sie ein.
- Kopierte Phasen/Elemente tragen Herkunfts-Verweise (sourcePhaseId,
  sourceElementId). Ueber den Namen zu matchen waere fragil gewesen.

Abweichungs-Markierung (Diff gegen das Eltern-Szenario, live):
- geaendert = gelb, neu = gruen + Badge, entfernt = graue Geisterzeile.
- Markiert: Phasen-/Uebergangszellen, Element-Zeilen, Phasenkoepfe, Cash-Anfangswert,
  Cash-Uebergaenge, Grundprofil. Zaehler ueber der Matrix.
- Eigene Theme-Tokens fuer Hell/Dunkel/Warm -- ein fester Gelbwert waere im
  Dunkelschema unbrauchbar.

Charts vergleichen neu die Geschwister-Szenarien statt fremder Plaene.

Datenmodell/Migration:
- Neue Tabelle Plan; bisheriger Plan -> Scenario (IDs erhalten, damit alle
  Kind-Fremdschluessel gueltig bleiben); planId -> scenarioId in Person/Phase/
  FinancialElement. Bestehende Szenarien werden per rekursivem CTE demselben
  Behaelter zugeordnet, auch mehrfach verschachtelte.
- Migration VOR dem Deploy gegen echtes PostgreSQL verifiziert (PGlite, in-process),
  inkl. verschachtelter Szenarien und Cascade. Der Test ist als
  migrations.test.ts committet und sichert kuenftige Migrationen ab.

API neu unter /api/scenarios/*. 10 neue Tests (48 -> 58). Spezifikation auf v0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 09:51:44 +02:00
parent 71137f7cea
commit a5d4868c58
25 changed files with 1336 additions and 486 deletions
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { computeScenarioDiff } from "@/lib/diff";
import type { PlanInput } from "@/lib/types";
// Basis: 1 Phase, 2 Elemente. Das Kind ist eine Kopie mit Herkunfts-Verweisen.
function base(): PlanInput {
return {
id: "S1", name: "Basisszenario", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
persons: [{ id: "pA", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "b-ph1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {}, sourcePhaseId: null }],
elements: [
{ id: "b-e1", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { "b-ph1": { amount: 90000 } }, transitionValues: {}, sourceElementId: null },
{ id: "b-e2", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { "b-ph1": { startValue: 100000, expectedReturn: 5 } }, transitionValues: { "b-ph1": { decision: "HOLD" } }, sourceElementId: null },
],
};
}
// Unveraenderte Kopie.
function copy(): PlanInput {
return {
id: "S2", name: "Variante", householdType: "SINGLE", inflationRateDefault: 1.5, initialCash: 1000,
persons: [{ id: "pA2", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [{ id: "c-ph1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {}, sourcePhaseId: "b-ph1" }],
elements: [
{ id: "c-e1", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1, phaseValues: { "c-ph1": { amount: 90000 } }, transitionValues: {}, sourceElementId: "b-e1" },
{ id: "c-e2", category: "OTHER_ASSET", name: "ETF", ownerRole: "HOUSEHOLD", orderIndex: 2, phaseValues: { "c-ph1": { startValue: 100000, expectedReturn: 5 } }, transitionValues: { "c-ph1": { decision: "HOLD" } }, sourceElementId: "b-e2" },
],
};
}
describe("Szenario-Diff", () => {
it("frische Kopie zeigt keine Abweichung", () => {
const d = computeScenarioDiff(copy(), base());
expect(d.total).toBe(0);
expect(d.phaseCell.size).toBe(0);
expect(d.profileChanged).toBe(false);
});
it("Basisszenario (ohne Elternteil) hat nie Abweichungen", () => {
expect(computeScenarioDiff(base(), null).total).toBe(0);
});
it("geaenderter Zellwert wird genau einer Zelle zugeordnet", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 120000 };
const d = computeScenarioDiff(c, base());
expect(d.phaseCell.has("c-e1:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(1);
expect(d.elementRow.size).toBe(0); // Zeile selbst unveraendert
});
it("geaenderter Uebergangs-Entscheid markiert die Uebergangszelle", () => {
const c = copy();
c.elements[1].transitionValues["c-ph1"] = { decision: "SELL" };
const d = computeScenarioDiff(c, base());
expect(d.transitionCell.has("c-e2:c-ph1")).toBe(true);
expect(d.phaseCell.size).toBe(0);
});
it("neues Element ist 'added', geloeschtes wird als entfernt gemeldet", () => {
const c = copy();
c.elements.push({ id: "c-e3", category: "OTHER_DEBT", name: "Kredit", ownerRole: "HOUSEHOLD", orderIndex: 3, phaseValues: {}, transitionValues: {}, sourceElementId: null });
c.elements = c.elements.filter((e) => e.id !== "c-e1"); // Lohn geloescht
const d = computeScenarioDiff(c, base());
expect(d.elementRow.get("c-e3")).toBe("added");
expect(d.removedElements.map((r) => r.name)).toEqual(["Lohn"]);
});
it("Phasenkopf: Dauer/Name geaendert bzw. Phase neu", () => {
const c = copy();
c.phases[0].durationYears = 15;
c.phases.push({ id: "c-ph2", sequenceNumber: 2, name: "Pension", durationYears: 25, cashTransition: {}, sourcePhaseId: null });
const d = computeScenarioDiff(c, base());
expect(d.phaseHeader.get("c-ph1")).toBe("changed");
expect(d.phaseHeader.get("c-ph2")).toBe("added");
});
it("Profil (Pensionsalter) und Cash-Anfangswert werden erkannt", () => {
const c = copy();
c.persons[0].retirementAge = 62;
c.initialCash = 5000;
const d = computeScenarioDiff(c, base());
expect(d.profileChanged).toBe(true);
expect(d.cashInitialChanged).toBe(true);
});
it("Cash-Uebergang wird erkannt", () => {
const c = copy();
c.phases[0].cashTransition = { mode: "INFLOW", inflowAmount: 100000 };
const d = computeScenarioDiff(c, base());
expect(d.cashTransitionCell.has("c-ph1")).toBe(true);
});
it("fehlendes Feld und 0 gelten als gleich (keine Falsch-Markierung)", () => {
const c = copy();
c.elements[0].phaseValues["c-ph1"] = { amount: 90000, teuerungsausgleich: 0 };
expect(computeScenarioDiff(c, base()).phaseCell.size).toBe(0);
});
});
+153
View File
@@ -0,0 +1,153 @@
// Abweichungs-Erkennung zwischen einem Szenario und seinem ELTERN-Szenario.
//
// Die Zuordnung laeuft ueber die Herkunfts-Verweise, die beim Kopieren gesetzt werden
// (Phase.sourcePhaseId, FinancialElement.sourceElementId). Ueber den Namen zu matchen waere
// fragil: Umbenennen wuerde die Verknuepfung brechen und gleichnamige Elemente kollidieren.
//
// Der Vergleich ist LIVE gegen den aktuellen Stand des Elternteils -- aendert man dort einen
// Wert, verschiebt sich die Markierung im Kind entsprechend.
import { num } from "@/lib/elements";
import type { PhaseData, TransitionData, CashTransitionData } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
export type DiffKind = "changed" | "added" | "removed";
export interface ScenarioDiff {
// elementId -> Status der ganzen Zeile ("added" = im Elternteil nicht vorhanden).
elementRow: Map<string, DiffKind>;
// `${elementId}:${phaseId}` -> Zelle weicht ab.
phaseCell: Set<string>;
// `${elementId}:${fromPhaseId}` -> Uebergangs-Zelle weicht ab.
transitionCell: Set<string>;
// phaseId -> Phasenkopf weicht ab (Name/Dauer) bzw. Phase ist neu.
phaseHeader: Map<string, DiffKind>;
// phaseId -> Cash-Uebergang nach dieser Phase weicht ab.
cashTransitionCell: Set<string>;
cashInitialChanged: boolean;
profileChanged: boolean;
// Im Elternteil vorhandene, hier geloeschte Elemente (fuer die Geisterzeilen).
removedElements: { id: string; name: string; category: ElementInput["category"] }[];
removedPhaseCount: number;
total: number;
}
export function emptyDiff(): ScenarioDiff {
return {
elementRow: new Map(),
phaseCell: new Set(),
transitionCell: new Set(),
phaseHeader: new Map(),
cashTransitionCell: new Set(),
cashInitialChanged: false,
profileChanged: false,
removedElements: [],
removedPhaseCount: 0,
total: 0,
};
}
// Vergleicht zwei JSON-Payloads feldweise; fehlend und 0 gelten als gleich, damit ein
// nicht gesetztes Feld nicht faelschlich als Aenderung erscheint.
function sameData(a: object = {}, b: object = {}): boolean {
const ra = a as Record<string, unknown>;
const rb = b as Record<string, unknown>;
const keys = new Set([...Object.keys(ra), ...Object.keys(rb)]);
for (const k of keys) {
const va = ra[k];
const vb = rb[k];
if (typeof va === "number" || typeof vb === "number") {
if (Math.round(num(va as number)) !== Math.round(num(vb as number))) return false;
} else if ((va ?? "") !== (vb ?? "")) {
return false;
}
}
return true;
}
export function computeScenarioDiff(scenario: PlanInput, base: PlanInput | null): ScenarioDiff {
const d = emptyDiff();
if (!base) return d;
// --- Phasen ---
const basePhaseById = new Map(base.phases.map((p) => [p.id, p]));
const usedBasePhases = new Set<string>();
// Zuordnung eigene Phase -> Eltern-Phase (fuer die Zellen-Vergleiche).
const phaseToBase = new Map<string, string>();
for (const ph of scenario.phases) {
const src = ph.sourcePhaseId ? basePhaseById.get(ph.sourcePhaseId) : undefined;
if (!src) {
d.phaseHeader.set(ph.id, "added");
continue;
}
usedBasePhases.add(src.id);
phaseToBase.set(ph.id, src.id);
if (ph.name !== src.name || ph.durationYears !== src.durationYears) {
d.phaseHeader.set(ph.id, "changed");
}
if (!sameData(ph.cashTransition as CashTransitionData, src.cashTransition as CashTransitionData)) {
d.cashTransitionCell.add(ph.id);
}
}
d.removedPhaseCount = base.phases.filter((p) => !usedBasePhases.has(p.id)).length;
// --- Elemente ---
const baseElById = new Map(base.elements.map((e) => [e.id, e]));
const usedBaseEls = new Set<string>();
for (const el of scenario.elements) {
const src = el.sourceElementId ? baseElById.get(el.sourceElementId) : undefined;
if (!src) {
d.elementRow.set(el.id, "added");
continue;
}
usedBaseEls.add(src.id);
if (el.name !== src.name || el.ownerRole !== src.ownerRole) {
d.elementRow.set(el.id, "changed");
}
// Zellen je Phase / Uebergang ueber die Phasen-Zuordnung vergleichen.
for (const ph of scenario.phases) {
const basePhaseId = phaseToBase.get(ph.id);
const mine = el.phaseValues[ph.id];
const theirs = basePhaseId ? src.phaseValues[basePhaseId] : undefined;
if (!basePhaseId) continue; // neue Phase -> Kopf ist bereits als "added" markiert
if (!sameData(mine as PhaseData, theirs as PhaseData)) {
d.phaseCell.add(`${el.id}:${ph.id}`);
}
const myT = el.transitionValues[ph.id];
const theirT = src.transitionValues[basePhaseId];
if (!sameData(myT as TransitionData, theirT as TransitionData)) {
d.transitionCell.add(`${el.id}:${ph.id}`);
}
}
}
d.removedElements = base.elements
.filter((e) => !usedBaseEls.has(e.id))
.map((e) => ({ id: e.id, name: e.name, category: e.category }));
// --- Profil und Cash-Anfangswert ---
d.cashInitialChanged = Math.round(scenario.initialCash) !== Math.round(base.initialCash);
const personKey = (p: PlanInput["persons"][number]) =>
`${p.role}|${p.name ?? ""}|${p.age}|${p.retirementAge}`;
d.profileChanged =
scenario.householdType !== base.householdType ||
scenario.inflationRateDefault !== base.inflationRateDefault ||
scenario.persons.map(personKey).sort().join(";") !== base.persons.map(personKey).sort().join(";");
d.total =
d.elementRow.size +
d.phaseCell.size +
d.transitionCell.size +
d.phaseHeader.size +
d.cashTransitionCell.size +
d.removedElements.length +
d.removedPhaseCount +
(d.cashInitialChanged ? 1 : 0) +
(d.profileChanged ? 1 : 0);
return d;
}
+63
View File
@@ -0,0 +1,63 @@
// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prueft das
// Ergebnis. Faengt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy
// gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfuegung.
import { describe, it, expect } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const MIG = path.join(process.cwd(), "prisma", "migrations");
describe("Datenbank-Migrationen", () => {
it("laufen vollstaendig durch und ergeben das erwartete Schema", async () => {
const db = await PGlite.create();
const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort();
expect(dirs.length).toBeGreaterThan(0);
for (const d of dirs) {
await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8"));
}
const tables = (
await db.query<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables WHERE table_schema='public'`
)
).rows.map((r) => r.table_name);
// V6-Struktur: Behaelter Plan + berechenbares Scenario.
for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) {
expect(tables, `Tabelle ${t} fehlt`).toContain(t);
}
const cols = async (table: string) =>
(
await db.query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns WHERE table_name=$1`,
[table]
)
).rows.map((r) => r.column_name);
// Kind-Tabellen haengen am Szenario, nicht mehr am Plan.
for (const t of ["Person", "Phase", "FinancialElement"]) {
const c = await cols(t);
expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId");
expect(c, `${t}.planId haette entfernt werden muessen`).not.toContain("planId");
}
// Herkunfts-Verweise fuer den Diff.
expect(await cols("Phase")).toContain("sourcePhaseId");
expect(await cols("FinancialElement")).toContain("sourceElementId");
// Der Plan traegt keine Finanzdaten mehr.
const planCols = await cols("Plan");
expect(planCols).not.toContain("householdType");
expect(planCols).toContain("userId");
// Das Szenario traegt sie.
const scenCols = await cols("Scenario");
for (const c of ["planId", "isBase", "parentScenarioId", "householdType", "initialCash"]) {
expect(scenCols, `Scenario.${c} fehlt`).toContain(c);
}
expect(scenCols).not.toContain("userId"); // Eigentuemer haengt am Plan
}, 60000);
});
+28 -6
View File
@@ -11,9 +11,9 @@ export const planInclude = {
orderBy: { orderIndex: "asc" },
include: { phaseValues: true, transitionValues: true },
},
} satisfies Prisma.PlanInclude;
} satisfies Prisma.ScenarioInclude;
export type PlanWithRelations = Prisma.PlanGetPayload<{ include: typeof planInclude }>;
export type PlanWithRelations = Prisma.ScenarioGetPayload<{ include: typeof planInclude }>;
function parsePhaseData(raw: unknown): PhaseData {
const parsed = phaseDataSchema.safeParse(raw);
@@ -30,6 +30,7 @@ function parseCashTransition(raw: unknown): CashTransitionData {
return parsed.success ? parsed.data : {};
}
// Wandelt ein Szenario (DB) in die berechenbare Einheit (PlanInput) um.
export function toPlanInput(plan: PlanWithRelations): PlanInput {
return {
id: plan.id,
@@ -50,6 +51,7 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
name: phase.name,
durationYears: phase.durationYears,
cashTransition: parseCashTransition(phase.cashTransition),
sourcePhaseId: phase.sourcePhaseId,
})),
elements: plan.elements.map((e) => {
const phaseValues: Record<string, PhaseData> = {};
@@ -64,29 +66,49 @@ export function toPlanInput(plan: PlanWithRelations): PlanInput {
orderIndex: e.orderIndex,
phaseValues,
transitionValues,
sourceElementId: e.sourceElementId,
};
}),
};
}
// Laedt einen Plan inkl. Profil + Phasen + Elemente, aber nur wenn er dem Benutzer gehoert.
// --- Ownership-Abfragen. Der Eigentuemer haengt neu am PLAN; Szenario/Phase/Element
// erben ihn ueber die Kette Scenario -> Plan -> User. ---
// Laedt ein Szenario inkl. Profil + Phasen + Elemente, nur wenn es dem Benutzer gehoert.
export async function getOwnedScenario(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
include: planInclude,
});
}
// Wie oben, zusaetzlich mit den Kopfdaten (Plan, Basis-Flag, Elternteil).
export async function getOwnedScenarioWithMeta(scenarioId: string, userId: string) {
return prisma.scenario.findFirst({
where: { id: scenarioId, plan: { userId } },
include: { ...planInclude, plan: true },
});
}
// Laedt einen Plan (Behaelter) inkl. Szenario-Kopfdaten.
export async function getOwnedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({
where: { id: planId, userId },
include: planInclude,
include: { scenarios: { orderBy: { createdAt: "asc" } } },
});
}
// Laedt eine Phase (Basisdaten), aber nur wenn sie dem Benutzer gehoert.
export async function getOwnedPhase(phaseId: string, userId: string) {
return prisma.phase.findFirst({
where: { id: phaseId, plan: { userId } },
where: { id: phaseId, scenario: { plan: { userId } } },
});
}
// Laedt ein Element (Basisdaten), aber nur wenn es dem Benutzer gehoert.
export async function getOwnedElement(elementId: string, userId: string) {
return prisma.financialElement.findFirst({
where: { id: elementId, plan: { userId } },
where: { id: elementId, scenario: { plan: { userId } } },
});
}
+25 -2
View File
@@ -23,6 +23,8 @@ export interface PhaseInput {
durationYears: number;
// Cash-Entscheid beim Uebergang NACH dieser Phase (einmalige Sonderein-/ausgaben).
cashTransition: CashTransitionData;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourcePhaseId?: string | null;
}
export interface ElementInput {
@@ -34,10 +36,31 @@ export interface ElementInput {
// Werte je Phase (Key = phaseId) bzw. je Uebergang (Key = fromPhaseId).
phaseValues: Record<string, PhaseData>;
transitionValues: Record<string, TransitionData>;
// Gegenstueck im Eltern-Szenario (Diff-Grundlage); null im Basisszenario.
sourceElementId?: string | null;
}
// Ein Plan ist selbsttragend: er traegt sein eigenes Grundprofil (Haushaltsform, Personen,
// Inflationsannahme) plus die Phasenkette und die finanziellen Elemente.
// Kopf-Daten eines Szenarios (fuer Baum und Auswahl in der Seitenleiste).
export interface ScenarioMeta {
id: string;
planId: string;
name: string;
isBase: boolean;
parentScenarioId: string | null;
}
// Ein Plan ist der Behaelter; er traegt nur den Namen und seine Szenarien.
export interface PlanListItem {
id: string;
name: string;
createdAt?: string;
scenarios: ScenarioMeta[];
}
// Die berechenbare Einheit (fachlich: ein SZENARIO). Sie ist selbsttragend und traegt ihr
// eigenes Grundprofil (Haushaltsform, Personen, Inflation, Cash) plus Phasen und Elemente.
// Der Name `PlanInput` ist historisch und bleibt, weil die ganze Berechnungsschicht darauf
// aufsetzt (computePlan, Monte Carlo, Tests).
export interface PlanInput {
id: string;
name: string;