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
+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 } } },
});
}