Version A.B: B automatisch je Bearbeitungssitzung (10-Minuten-Fenster, unveraenderte Staende erzeugen keine), A manuell mit Pflichtkommentar. Eine Version haelt den vollstaendigen Zustand als PlanInput-JSON -- dadurch ist die Versionsauswahl in allen vier Analysewerkzeugen fast kostenlos, bei Monte-Carlo je Szenario einzeln. Wiederherstellen erhaelt die IDs (sonst verlieren Kind-Szenarien ihre Diff-Basis) und legt den Stand selbst als neue Version an. Wo ein Bezug trotzdem bricht, warnt der Dialog vorher namentlich. Statischer Waechter-Test: jeder schreibende Endpunkt loest eine Version aus. Migration gegen echtes Postgres verifiziert. Spezifikation 0.19 (3.8 und 9.28 neu), 25 Tests (139 -> 164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
// Datenbank-Anbindung der Versionierung. Die Entscheidungslogik (wann eine Version entsteht,
|
||||
// wie sie nummeriert wird) liegt in `versioning.ts` und ist dort ohne Datenbank getestet.
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import {
|
||||
assessRestore,
|
||||
decideVersion,
|
||||
nextMajor,
|
||||
planRestore,
|
||||
restoreComment,
|
||||
type LatestVersion,
|
||||
type RestoreImpact,
|
||||
type VersionRef,
|
||||
} from "@/lib/versioning";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const asJson = (plan: PlanInput) => plan as unknown as Prisma.InputJsonValue;
|
||||
|
||||
async function loadPlanInput(scenarioId: string): Promise<PlanInput | null> {
|
||||
const scenario = await prisma.scenario.findUnique({ where: { id: scenarioId }, include: planInclude });
|
||||
return scenario ? toPlanInput(scenario) : null;
|
||||
}
|
||||
|
||||
async function loadLatest(scenarioId: string): Promise<LatestVersion | null> {
|
||||
const v = await prisma.scenarioVersion.findFirst({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
});
|
||||
if (!v) return null;
|
||||
return {
|
||||
id: v.id,
|
||||
major: v.major,
|
||||
minor: v.minor,
|
||||
isMajor: v.isMajor,
|
||||
createdById: v.createdById,
|
||||
updatedAt: v.updatedAt,
|
||||
snapshot: v.snapshot as unknown as PlanInput,
|
||||
};
|
||||
}
|
||||
|
||||
// Wird nach JEDEM inhaltsveraendernden Schreibvorgang aufgerufen. Legt je nach Entscheidung
|
||||
// eine neue Nebenversion an, aktualisiert die laufende oder tut nichts.
|
||||
//
|
||||
// Bewusst fehlertolerant: Schlaegt das Festhalten der Version fehl, darf das die eigentliche
|
||||
// Aenderung des Nutzers nicht scheitern lassen -- die Historie ist Begleitinformation, nicht
|
||||
// der Zweck der Anfrage.
|
||||
export async function touchScenario(scenarioId: string, userId: string): Promise<void> {
|
||||
try {
|
||||
const next = await loadPlanInput(scenarioId);
|
||||
if (!next) return;
|
||||
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const decision = decideVersion(latest, next, userId, new Date());
|
||||
|
||||
if (decision.action === "skip") return;
|
||||
|
||||
if (decision.action === "update") {
|
||||
await prisma.scenarioVersion.update({
|
||||
where: { id: decision.versionId },
|
||||
data: { snapshot: asJson(next) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: decision.major,
|
||||
minor: decision.minor,
|
||||
createdById: userId,
|
||||
snapshot: asJson(next),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Versionierung fehlgeschlagen", { scenarioId, err });
|
||||
}
|
||||
}
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Anders als eine Nebenversion wird sie nie
|
||||
// zusammengefasst und nie nachtraeglich veraendert.
|
||||
export async function createMajorVersion(
|
||||
scenarioId: string,
|
||||
userId: string,
|
||||
comment: string
|
||||
): Promise<VersionRef | null> {
|
||||
const snapshot = await loadPlanInput(scenarioId);
|
||||
if (!snapshot) return null;
|
||||
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const ref = nextMajor(latest);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: ref.major,
|
||||
minor: ref.minor,
|
||||
comment: comment.trim(),
|
||||
isMajor: true,
|
||||
createdById: userId,
|
||||
snapshot: asJson(snapshot),
|
||||
},
|
||||
}),
|
||||
prisma.scenario.update({ where: { id: scenarioId }, data: { currentMajor: ref.major } }),
|
||||
]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
// Welche Bezuege wuerde ein Wiederherstellen in den Kind-Szenarien brechen? Wird vor dem
|
||||
// eigentlichen Wiederherstellen abgefragt, damit der Dialog warnen kann.
|
||||
export async function restoreImpact(scenarioId: string, snapshot: PlanInput): Promise<RestoreImpact> {
|
||||
const children = await prisma.scenario.findMany({
|
||||
where: { parentScenarioId: scenarioId },
|
||||
select: {
|
||||
name: true,
|
||||
elements: { select: { sourceElementId: true } },
|
||||
phases: { select: { sourcePhaseId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return assessRestore(
|
||||
snapshot,
|
||||
children.map((c) => ({
|
||||
name: c.name,
|
||||
elementSourceIds: c.elements.map((e) => e.sourceElementId).filter((x): x is string => !!x),
|
||||
phaseSourceIds: c.phases.map((p) => p.sourcePhaseId).filter((x): x is string => !!x),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf den Stand einer Version zurueck.
|
||||
//
|
||||
// IDs von Phasen und Elementen werden ERHALTEN (der Snapshot traegt sie mit). Wuerden hier
|
||||
// neue IDs entstehen, verloeren alle Kind-Szenarien ihre Diff-Basis: `sourceElementId` zeigt
|
||||
// auf die IDs dieses Szenarios, und jedes Element wuerde schlagartig als "neu" statt als
|
||||
// "geaendert" gelten.
|
||||
//
|
||||
// Das Wiederherstellen loescht KEINE Historie -- es legt anschliessend eine neue Version an.
|
||||
export async function restoreVersion(
|
||||
scenarioId: string,
|
||||
versionId: string,
|
||||
userId: string
|
||||
): Promise<VersionRef | null> {
|
||||
const version = await prisma.scenarioVersion.findFirst({ where: { id: versionId, scenarioId } });
|
||||
if (!version) return null;
|
||||
const snap = version.snapshot as unknown as PlanInput;
|
||||
|
||||
// Was zu tun ist, entscheidet die reine Funktion `planRestore` (dort getestet); hier wird
|
||||
// der Plan nur noch ausgefuehrt.
|
||||
const [curPhases, curElements, curPersons] = await Promise.all([
|
||||
prisma.phase.findMany({ where: { scenarioId }, select: { id: true } }),
|
||||
prisma.financialElement.findMany({ where: { scenarioId }, select: { id: true } }),
|
||||
prisma.person.findMany({ where: { scenarioId }, select: { role: true } }),
|
||||
]);
|
||||
const plan = planRestore(snap, {
|
||||
phaseIds: curPhases.map((p) => p.id),
|
||||
elementIds: curElements.map((e) => e.id),
|
||||
personRoles: curPersons.map((p) => p.role),
|
||||
});
|
||||
|
||||
const phaseById = new Map(snap.phases.map((p) => [p.id, p]));
|
||||
const elementById = new Map(snap.elements.map((e) => [e.id, e]));
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Profil.
|
||||
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).
|
||||
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 },
|
||||
});
|
||||
}
|
||||
if (plan.deletePersonRoles.length > 0) {
|
||||
await tx.person.deleteMany({
|
||||
where: { scenarioId, role: { in: plan.deletePersonRoles as never } },
|
||||
});
|
||||
}
|
||||
|
||||
// Was der alte Stand nicht kannte, verschwindet -- samt seiner Werte (Cascade).
|
||||
if (plan.deletePhaseIds.length > 0) {
|
||||
await tx.phase.deleteMany({ where: { id: { in: plan.deletePhaseIds } } });
|
||||
}
|
||||
if (plan.deleteElementIds.length > 0) {
|
||||
await tx.financialElement.deleteMany({ where: { id: { in: plan.deleteElementIds } } });
|
||||
}
|
||||
|
||||
// Sequenznummern zuerst auf negative Werte parken: Sie sind je Szenario eindeutig, und
|
||||
// beim Zurueckgehen koennen sich alte und neue Nummern ueberschneiden.
|
||||
for (const [i, id] of plan.parkPhaseIds.entries()) {
|
||||
await tx.phase.update({ where: { id }, data: { sequenceNumber: -1 - i } });
|
||||
}
|
||||
|
||||
const phaseData = (id: string) => {
|
||||
const ph = phaseById.get(id)!;
|
||||
return {
|
||||
sequenceNumber: ph.sequenceNumber,
|
||||
name: ph.name,
|
||||
durationYears: ph.durationYears,
|
||||
cashTransition: (ph.cashTransition ?? {}) as Prisma.InputJsonValue,
|
||||
sourcePhaseId: ph.sourcePhaseId ?? null,
|
||||
};
|
||||
};
|
||||
for (const id of plan.updatePhaseIds) await tx.phase.update({ where: { id }, data: phaseData(id) });
|
||||
for (const id of plan.createPhaseIds) {
|
||||
await tx.phase.create({ data: { id, scenarioId, ...phaseData(id) } });
|
||||
}
|
||||
|
||||
const elementData = (id: string) => {
|
||||
const el = elementById.get(id)!;
|
||||
return {
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole ?? null,
|
||||
orderIndex: el.orderIndex,
|
||||
sourceElementId: el.sourceElementId ?? null,
|
||||
};
|
||||
};
|
||||
for (const id of plan.updateElementIds) {
|
||||
await tx.financialElement.update({ where: { id }, data: elementData(id) });
|
||||
}
|
||||
for (const id of plan.createElementIds) {
|
||||
await tx.financialElement.create({ data: { id, scenarioId, ...elementData(id) } });
|
||||
}
|
||||
|
||||
// Werte vollstaendig ersetzen statt abgleichen -- der Snapshot ist die Wahrheit.
|
||||
const allElementIds = [...plan.updateElementIds, ...plan.createElementIds];
|
||||
if (allElementIds.length > 0) {
|
||||
await tx.elementPhaseValue.deleteMany({ where: { elementId: { in: allElementIds } } });
|
||||
await tx.elementTransitionValue.deleteMany({ where: { elementId: { in: allElementIds } } });
|
||||
}
|
||||
for (const { elementId, phaseId } of plan.phaseValueKeys) {
|
||||
await tx.elementPhaseValue.create({
|
||||
data: {
|
||||
elementId,
|
||||
phaseId,
|
||||
data: elementById.get(elementId)!.phaseValues[phaseId] as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const { elementId, fromPhaseId } of plan.transitionValueKeys) {
|
||||
await tx.elementTransitionValue.create({
|
||||
data: {
|
||||
elementId,
|
||||
fromPhaseId,
|
||||
data: elementById.get(elementId)!.transitionValues[fromPhaseId] as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Der wiederhergestellte Stand ist selbst eine neue Version -- mit Herkunftsvermerk, und
|
||||
// ohne dass irgendetwas aus der Historie verloren geht.
|
||||
const latest = await loadLatest(scenarioId);
|
||||
const current = await loadPlanInput(scenarioId);
|
||||
if (!current) return null;
|
||||
|
||||
const created = await prisma.scenarioVersion.create({
|
||||
data: {
|
||||
scenarioId,
|
||||
major: latest?.major ?? 1,
|
||||
minor: (latest?.minor ?? 0) + 1,
|
||||
comment: restoreComment({ major: version.major, minor: version.minor }),
|
||||
createdById: userId,
|
||||
snapshot: asJson(current),
|
||||
},
|
||||
});
|
||||
|
||||
return { major: created.major, minor: created.minor };
|
||||
}
|
||||
Reference in New Issue
Block a user