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,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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user