Szenario-Hierarchie: Plan als Behaelter, Diff-Markierung (V6)
Deploy App / deploy (push) Successful in 1m43s
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:
@@ -0,0 +1,86 @@
|
||||
-- V6: Szenario-Hierarchie. Der bisherige "Plan" wird zum SZENARIO (IDs bleiben erhalten,
|
||||
-- damit alle Kind-Datensaetze gueltig bleiben); darueber entsteht ein neuer, schlanker PLAN
|
||||
-- als Behaelter. Bestehende Szenarien (bisher: Plan mit parentPlanId) werden als Baum unter
|
||||
-- denselben Plan gehaengt wie ihr Ursprung.
|
||||
|
||||
-- 1) Bisherige Plan-Tabelle wird zum Szenario. Kind-FKs folgen dem Rename automatisch.
|
||||
ALTER TABLE "Plan" RENAME TO "Scenario";
|
||||
ALTER TABLE "Scenario" RENAME COLUMN "parentPlanId" TO "parentScenarioId";
|
||||
ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_pkey" TO "Scenario_pkey";
|
||||
ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_parentPlanId_fkey" TO "Scenario_parentScenarioId_fkey";
|
||||
|
||||
ALTER TABLE "Scenario" ADD COLUMN "isBase" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Scenario" ADD COLUMN "planId" TEXT;
|
||||
|
||||
-- 2) Neuer Behaelter "Plan".
|
||||
CREATE TABLE "Plan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 3) Je Wurzel-Szenario (bisher: eigenstaendiger Plan) einen Behaelter anlegen. Die Plan-ID
|
||||
-- wird deterministisch aus der Szenario-ID abgeleitet, damit die Zuordnung ohne
|
||||
-- Hilfstabelle moeglich ist.
|
||||
INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt")
|
||||
SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP
|
||||
FROM "Scenario" s
|
||||
WHERE s."parentScenarioId" IS NULL;
|
||||
|
||||
-- 4) Wurzel-Szenarien werden zum Basisszenario ihres Behaelters. Der bisherige Plan-Name
|
||||
-- wandert auf den Behaelter (Schritt 3); das Szenario heisst neu "Basisszenario".
|
||||
UPDATE "Scenario" s
|
||||
SET "planId" = 'plan_' || s."id", "isBase" = true, "name" = 'Basisszenario'
|
||||
WHERE s."parentScenarioId" IS NULL;
|
||||
|
||||
-- 5) Kind-Szenarien erben den Behaelter ihrer Wurzel (beliebig tief verschachtelt).
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT "id", "planId" FROM "Scenario" WHERE "parentScenarioId" IS NULL
|
||||
UNION ALL
|
||||
SELECT c."id", t."planId" FROM "Scenario" c JOIN tree t ON c."parentScenarioId" = t."id"
|
||||
)
|
||||
UPDATE "Scenario" s
|
||||
SET "planId" = t."planId"
|
||||
FROM tree t
|
||||
WHERE s."id" = t."id" AND s."planId" IS NULL;
|
||||
|
||||
-- 6) Sicherheitsnetz: Szenarien, deren Elternteil fehlt (verwaist), werden eigenstaendig.
|
||||
INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt")
|
||||
SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP
|
||||
FROM "Scenario" s
|
||||
WHERE s."planId" IS NULL;
|
||||
|
||||
UPDATE "Scenario" s
|
||||
SET "planId" = 'plan_' || s."id", "isBase" = true, "parentScenarioId" = NULL
|
||||
WHERE s."planId" IS NULL;
|
||||
|
||||
-- 7) Beziehungen des Behaelters festziehen.
|
||||
ALTER TABLE "Scenario" ALTER COLUMN "planId" SET NOT NULL;
|
||||
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Scenario" ADD CONSTRAINT "Scenario_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 8) Eigentuemer haengt neu am Behaelter; die alten Felder am Szenario entfallen.
|
||||
ALTER TABLE "Scenario" DROP CONSTRAINT "Plan_userId_fkey";
|
||||
ALTER TABLE "Scenario" DROP COLUMN "userId";
|
||||
ALTER TABLE "Scenario" DROP COLUMN "branchFromPhaseId";
|
||||
|
||||
-- 9) Kind-Tabellen: planId -> scenarioId (inkl. Constraint- und Index-Namen).
|
||||
ALTER TABLE "Person" RENAME COLUMN "planId" TO "scenarioId";
|
||||
ALTER TABLE "Person" RENAME CONSTRAINT "Person_planId_fkey" TO "Person_scenarioId_fkey";
|
||||
ALTER INDEX "Person_planId_role_key" RENAME TO "Person_scenarioId_role_key";
|
||||
|
||||
ALTER TABLE "Phase" RENAME COLUMN "planId" TO "scenarioId";
|
||||
ALTER TABLE "Phase" RENAME CONSTRAINT "Phase_planId_fkey" TO "Phase_scenarioId_fkey";
|
||||
ALTER INDEX "Phase_planId_sequenceNumber_key" RENAME TO "Phase_scenarioId_sequenceNumber_key";
|
||||
|
||||
ALTER TABLE "FinancialElement" RENAME COLUMN "planId" TO "scenarioId";
|
||||
ALTER TABLE "FinancialElement" RENAME CONSTRAINT "FinancialElement_planId_fkey" TO "FinancialElement_scenarioId_fkey";
|
||||
|
||||
-- 10) Herkunfts-Verweise fuer die Abweichungs-Markierung (Diff gegen das Eltern-Szenario).
|
||||
ALTER TABLE "Phase" ADD COLUMN "sourcePhaseId" TEXT;
|
||||
ALTER TABLE "FinancialElement" ADD COLUMN "sourceElementId" TEXT;
|
||||
+59
-28
@@ -1,13 +1,21 @@
|
||||
// FPT (Financial Planning Tool) — Datenmodell.
|
||||
// Kernkonzept (Rework 07/2026): finanzielle Elemente leben auf PLAN-Ebene und sind
|
||||
// ueber alle Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase
|
||||
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
||||
//
|
||||
// Kernkonzept: finanzielle Elemente leben auf SZENARIO-Ebene und sind ueber alle
|
||||
// Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase ein
|
||||
// Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
||||
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
|
||||
// validiert und typisiert in der Applikationsschicht (lib/elements.ts).
|
||||
//
|
||||
// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom
|
||||
// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und
|
||||
// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme.
|
||||
// Rework 07/2026 (V6, Szenario-Hierarchie): Der PLAN ist neu ein schlanker Behaelter und
|
||||
// traegt selbst keine Finanzdaten. Die berechenbare Einheit ist das SZENARIO -- es traegt
|
||||
// das Grundprofil (Haushaltsform, Personen, Inflation, Cash-Anfangswert), die Phasenkette
|
||||
// und die Elemente. Jeder Plan hat genau ein Basisszenario (isBase) und beliebig viele
|
||||
// weitere Szenarien, die als Baum (parentScenarioId) darunter haengen. Kopierte Phasen und
|
||||
// Elemente tragen einen Herkunfts-Verweis (sourcePhaseId / sourceElementId) auf ihr
|
||||
// Gegenstueck im Eltern-Szenario -- darauf beruht die Abweichungs-Markierung im UI.
|
||||
//
|
||||
// Namens-Hinweis: In der Berechnungsschicht heisst die berechenbare Einheit weiterhin
|
||||
// `PlanInput` / `computePlan` (sie beschreibt eine Finanzplanung -- fachlich ein Szenario).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
@@ -54,35 +62,52 @@ enum ElementCategory {
|
||||
OTHER_DEBT
|
||||
}
|
||||
|
||||
// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter.
|
||||
// Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter --
|
||||
// dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich.
|
||||
model Person {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
role PersonRole
|
||||
name String?
|
||||
age Int
|
||||
retirementAge Int
|
||||
|
||||
@@unique([planId, role])
|
||||
@@unique([scenarioId, role])
|
||||
}
|
||||
|
||||
// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform,
|
||||
// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
|
||||
// Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien.
|
||||
model Plan {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
scenarios Scenario[]
|
||||
}
|
||||
|
||||
// Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je
|
||||
// Plan ist das Basisszenario (isBase); weitere haengen als Baum darunter (parentScenarioId).
|
||||
model Scenario {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
isBase Boolean @default(false)
|
||||
|
||||
// Szenario, aus dem dieses kopiert wurde. Zugleich die Vergleichsbasis fuer den Diff
|
||||
// (Basisszenario: null). Beim Loeschen des Elternteils bleibt der Baum flach erhalten.
|
||||
parentScenarioId String?
|
||||
parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull)
|
||||
children Scenario[] @relation("ScenarioTree")
|
||||
|
||||
householdType HouseholdType
|
||||
inflationRateDefault Float
|
||||
initialCash Float @default(0)
|
||||
|
||||
parentPlanId String?
|
||||
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
||||
scenarios Plan[] @relation("PlanScenarios")
|
||||
branchFromPhaseId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -95,13 +120,16 @@ model Plan {
|
||||
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
|
||||
// Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier.
|
||||
model Phase {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
sequenceNumber Int
|
||||
name String
|
||||
durationYears Int
|
||||
|
||||
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
|
||||
sourcePhaseId String?
|
||||
|
||||
// Entscheid fuer das Cash-Konto beim UEBERGANG NACH dieser Phase (JSON, validiert in
|
||||
// lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier
|
||||
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
|
||||
@@ -113,20 +141,23 @@ model Phase {
|
||||
phaseValues ElementPhaseValue[]
|
||||
transitionValues ElementTransitionValue[] @relation("TransitionFromPhase")
|
||||
|
||||
@@unique([planId, sequenceNumber])
|
||||
@@unique([scenarioId, sequenceNumber])
|
||||
}
|
||||
|
||||
// Ein finanzielles Element (plan-weit): Kategorie + optionale Personenzuordnung.
|
||||
// Ein finanzielles Element (szenario-weit): Kategorie + optionale Personenzuordnung.
|
||||
model FinancialElement {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
category ElementCategory
|
||||
name String
|
||||
ownerRole OwnerRole?
|
||||
orderIndex Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
|
||||
sourceElementId String?
|
||||
|
||||
phaseValues ElementPhaseValue[]
|
||||
transitionValues ElementTransitionValue[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user