d023534a03
Deploy App / deploy (push) Successful in 1m48s
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>
284 lines
9.9 KiB
TypeScript
284 lines
9.9 KiB
TypeScript
// 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 };
|
|
}
|