PDF-Berichte (Roadmap 11)
Deploy App / deploy (push) Successful in 3m5s

Neuer Unterpunkt "Berichte" je Plan: Liste plus Assistent (Titel, Notiz,
nominal ODER real, Plan-/Ist-Daten, bis zu drei Szenarien, gespeicherte
Analysen). Layout immer gleich, Auswahl bestimmt nur die Bausteine.

Die PDF-Datei wird ALS DATEI abgelegt (BYTEA in Postgres, nicht im
Container-Dateisystem): Ein Bericht muss in drei Jahren byte-identisch
wieder herunterladbar sein -- eine Neuerzeugung koennte das nach
Aenderungen an Plan, Rechenkern oder Layout nicht garantieren.

Kennzahlen je Szenario inkl. offener Entscheide. Deren Zaehlung liegt neu
als reine Funktion in decisions.ts, die Matrix UND Bericht benutzen --
sonst nennen beide verschiedene Zahlen.

Zu jeder Kennzahl ihre Grundlage als Verweis; die vollstaendigen Annahmen
einmal je Szenario. Haftungsausschluss ist verpflichtend (per Test).

Technik: pdfkit in der Node-Runtime statt Headless-Browser;
@react-pdf/renderer bricht mit React 19. Als externes Paket deklariert,
weil pdfkit Font-Metriken ueber Dateipfade laedt.

Spezifikation 0.25 (3.11 neu), 9 Tests (212 -> 221).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 08:30:18 +02:00
parent 489390a4a3
commit d9ef980edf
17 changed files with 1988 additions and 47 deletions
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
export const runtime = "nodejs";
// Liefert die GESPEICHERTE PDF-Datei. Sie wird nicht neu erzeugt -- das ist der Kern des
// Audit-Trails: derselbe Bericht ergibt in drei Jahren dieselben Bytes.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; reportId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, reportId } = await params;
const report = await prisma.report.findFirst({
where: { id: reportId, planId, plan: { userId } },
select: { title: true, pdf: true, createdAt: true },
});
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
const date = report.createdAt.toISOString().slice(0, 10);
const safe = report.title.replace(/[^\w\s.-]/g, "").trim().replace(/\s+/g, "_") || "Bericht";
return new NextResponse(new Uint8Array(report.pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${date}_${safe}.pdf"`,
"Content-Length": String(report.pdf.length),
},
});
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ planId: string; reportId: string }> }
) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId, reportId } = await params;
const report = await prisma.report.findFirst({ where: { id: reportId, planId, plan: { userId } } });
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
await prisma.report.delete({ where: { id: reportId } });
return NextResponse.json({ ok: true });
}
+150
View File
@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getCurrentUserId } from "@/lib/session";
import { planInclude, toPlanInput } from "@/lib/queries";
import { buildReport, MAX_REPORT_SCENARIOS, type ReportConfig } from "@/lib/report";
import { renderReportPdf } from "@/lib/report-pdf";
import type { ActualsSetInput } from "@/lib/actuals";
// pdfkit braucht die Node-Runtime (Streams, Buffer) -- nicht Edge.
export const runtime = "nodejs";
const createSchema = z.object({
title: z.string().min(1).max(160),
metric: z.enum(["nominal", "real"]),
source: z.enum(["PLAN", "ACTUAL"]),
scenarioIds: z.array(z.string()).min(1).max(MAX_REPORT_SCENARIOS),
analysisIds: z.array(z.string()).max(20).default([]),
comment: z.string().max(500).nullish(),
});
async function ownedPlan(planId: string, userId: string) {
return prisma.plan.findFirst({ where: { id: planId, userId } });
}
// Liste der Berichte -- ohne die PDF-Bytes (die kommen erst beim Herunterladen).
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const plan = await ownedPlan(planId, userId);
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
const rows = await prisma.report.findMany({
where: { planId },
orderBy: { createdAt: "desc" },
select: {
id: true,
title: true,
config: true,
pdfBytes: true,
createdAt: true,
createdBy: { select: { username: true } },
},
});
return NextResponse.json({
reports: rows.map((r) => ({
id: r.id,
title: r.title,
config: r.config,
pdfBytes: r.pdfBytes,
createdAt: r.createdAt,
author: r.createdBy.username,
})),
});
}
// Erzeugt den Bericht: rechnet, baut das Modell, rendert das PDF und legt beides ab.
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
const userId = await getCurrentUserId();
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
const { planId } = await params;
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const cfg = parsed.data;
const plan = await prisma.plan.findFirst({
where: { id: planId, userId },
include: {
persons: { orderBy: { role: "asc" } },
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], include: planInclude },
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
},
});
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
// Nur Szenarien dieses Plans, in der Reihenfolge der Auswahl -- Basis zuerst.
const chosen = cfg.scenarioIds
.map((id) => plan.scenarios.find((s) => s.id === id))
.filter((s): s is (typeof plan.scenarios)[number] => !!s)
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
if (chosen.length === 0) {
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
}
const analyses =
cfg.analysisIds.length === 0
? []
: await prisma.savedAnalysis.findMany({
where: { id: { in: cfg.analysisIds }, planId },
orderBy: { createdAt: "asc" },
select: { name: true, type: true, result: true },
});
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
const config: ReportConfig = {
title: cfg.title,
metric: cfg.metric,
source: cfg.source,
scenarioIds: chosen.map((s) => s.id),
analysisIds: cfg.analysisIds,
comment: cfg.comment ?? null,
};
const model = buildReport({
planName: plan.name,
author: user?.username ?? "unbekannt",
createdAt: new Date(),
config,
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
household: {
householdType: plan.householdType,
startYear: plan.startYear,
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
},
actuals: plan.actuals.map(
(a): ActualsSetInput => ({
id: a.id,
recordedOn: a.recordedOn.toISOString().slice(0, 10),
year: a.year,
comment: a.comment,
cash: a.cash,
values: a.values as Record<string, { value?: number; mortgage?: number }>,
})
),
origins: plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId }))),
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
});
const pdf = await renderReportPdf(model);
const created = await prisma.report.create({
data: {
planId,
title: cfg.title,
config: config as unknown as object,
model: model as unknown as object,
pdf: new Uint8Array(pdf),
pdfBytes: pdf.length,
createdById: userId,
},
select: { id: true },
});
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
}
+19 -4
View File
@@ -9,6 +9,7 @@ import {
Copy,
Dices,
FileText,
FileSpreadsheet,
FolderKanban,
CalendarClock,
GitBranch,
@@ -33,6 +34,7 @@ import { LiveSimDialog } from "@/components/LiveSimDialog";
import { ActualsDialog } from "@/components/ActualsDialog";
import { PlanDashboardView, ScenarioListView, AnalysesView } from "@/components/PlanViews";
import { SavedAnalysisView } from "@/components/SavedAnalysisView";
import { ReportsView } from "@/components/ReportsView";
import { buildViews } from "@/lib/dataview";
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
import { VersionHistoryDialog } from "@/components/VersionHistoryDialog";
@@ -105,7 +107,7 @@ function AppShellInner({ username }: { username: string }) {
const [showHistory, setShowHistory] = useState(false);
const [showActuals, setShowActuals] = useState(false);
// Plan-Ebene: Dashboard / Szenarien-Liste / Analysen. Null = kein Plan-View aktiv.
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" } | null>(null);
const [planNav, setPlanNav] = useState<{ planId: string; tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports" } | null>(null);
// Welche Szenario-Bäume in der Seitenleiste aufgeklappt sind. Standard: eingeklappt.
const [expandedTrees, setExpandedTrees] = useState<Record<string, boolean>>({});
const [savedAnalysisId, setSavedAnalysisId] = useState<string | null>(null);
@@ -182,7 +184,7 @@ function AppShellInner({ username }: { username: string }) {
// Plan-Ebene öffnen (Dashboard / Szenarien / Analysen). Räumt die Szenario- und Wissens-
// Ansichten weg -- es kann immer nur eine Hauptansicht aktiv sein.
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses") {
function openPlanTab(planId: string, tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports") {
setPlanNav({ planId, tab });
setSelectedScenarioId(null);
setShowSpec(false);
@@ -347,7 +349,7 @@ function AppShellInner({ username }: { username: string }) {
{plans.map((p) => {
const navHere = planNav?.planId === p.id;
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses", label: string, Icon: typeof FolderKanban) => (
const subItem = (tab: "dashboard" | "scenarios" | "actuals" | "analyses" | "reports", label: string, Icon: typeof FolderKanban) => (
<button
type="button"
onClick={() => openPlanTab(p.id, tab)}
@@ -427,6 +429,7 @@ function AppShellInner({ username }: { username: string }) {
Effektive Werte
</button>
{subItem("analyses", "Analysen", BarChart3)}
{subItem("reports", "Berichte", FileSpreadsheet)}
</div>
);
})}
@@ -510,7 +513,11 @@ function AppShellInner({ username }: { username: string }) {
: showSpec
? "So rechnet FPT"
: planNav
? `${activePlan?.name ?? "Plan"} · ${planNav.tab === "dashboard" ? "Dashboard" : planNav.tab === "scenarios" ? "Szenarien" : "Analysen"}`
? `${activePlan?.name ?? "Plan"} · ${
{ dashboard: "Dashboard", scenarios: "Szenarien", actuals: "Effektive Werte", analyses: "Analysen", reports: "Berichte" }[
planNav.tab
]
}`
: selectedScenarioId && detail
? `${detail.meta.planName} · ${detail.meta.name}`
: "Übersicht"}
@@ -567,6 +574,14 @@ function AppShellInner({ username }: { username: string }) {
/>
)}
{!showSpec && !showSystemParams && planNav?.tab === "reports" && activePlan && (
<ReportsView
planId={planNav.planId}
scenarios={activePlan.scenarios.map((s) => ({ id: s.id, name: s.name, isBase: s.isBase }))}
hasActuals={(activePlan._count?.actuals ?? 0) > 0}
/>
)}
{!showSpec && !showSystemParams && !loading && !planNav && selectedScenarioId === null && (
<DashboardHome
username={username}
+12 -40
View File
@@ -27,6 +27,12 @@ import { Timeline } from "@/components/Timeline";
import { Sparkline } from "@/components/Sparkline";
import { CapitalDistributionDialog, RateDistributionDialog } from "@/components/DistributionDialogs";
import { capitalPot } from "@/lib/distribution";
import {
isRetirementTransition,
openTransitionCount,
transitionInactive as inactiveAtTransition,
TRANSITION_CATEGORIES,
} from "@/lib/decisions";
import { Tour, TOUR_DONE_KEY } from "@/components/Tour";
import { Button, EmptyState, InspectorShell, Modal, useConfirm, useToast } from "@/components/ui";
import { ElementDetailDialog, PhaseDetailDialog } from "@/components/DetailView";
@@ -75,15 +81,6 @@ const CATEGORY_ICON: Record<ElementCategory, React.ReactNode> = {
// Kategorien mit einem Übergangs-Entscheid. AHV ist dabei ein Sonderfall: nur beim
// Pensions-Übergang ist die Beitragskarriere zu prüfen (siehe transitionInactive).
const TRANSITION_CATEGORIES: ElementCategory[] = [
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
const VALUE_CATEGORIES: ElementCategory[] = [
"PENSION_FUND",
"PILLAR_3A",
@@ -244,12 +241,8 @@ export function PlanView({
return computed.phases.find((p) => p.id === phaseId)?.elements.find((e) => e.elementId === elementId);
}
function isRetirementTransition(element: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
return !!before?.working && !!after && !after.working;
}
// isRetirementTransition/transitionInactive/openTransitionCount kommen aus lib/decisions --
// der Bericht zaehlt mit derselben Regel (sonst meldet er andere Zahlen als die Matrix).
// Beitragskarriere des Element-Besitzers (nur für AHV relevant).
function careerFor(element: ElementInput) {
@@ -315,20 +308,8 @@ export function PlanView({
// Am Übergang nichts (mehr) zu tun: verkauft/getilgt ODER PK/3a nach der Pensionierung
// (Besitzer ist zu Beginn der Von-Phase bereits pensioniert -> bereits bezogen/verrentet)
// ODER AHV ausserhalb des Pensions-Übergangs.
function transitionInactive(el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed): boolean {
const ce = computedElement(fromPhase.id, el.id);
if (ce && ce.status !== "ACTIVE") return true;
if (el.category === "AHV") {
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
}
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
if (owner && !owner.working) return true;
}
}
return false;
}
const transitionInactive = (el: ElementInput, fromPhase: PhaseComputed, toPhase?: PhaseComputed) =>
inactiveAtTransition(computed, el, fromPhase, toPhase);
function cashTransitionFor(phaseId: string): CashTransitionData {
return plan.phases.find((p) => p.id === phaseId)?.cashTransition ?? {};
@@ -336,17 +317,8 @@ export function PlanView({
// Anzahl offener (noch nicht getroffener) Übergangs-Entscheide an einer Grenze.
// Der Cash-Entscheid (einmalige Sonderein-/ausgaben) zählt mit.
function transitionOpenCount(fromPhase: PhaseComputed, toPhase: PhaseComputed): number {
let n = isCashTransitionAnswered(cashTransitionFor(fromPhase.id)) ? 0 : 1;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(el, fromPhase, toPhase)) continue;
const td = el.transitionValues[fromPhase.id] ?? {};
const retire = toPhase ? isRetirementTransition(el, fromPhase, toPhase) : false;
if (!isTransitionAnswered(el.category, retire, td)) n++;
}
return n;
}
const transitionOpenCount = (fromPhase: PhaseComputed, toPhase: PhaseComputed) =>
openTransitionCount(plan, computed, fromPhase, toPhase);
// Ist der Übergangs-Entscheid dieses Elements noch offen?
function transitionUnanswered(el: ElementInput, fromPhase: PhaseComputed, toPhase: PhaseComputed): boolean {
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Download, FileText, Plus, Trash2 } from "lucide-react";
import { Button, useConfirm, useToast } from "@/components/ui";
import { InfoBubble } from "@/components/InfoBubble";
import { api } from "@/lib/api-client";
import { ANALYSIS_TYPE_LABEL, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
import { MAX_REPORT_SCENARIOS, type ReportMetric, type ReportSource } from "@/lib/report";
interface StoredReport {
id: string;
title: string;
config: { metric?: string; source?: string; scenarioIds?: string[]; analysisIds?: string[]; comment?: string | null };
pdfBytes: number;
createdAt: string;
author: string;
}
const dt = (iso: string) =>
new Date(iso).toLocaleString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
export function ReportsView({
planId,
scenarios,
hasActuals,
}: {
planId: string;
scenarios: { id: string; name: string; isBase: boolean }[];
hasActuals: boolean;
}) {
const [reports, setReports] = useState<StoredReport[] | null>(null);
const [analyses, setAnalyses] = useState<SavedAnalysisMeta[]>([]);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<"list" | "new">("list");
const [busy, setBusy] = useState(false);
const confirm = useConfirm();
const toast = useToast();
// Formular
const [title, setTitle] = useState("");
const [comment, setComment] = useState("");
const [metric, setMetric] = useState<ReportMetric>("nominal");
const [source, setSource] = useState<ReportSource>("PLAN");
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
const load = useCallback(async () => {
const [r, a] = await Promise.all([
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
]);
setReports(r.reports);
setAnalyses(a.analyses);
}, [planId]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [r, a] = await Promise.all([
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
]);
if (cancelled) return;
setReports(r.reports);
setAnalyses(a.analyses);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
}
})();
return () => {
cancelled = true;
};
}, [planId]);
function startNew() {
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
setTitle(`Finanzplanung ${new Date().getFullYear()}`);
setComment("");
setMetric("nominal");
setSource("PLAN");
setScenarioIds(base ? [base.id] : []);
setAnalysisIds([]);
setMode("new");
}
function toggleScenario(id: string) {
setScenarioIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= MAX_REPORT_SCENARIOS ? prev : [...prev, id]
);
}
async function create() {
setBusy(true);
try {
await api.post(`/api/plans/${planId}/reports`, {
title: title.trim() || "Finanzplanung",
metric,
source,
scenarioIds,
analysisIds,
comment: comment.trim() || undefined,
});
await load();
setMode("list");
toast("success", "Bericht erstellt.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Bericht konnte nicht erstellt werden.");
} finally {
setBusy(false);
}
}
async function remove(r: StoredReport) {
const ok = await confirm({
title: "Bericht löschen?",
message: `«${r.title}» wird endgültig entfernt. Die Datei lässt sich danach nicht mehr herunterladen.`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.delete(`/api/plans/${planId}/reports/${r.id}`);
await load();
toast("success", "Bericht gelöscht.");
} catch (e) {
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
}
}
if (mode === "new") {
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold text-fg">Neuer Bericht</h2>
<p className="text-sm text-muted">
Der Bericht wird als PDF erzeugt und abgelegt. Er hält den Stand von heute fest spätere Änderungen am
Plan verändern ihn nicht mehr.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-muted">Titel</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">Notiz auf dem Deckblatt (optional)</label>
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="z. B. «Stand nach Beratungsgespräch»"
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Werte</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{(["nominal", "real"] as ReportMetric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={`rounded-md px-2 py-0.5 font-medium ${metric === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"}`}
>
{m === "real" ? "Real" : "Nominal"}
</button>
))}
</div>
<InfoBubble text="Der Bericht führt durchgängig EINE Leitgrösse. Beides nebeneinander würde jede Tabellenspalte verdoppeln." />
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<span className="font-medium">Grundlage</span>
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
{(["PLAN", "ACTUAL"] as ReportSource[]).map((s) => (
<button
key={s}
type="button"
disabled={s === "ACTUAL" && !hasActuals}
onClick={() => setSource(s)}
title={s === "ACTUAL" && !hasActuals ? "Für diesen Plan sind keine effektiven Werte erfasst." : undefined}
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
source === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{s === "ACTUAL" ? "Effektiv" : "Plan"}
</button>
))}
</div>
</label>
</div>
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Szenarien (max. {MAX_REPORT_SCENARIOS})
<InfoBubble text="Mehr als drei Szenarien machen die Vergleichstabelle unlesbar. Das Basisszenario führt den Bericht an und liefert die Zusammenfassung." />
</div>
<div className="flex flex-col gap-2">
{scenarios.map((s) => {
const checked = scenarioIds.includes(s.id);
const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS;
return (
<label
key={s.id}
className={`flex items-center gap-2 rounded-xl border p-3 text-sm ${
checked ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
} ${full ? "opacity-40" : ""}`}
>
<input type="checkbox" checked={checked} disabled={full} onChange={() => toggleScenario(s.id)} />
<span className="font-medium text-fg">{s.name}</span>
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
</label>
);
})}
</div>
</div>
<div>
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
Gespeicherte Analysen einbinden
<InfoBubble text="Übernommen werden die beim Speichern eingefrorenen Zahlen es wird nichts neu gerechnet." />
</div>
{analyses.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten.
</p>
) : (
<div className="flex flex-col gap-1.5">
{analyses.map((a) => (
<label key={a.id} className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs">
<input
type="checkbox"
checked={analysisIds.includes(a.id)}
onChange={() =>
setAnalysisIds((prev) => (prev.includes(a.id) ? prev.filter((x) => x !== a.id) : [...prev, a.id]))
}
/>
<span className="font-medium text-fg">{a.name}</span>
<span className="text-faint">{ANALYSIS_TYPE_LABEL[a.type as AnalysisType]}</span>
</label>
))}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => setMode("list")}>
<ArrowLeft className="h-4 w-4" /> Zurück
</Button>
<Button disabled={busy || scenarioIds.length === 0} onClick={create}>
{busy ? "Wird erstellt…" : "Bericht erstellen"}
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-fg">Berichte</h2>
<Button onClick={startNew} disabled={scenarios.length === 0}>
<Plus className="h-4 w-4" /> Neuer Bericht
</Button>
</div>
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
Ein Bericht ist ein <strong className="text-fg">festes Dokument</strong>: Die erzeugte PDF-Datei wird abgelegt
und lässt sich jederzeit unverändert wieder herunterladen auch wenn du den Plan danach weiterentwickelst.
</p>
{error && <p className="text-xs text-danger">{error}</p>}
{!reports && !error && <p className="text-xs text-muted">Wird geladen</p>}
{reports && reports.length === 0 && (
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
Noch kein Bericht erstellt.
</p>
)}
{reports && reports.length > 0 && (
<div className="flex flex-col gap-2">
{reports.map((r) => (
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
<FileText className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-fg">{r.title}</div>
<div className="text-[11px] text-muted">
{dt(r.createdAt)} · {r.author} · {r.config.metric === "real" ? "real" : "nominal"} ·{" "}
{r.config.source === "ACTUAL" ? "effektiv" : "Plan"} · {(r.config.scenarioIds ?? []).length} Szenario
{(r.config.scenarioIds ?? []).length === 1 ? "" : "s"} · {Math.round(r.pdfBytes / 1024)} KB
</div>
</div>
<a
href={`/api/plans/${planId}/reports/${r.id}`}
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface hover:text-fg"
>
<Download className="h-3.5 w-3.5" /> PDF
</a>
<button
type="button"
onClick={() => remove(r)}
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
// Offene Übergangs-Entscheide (SPEZIFIKATION 3.5.4).
//
// Die Zählung lag bisher lokal in `PlanView`. Sie wird jetzt auch vom Bericht gebraucht --
// und zwei Umsetzungen derselben Regel driften garantiert auseinander. Deshalb hier als
// reine Funktion, die beide benutzen.
import { isCashTransitionAnswered, isTransitionAnswered } from "@/components/ElementDetail";
import type { ElementCategory } from "@/lib/elements";
import type { ElementInput, PlanInput } from "@/lib/types";
import type { PhaseComputed, PlanComputed } from "@/lib/calculations";
export const TRANSITION_CATEGORIES: ElementCategory[] = [
"AHV",
"PENSION_FUND",
"PILLAR_3A",
"REAL_ESTATE",
"OTHER_ASSET",
"OTHER_DEBT",
];
// Wechselt der Besitzer dieses Elements an DIESER Grenze in die Pension? Entscheidet, ob
// z. B. ein PK-Bezug überhaupt zur Wahl steht.
export function isRetirementTransition(
element: ElementInput,
fromPhase: PhaseComputed,
toPhase: PhaseComputed
): boolean {
if (!element.ownerRole || element.ownerRole === "HOUSEHOLD") return false;
const before = fromPhase.persons.find((p) => p.role === element.ownerRole);
const after = toPhase.persons.find((p) => p.role === element.ownerRole);
return !!before?.working && !!after && !after.working;
}
// Steht an dieser Grenze für dieses Element gar kein Entscheid an (bereits verkauft/getilgt,
// AHV ausserhalb des Pensions-Übergangs, PK/3a einer bereits pensionierten Person)?
export function transitionInactive(
computed: PlanComputed,
el: ElementInput,
fromPhase: PhaseComputed,
toPhase?: PhaseComputed
): boolean {
const ce = computed.phases.find((p) => p.id === fromPhase.id)?.elements.find((e) => e.elementId === el.id);
if (ce && ce.status !== "ACTIVE") return true;
if (el.category === "AHV") {
return !(toPhase && isRetirementTransition(el, fromPhase, toPhase));
}
if (el.category === "PENSION_FUND" || el.category === "PILLAR_3A") {
if (el.ownerRole && el.ownerRole !== "HOUSEHOLD") {
const owner = fromPhase.persons.find((p) => p.role === el.ownerRole);
if (owner && !owner.working) return true;
}
}
return false;
}
// Offene Entscheide an EINER Phasengrenze. Der Cash-Entscheid zählt mit.
export function openTransitionCount(
plan: PlanInput,
computed: PlanComputed,
fromPhase: PhaseComputed,
toPhase: PhaseComputed
): number {
const cash = plan.phases.find((p) => p.id === fromPhase.id)?.cashTransition ?? {};
let n = isCashTransitionAnswered(cash) ? 0 : 1;
for (const el of plan.elements) {
if (!TRANSITION_CATEGORIES.includes(el.category)) continue;
if (transitionInactive(computed, el, fromPhase, toPhase)) continue;
const td = el.transitionValues[fromPhase.id] ?? {};
if (!isTransitionAnswered(el.category, isRetirementTransition(el, fromPhase, toPhase), td)) n++;
}
return n;
}
// Summe über alle Phasengrenzen -- die aktionierbarste Kennzahl des ganzen Werkzeugs.
export function totalOpenDecisions(plan: PlanInput, computed: PlanComputed): number {
let n = 0;
for (let i = 0; i < computed.phases.length - 1; i++) {
n += openTransitionCount(plan, computed, computed.phases[i], computed.phases[i + 1]);
}
return n;
}
+13
View File
@@ -127,6 +127,19 @@ describe("Datenbank-Migrationen", () => {
);
expect(cashCol.rows[0].is_nullable).toBe("YES");
// --- Berichte: die PDF-Datei selbst muss abgelegt werden (Audit-Trail). ---
expect(tables, "Tabelle Report fehlt").toContain("Report");
const repCols = await cols("Report");
for (const c of ["planId", "title", "config", "model", "pdf", "pdfBytes", "createdById"]) {
expect(repCols, `Report.${c} fehlt`).toContain(c);
}
const pdfCol = await db.query<{ data_type: string; is_nullable: string }>(
`SELECT data_type, is_nullable FROM information_schema.columns
WHERE table_name='Report' AND column_name='pdf'`
);
expect(pdfCol.rows[0].data_type).toBe("bytea");
expect(pdfCol.rows[0].is_nullable).toBe("NO");
// --- Gespeicherte Analysen ---
expect(tables, "Tabelle SavedAnalysis fehlt").toContain("SavedAnalysis");
for (const c of ["planId", "name", "type", "metric", "source", "inputs", "result", "createdById"]) {
+357
View File
@@ -0,0 +1,357 @@
// Zeichnet ein ReportModel als PDF. Läuft ausschliesslich serverseitig (Node-Runtime).
//
// pdfkit statt Headless-Browser: kein 300-MB-Chromium im Container, echte Vektorausgabe,
// und weil das Layout bewusst IMMER gleich ist, kostet programmatisches Setzen nichts.
// @react-pdf/renderer schied aus -- es bricht mit React 19 / Next 16.
//
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
// Umlaute; ein Font-Embedding ist nicht nötig.
import PDFDocument from "pdfkit";
import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report";
const A4 = { width: 595.28, height: 841.89 };
const M = 56; // Seitenrand
const CONTENT = A4.width - 2 * M;
const COLORS = {
text: "#1f2937",
muted: "#6b7280",
faint: "#9ca3af",
accent: "#4f46e5",
danger: "#dc2626",
success: "#16a34a",
line: "#e5e7eb",
band: "#f9fafb",
};
const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"];
type Doc = InstanceType<typeof PDFDocument>;
// --- Grundbausteine ----------------------------------------------------------------------
function ensureSpace(doc: Doc, needed: number) {
if (doc.y + needed > A4.height - M - 24) doc.addPage();
}
function h1(doc: Doc, text: string) {
ensureSpace(doc, 40);
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(16).text(text, M, doc.y);
doc.moveDown(0.4);
}
function h2(doc: Doc, text: string) {
ensureSpace(doc, 34);
doc.moveDown(0.5);
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(11.5).text(text, M, doc.y);
doc
.moveTo(M, doc.y + 2)
.lineTo(M + CONTENT, doc.y + 2)
.strokeColor(COLORS.line)
.lineWidth(0.8)
.stroke();
doc.moveDown(0.5);
}
function body(doc: Doc, text: string, opts: { color?: string; size?: number } = {}) {
ensureSpace(doc, 26);
doc
.fillColor(opts.color ?? COLORS.muted)
.font("Helvetica")
.fontSize(opts.size ?? 9.5)
.text(text, M, doc.y, { width: CONTENT, align: "left" });
doc.moveDown(0.3);
}
// Kennzahlen als Kästchen, drei je Reihe. Die Basis-Zeile darunter beantwortet die Frage
// «mit welcher Annahme wurde das gerechnet» -- ohne den Annahmen-Abschnitt zu wiederholen.
function keyFigures(doc: Doc, figures: KeyFigure[]) {
const perRow = 3;
const gap = 10;
const w = (CONTENT - gap * (perRow - 1)) / perRow;
for (let i = 0; i < figures.length; i += perRow) {
const row = figures.slice(i, i + perRow);
const h = 56;
ensureSpace(doc, h + 8);
const top = doc.y;
row.forEach((f, j) => {
const x = M + j * (w + gap);
doc.roundedRect(x, top, w, h, 4).fillColor(COLORS.band).fill();
doc.roundedRect(x, top, w, h, 4).strokeColor(COLORS.line).lineWidth(0.8).stroke();
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(7).text(f.label.toUpperCase(), x + 8, top + 7, { width: w - 16 });
doc
.fillColor(f.tone === "danger" ? COLORS.danger : f.tone === "success" ? COLORS.success : COLORS.text)
.font("Helvetica-Bold")
.fontSize(12)
.text(f.value, x + 8, top + 19, { width: w - 16, lineBreak: false });
if (f.basis) {
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(6.5).text(f.basis, x + 8, top + 36, { width: w - 16, height: 16 });
}
});
doc.y = top + h + 8;
}
}
function table(doc: Doc, t: ReportTable, opts: { firstColWidth?: number } = {}) {
const cols = t.columns.length;
const firstW = opts.firstColWidth ?? Math.max(120, CONTENT * 0.32);
const restW = (CONTENT - firstW) / Math.max(1, cols - 1);
const colX = (i: number) => (i === 0 ? M : M + firstW + (i - 1) * restW);
const colW = (i: number) => (i === 0 ? firstW : restW);
const header = () => {
const top = doc.y;
doc.rect(M, top, CONTENT, 18).fillColor(COLORS.band).fill();
t.columns.forEach((c, i) => {
doc
.fillColor(COLORS.muted)
.font("Helvetica-Bold")
.fontSize(7.5)
.text(c, colX(i) + 4, top + 5.5, { width: colW(i) - 8, align: i === 0 ? "left" : "right", lineBreak: false });
});
doc.y = top + 18;
};
ensureSpace(doc, 40);
header();
for (const row of t.rows) {
if (doc.y + 16 > A4.height - M - 24) {
doc.addPage();
header();
}
const top = doc.y;
row.forEach((cell, i) => {
doc
.fillColor(i === 0 ? COLORS.text : COLORS.muted)
.font(i === 0 ? "Helvetica-Bold" : "Helvetica")
.fontSize(8)
.text(String(cell), colX(i) + 4, top + 4, { width: colW(i) - 8, align: i === 0 ? "left" : "right", lineBreak: false });
});
doc
.moveTo(M, top + 15)
.lineTo(M + CONTENT, top + 15)
.strokeColor(COLORS.line)
.lineWidth(0.5)
.stroke();
doc.y = top + 16;
}
doc.moveDown(0.5);
}
function definitionList(doc: Doc, rows: [string, string][]) {
for (const [k, v] of rows) {
ensureSpace(doc, 16);
const top = doc.y;
doc.fillColor(COLORS.muted).font("Helvetica").fontSize(8.5).text(k, M, top, { width: CONTENT * 0.38, lineBreak: false });
doc
.fillColor(COLORS.text)
.font("Helvetica-Bold")
.fontSize(8.5)
.text(v, M + CONTENT * 0.38, top, { width: CONTENT * 0.62 });
doc.y = Math.max(doc.y, top + 13);
}
doc.moveDown(0.4);
}
// Liniendiagramm als echte Vektoren -- genau der Grund, warum die gespeicherten Analysen
// Zahlen und keine Bilder sind (druckscharf in jeder Auflösung).
function lineChart(doc: Doc, chart: ReportChart) {
const h = 170;
ensureSpace(doc, h + 40);
const top = doc.y + 4;
const plotX = M + 46;
const plotW = CONTENT - 46;
const plotH = h - 26;
const all = chart.series.flatMap((s) => s.points);
if (all.length === 0) return;
const xs = all.map((p) => p.x);
const ys = all.map((p) => p.y);
const xMin = Math.min(...xs);
const xMax = Math.max(...xs);
const yMin = Math.min(0, Math.min(...ys));
const yMax = Math.max(...ys, 1);
const sx = (x: number) => plotX + ((x - xMin) / Math.max(1, xMax - xMin)) * plotW;
const sy = (y: number) => top + plotH - ((y - yMin) / Math.max(1, yMax - yMin)) * plotH;
// Gitter und y-Beschriftung (kompakt, in Tausend/Millionen).
const fmt = (v: number) =>
Math.abs(v) >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)} Mio` : `${Math.round(v / 1000)}k`;
for (let i = 0; i <= 4; i++) {
const val = yMin + ((yMax - yMin) * i) / 4;
const y = sy(val);
doc.moveTo(plotX, y).lineTo(plotX + plotW, y).strokeColor(COLORS.line).lineWidth(0.5).stroke();
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(6.5).text(fmt(val), M, y - 3, { width: 42, align: "right" });
}
chart.series.forEach((s, i) => {
const color = SERIES_COLORS[i % SERIES_COLORS.length];
doc.strokeColor(color).lineWidth(1.4);
if (s.dashed) doc.dash(3, { space: 2 });
s.points.forEach((p, k) => (k === 0 ? doc.moveTo(sx(p.x), sy(p.y)) : doc.lineTo(sx(p.x), sy(p.y))));
doc.stroke();
doc.undash();
});
// x-Achse
doc.fillColor(COLORS.faint).font("Helvetica").fontSize(6.5);
doc.text(`${chart.xLabel} ${xMin}`, plotX, top + plotH + 4, { width: 80 });
doc.text(`${xMax}`, plotX + plotW - 40, top + plotH + 4, { width: 40, align: "right" });
// Legende
let lx = plotX;
const ly = top + plotH + 14;
chart.series.forEach((s, i) => {
const color = SERIES_COLORS[i % SERIES_COLORS.length];
doc.rect(lx, ly + 2, 8, 2).fillColor(color).fill();
doc.fillColor(COLORS.muted).font("Helvetica").fontSize(7).text(s.label, lx + 12, ly, { width: 140, lineBreak: false });
lx += 12 + Math.min(140, doc.widthOfString(s.label)) + 14;
});
doc.y = ly + 16;
}
// --- Bericht -----------------------------------------------------------------------------
export function renderReportPdf(model: ReportModel): Promise<Buffer> {
const doc = new PDFDocument({
size: "A4",
margins: { top: M, bottom: M, left: M, right: M },
// Nötig für die Fusszeile: Seitenzahlen lassen sich erst nachträglich setzen, wenn die
// Gesamtzahl feststeht -- dafür müssen die Seiten gepuffert bleiben.
bufferPages: true,
info: { Title: model.meta.title, Author: "FPT", Subject: `Finanzplanung ${model.meta.planName}` },
});
const chunks: Buffer[] = [];
const done = new Promise<Buffer>((resolve, reject) => {
doc.on("data", (c: Buffer) => chunks.push(c));
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
const created = new Date(model.meta.createdAt).toLocaleDateString("de-CH", {
day: "2-digit",
month: "long",
year: "numeric",
});
// --- Seite 1: Deckblatt + Zusammenfassung ---
doc.fillColor(COLORS.accent).font("Helvetica-Bold").fontSize(9).text("FINANZPLANUNG", M, M);
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(22).text(model.meta.title, M, doc.y + 4, { width: CONTENT });
doc.fillColor(COLORS.muted).font("Helvetica").fontSize(10).text(model.meta.planName, M, doc.y + 2);
doc
.fillColor(COLORS.faint)
.fontSize(8.5)
.text(
`Erstellt am ${created} von ${model.meta.author} · Werte ${model.meta.metricLabel} · Grundlage: ${model.meta.sourceLabel}`,
M,
doc.y + 4,
{ width: CONTENT }
);
if (model.meta.comment) {
doc.fillColor(COLORS.muted).font("Helvetica-Oblique").fontSize(9).text(model.meta.comment, M, doc.y + 6, { width: CONTENT });
}
doc.moveDown(1);
h2(doc, "Das Wichtigste in Kürze");
keyFigures(doc, model.summary.figures);
for (const s of model.summary.statements) {
ensureSpace(doc, 22);
doc.fillColor(COLORS.accent).font("Helvetica-Bold").fontSize(9).text("•", M, doc.y, { width: 10, lineBreak: false });
doc.fillColor(COLORS.text).font("Helvetica").fontSize(9).text(s, M + 12, doc.y, { width: CONTENT - 12 });
doc.moveDown(0.25);
}
// --- Ausgangslage ---
h2(doc, "Ausgangslage");
definitionList(doc, model.household.rows);
// --- Je Szenario ---
for (const s of model.scenarios) {
doc.addPage();
h1(doc, `Szenario «${s.name}»${s.isBase ? " (Basis)" : ""}`);
keyFigures(doc, s.keyFigures);
h2(doc, s.chart.title);
lineChart(doc, s.chart);
h2(doc, "Lebensphasen");
table(doc, s.phases);
h2(doc, "Annahmen, mit denen gerechnet wurde");
body(
doc,
"Alle Kennzahlen oben beruhen auf diesen Werten. Ändert sich eine Annahme, ändert sich das Ergebnis."
);
for (const g of s.assumptions) {
ensureSpace(doc, 24);
doc.fillColor(COLORS.text).font("Helvetica-Bold").fontSize(9).text(g.title, M, doc.y);
doc.moveDown(0.2);
definitionList(doc, g.rows);
}
}
// --- Vergleich ---
if (model.comparison) {
doc.addPage();
h1(doc, "Szenario-Vergleich");
body(doc, "Dieselben Kennzahlen über alle gewählten Szenarien -- unter identischen Annahmen gerechnet.");
table(doc, model.comparison);
}
// --- Plan/Ist ---
if (model.actuals) {
h2(doc, "Plan gegenüber effektiven Werten");
definitionList(doc, model.actuals.rows);
body(doc, model.actuals.note, { size: 8 });
}
// --- Gespeicherte Analysen ---
if (model.analyses.length > 0) {
doc.addPage();
h1(doc, "Analysen");
body(doc, "Festgehaltene Auswertungen zum Zeitpunkt ihrer Erstellung. Es wurde nichts neu gerechnet.");
for (const a of model.analyses) {
h2(doc, a.name);
if (a.params.length > 0) definitionList(doc, a.params);
if (a.table) table(doc, a.table);
}
}
// --- Anhang ---
doc.addPage();
h1(doc, "Wichtige Hinweise");
for (const d of model.disclaimer) {
ensureSpace(doc, 30);
doc.fillColor(COLORS.text).font("Helvetica").fontSize(9).text(d, M, doc.y, { width: CONTENT });
doc.moveDown(0.5);
}
// Fusszeile mit Seitenzahlen auf allen Seiten.
const range = doc.bufferedPageRange();
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
doc
.fillColor(COLORS.faint)
.font("Helvetica")
.fontSize(7)
.text(
`${model.meta.planName} · ${model.meta.title} · ${created}`,
M,
A4.height - M + 12,
{ width: CONTENT - 40, lineBreak: false }
);
doc.text(`${i - range.start + 1} / ${range.count}`, M + CONTENT - 40, A4.height - M + 12, {
width: 40,
align: "right",
lineBreak: false,
});
}
doc.end();
return done;
}
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from "vitest";
import { buildReport, MAX_REPORT_SCENARIOS } from "@/lib/report";
import { renderReportPdf } from "@/lib/report-pdf";
import type { ActualsSetInput } from "@/lib/actuals";
import type { PlanInput } from "@/lib/types";
// Paar, 45/43, 20 Jahre Erwerb + 20 Jahre Pension, PK + ETF-Depot.
function plan(name: string, ret = 4): PlanInput {
return {
id: "s1",
name,
householdType: "COUPLE",
inflationRateDefault: 1.5,
initialCash: 20000,
startYear: 2026,
persons: [
{ id: "A", role: "PERSON_A", name: "Anna", age: 45, retirementAge: 65 },
{ id: "B", role: "PERSON_B", name: "Beat", age: 43, retirementAge: 64 },
],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { amount: 140000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null,
},
{
id: "exp", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { amount: 90000 }, p2: { amount: 80000 } }, transitionValues: {}, sourceElementId: null,
},
{
id: "pk", category: "PENSION_FUND", name: "Pensionskasse", ownerRole: "PERSON_A", orderIndex: 3,
phaseValues: { p1: { currentValue: 320000, expectedReturn: 2, annualContribution: 12000 } },
transitionValues: {}, sourceElementId: null,
},
{
id: "etf", category: "OTHER_ASSET", name: "ETF-Depot", ownerRole: "HOUSEHOLD", orderIndex: 4,
phaseValues: {
p1: { startValue: 180000, expectedReturn: ret, annualContribution: 9000 },
p2: { expectedReturn: ret, annualWithdrawal: 24000 },
},
transitionValues: {}, sourceElementId: null,
},
],
} as unknown as PlanInput;
}
function input(over: Partial<Parameters<typeof buildReport>[0]> = {}) {
return {
planName: "Meine Planung",
author: "kelle",
createdAt: new Date("2026-07-21T10:00:00Z"),
config: {
title: "Finanzplanung 2026",
metric: "nominal" as const,
source: "PLAN" as const,
scenarioIds: ["s1"],
analysisIds: [] as string[],
comment: null,
},
scenarios: [{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") }],
household: {
householdType: "COUPLE" as const,
startYear: 2026,
persons: [
{ name: "Anna", age: 45, role: "PERSON_A" },
{ name: "Beat", age: 43, role: "PERSON_B" },
],
},
actuals: [] as ActualsSetInput[],
origins: [] as { id: string; sourceElementId?: string | null }[],
analyses: [] as { name: string; type: string; result: unknown }[],
...over,
};
}
describe("buildReport", () => {
it("stellt die Zusammenfassung aus dem Basisszenario zusammen", () => {
const m = buildReport(input());
expect(m.summary.figures.length).toBeGreaterThan(3);
// Die drei Kernaussagen: Endvermögen, Reichweite, offene Entscheide.
expect(m.summary.statements).toHaveLength(3);
expect(m.summary.statements[0]).toContain("Endvermögen");
});
it("nennt zu JEDER Kennzahl die zugrunde liegende Annahme", () => {
// Der Kern der Anforderung: kein Ergebnis ohne seine Grundlage.
const m = buildReport(input());
for (const f of m.scenarios[0].keyFigures) {
expect(f.basis, `Kennzahl «${f.label}» ohne Basis-Angabe`).toBeTruthy();
}
});
it("führt die Annahmen zentral je Szenario -- mit den echten Startwerten", () => {
const m = buildReport(input());
const flat = m.scenarios[0].assumptions.flatMap((g) => g.rows.map(([k, v]) => `${k}: ${v}`)).join(" | ");
expect(flat).toContain("Inflation");
expect(flat).toContain("Pensionsalter Anna");
// Startwert und Rendite des Depots müssen ablesbar sein.
expect(flat).toMatch(/ETF-Depot.*Start.*Rendite 4 %/);
});
it("deckelt die Szenarien und vergleicht erst ab zwei", () => {
const one = buildReport(input());
expect(one.comparison).toBeUndefined();
const many = buildReport(
input({
scenarios: [
{ name: "Basis", isBase: true, plan: plan("Basis") },
{ name: "B", isBase: false, plan: plan("B", 3) },
{ name: "C", isBase: false, plan: plan("C", 2) },
{ name: "D", isBase: false, plan: plan("D", 1) },
],
})
);
expect(many.scenarios).toHaveLength(MAX_REPORT_SCENARIOS);
expect(many.comparison!.rows).toHaveLength(MAX_REPORT_SCENARIOS);
});
it("zeigt den Plan/Ist-Block nur, wenn effektive Werte gewählt UND vorhanden sind", () => {
const sets: ActualsSetInput[] = [
{ id: "a1", recordedOn: "2030-08-18", year: 2030, cash: null, values: { etf: { value: 400000 } } },
];
const p = plan("Basisszenario");
const origins = p.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }));
// Gewählt, aber keine Sätze -> kein Block.
expect(buildReport(input({ config: { ...input().config, source: "ACTUAL" } })).actuals).toBeUndefined();
// Sätze vorhanden, aber Plandaten gewählt -> kein Block.
expect(buildReport(input({ actuals: sets, origins })).actuals).toBeUndefined();
// Beides -> Block mit Abweichung.
const m = buildReport(input({ config: { ...input().config, source: "ACTUAL" }, actuals: sets, origins }));
expect(m.actuals).toBeDefined();
expect(m.actuals!.rows.map(([k]) => k)).toContain("Abweichung");
});
it("rechnet real, wenn real gewählt ist", () => {
const nominal = buildReport(input());
const real = buildReport(input({ config: { ...input().config, metric: "real" } }));
const val = (m: ReturnType<typeof buildReport>) =>
m.scenarios[0].keyFigures.find((f) => f.label.startsWith("Endvermögen"))!.value;
// Bei positiver Inflation liegt der Realwert unter dem nominalen.
expect(val(real)).not.toBe(val(nominal));
expect(real.meta.metricLabel).toContain("real");
});
it("trägt immer einen Haftungsausschluss", () => {
// Ein formal aussehendes PDF wird als Beratung gelesen -- das muss dagegenstehen.
const m = buildReport(input());
expect(m.disclaimer.length).toBeGreaterThan(2);
expect(m.disclaimer.join(" ")).toContain("keine Anlage-");
});
});
describe("renderReportPdf", () => {
it("erzeugt eine gültige, vollständige PDF-Datei", async () => {
const m = buildReport(
input({
scenarios: [
{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") },
{ name: "Tiefere Rendite", isBase: false, plan: plan("Tiefere Rendite", 2) },
],
analyses: [
{
name: "Monte-Carlo Basis",
type: "MONTE_CARLO",
result: {
params: [{ label: "Zielbetrag", value: "3'000'000" }],
table: { columns: ["Szenario", "Realismus"], rows: [["Basis", "63 %"]] },
},
},
],
})
);
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
// Ohne sauberes Dateiende lässt sich das PDF nicht öffnen.
expect(pdf.subarray(-1024).toString("latin1")).toContain("%%EOF");
expect(pdf.length).toBeGreaterThan(5000);
}, 30000);
it("kommt auch mit einem leeren Plan zurecht, statt zu werfen", async () => {
// Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen.
const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;
const m = buildReport(input({ scenarios: [{ name: "Leer", isBase: true, plan: leer }] }));
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
}, 30000);
});
+405
View File
@@ -0,0 +1,405 @@
// PDF-Bericht (Roadmap Nr. 11) -- das MODELL, ohne PDF-Kenntnisse.
//
// Diese Datei baut aus Plan, Berechnung und Konfiguration eine vollständig aufgelöste,
// druckfertige Struktur aus Zahlen und Texten. Das Zeichnen macht `report-pdf.ts`.
// Die Trennung hat zwei Gründe: Das Modell ist ohne PDF-Bibliothek testbar, und es ist
// zugleich das, was eingefroren wird -- der Bericht ändert sich nicht mehr, wenn der Plan
// später weiterentwickelt wird.
//
// Grundsätze aus der Recherche (siehe SPEZIFIKATION 3.11):
// * Zusammenfassung zuerst, Details danach -- Überkomplexität ist die häufigste Kritik
// an Beraterberichten.
// * Annahmen EINMAL zentral, nicht bei jeder Kennzahl wiederholt; die Kennzahlen
// verweisen darauf.
// * Transparenz zählt so viel wie das Ergebnis: Systemparameter und Methodik gehören
// in den Anhang, ein Haftungsausschluss ist Pflicht.
import { computePlan } from "@/lib/calculations";
import { totalOpenDecisions } from "@/lib/decisions";
import { num } from "@/lib/elements";
import { formatChf } from "@/lib/format";
import { resolveActuals, type ActualsSetInput, type ElementOrigin } from "@/lib/actuals";
import type { PlanComputed } from "@/lib/calculations";
import type { PlanInput } from "@/lib/types";
export type ReportMetric = "nominal" | "real";
export type ReportSource = "PLAN" | "ACTUAL";
// Höchstens drei Szenarien je Bericht: Darüber wird die Vergleichstabelle unlesbar, und
// vergleichbare Werkzeuge stellen bewusst nur zwei gegenüber.
export const MAX_REPORT_SCENARIOS = 3;
export interface ReportConfig {
title: string;
metric: ReportMetric;
source: ReportSource;
scenarioIds: string[];
analysisIds: string[];
comment?: string | null;
}
export interface KeyFigure {
label: string;
value: string;
// Kurzer Hinweis auf die zugrunde liegende Annahme -- der Verweis auf den zentralen
// Annahmen-Abschnitt, ohne ihn zu wiederholen.
basis?: string;
tone?: "danger" | "success";
}
export interface ReportTable {
columns: string[];
rows: (string | number)[][];
}
export interface ReportChart {
title: string;
series: { label: string; dashed?: boolean; points: { x: number; y: number }[] }[];
xLabel: string;
}
export interface ReportScenario {
name: string;
isBase: boolean;
keyFigures: KeyFigure[];
phases: ReportTable;
chart: ReportChart;
assumptions: { title: string; rows: [string, string][] }[];
openDecisions: number;
}
export interface ReportModel {
meta: {
planName: string;
title: string;
createdAt: string;
author: string;
metricLabel: string;
sourceLabel: string;
comment?: string | null;
};
household: { rows: [string, string][] };
summary: { figures: KeyFigure[]; statements: string[] };
scenarios: ReportScenario[];
comparison?: ReportTable;
actuals?: { rows: [string, string][]; note: string };
analyses: { name: string; type: string; params: [string, string][]; table?: ReportTable }[];
disclaimer: string[];
}
const DISCLAIMER = [
"Dieser Bericht ist eine rechnerische PROJEKTION auf Basis der von dir erfassten Annahmen. Er ist keine Anlage-, Steuer- oder Vorsorgeberatung und ersetzt keine Fachberatung.",
"Alle Zukunftswerte beruhen auf Annahmen zu Renditen, Inflation, Lohnentwicklung und Lebensdauer. Treffen diese nicht ein, weicht das tatsächliche Ergebnis ab möglicherweise erheblich.",
"Die Berechnung vereinfacht bewusst: Steuern werden nur dort berücksichtigt, wo ausgewiesen (Kapitalbezugs- und Grundstückgewinnsteuer). Eine laufende Einkommens- und Vermögenssteuer ist NICHT modelliert.",
"Wahrscheinlichkeiten aus der Monte-Carlo-Simulation messen die Streuung UM die getroffenen Annahmen nicht, ob die Annahmen selbst zutreffen.",
];
// --- Hilfsgrössen -----------------------------------------------------------------------
function pick(computed: PlanComputed, metric: ReportMetric, phaseIndex: number): number {
const p = computed.phases[phaseIndex];
if (!p) return 0;
return Math.round(metric === "real" ? p.endWealthReal : p.endWealthNominal);
}
// Vermögen im Jahr der Pensionierung von Person A -- ein Meilenstein, den man in fast jedem
// Beratungsbericht findet.
function wealthAtRetirement(plan: PlanInput, computed: PlanComputed, metric: ReportMetric): number | null {
const a = plan.persons.find((p) => p.role === "PERSON_A") ?? plan.persons[0];
if (!a) return null;
const point = computed.yearly.find((y) => y.age >= a.retirementAge);
if (!point) return null;
return Math.round(metric === "real" ? point.wealthReal : point.wealthNominal);
}
// Laufende Jahresrente (AHV bzw. verrentete PK) in der letzten Phase.
function annualPensionOf(computed: PlanComputed, category: "AHV" | "PENSION_FUND"): number {
const last = computed.phases[computed.phases.length - 1];
if (!last) return 0;
return Math.round(
last.elements.filter((e) => e.category === category).reduce((s, e) => s + Math.max(0, e.startValue), 0)
);
}
// Das grösste Vorsorgekapital zum Pensionierungszeitpunkt (PK + 3a), nominal.
function capitalAtRetirement(plan: PlanInput, computed: PlanComputed): number {
const a = plan.persons.find((p) => p.role === "PERSON_A") ?? plan.persons[0];
if (!a) return 0;
const year = computed.yearly.find((y) => y.age >= a.retirementAge)?.year;
if (!year) return 0;
let total = 0;
for (const ph of computed.phases) {
for (const el of ph.elements) {
if (el.category !== "PENSION_FUND" && el.category !== "PILLAR_3A") continue;
const pt = el.yearly.find((y) => y.year === year);
if (pt) total += Math.max(0, pt.value);
}
}
return Math.round(total);
}
// --- Annahmen ----------------------------------------------------------------------------
// Zentral je Szenario, damit die Kennzahlen nur noch darauf verweisen müssen.
function assumptionsOf(plan: PlanInput): { title: string; rows: [string, string][] }[] {
const out: { title: string; rows: [string, string][] }[] = [];
const firstPhaseId = [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber)[0]?.id;
out.push({
title: "Plan-weit",
rows: [
["Inflation", `${plan.inflationRateDefault} % pro Jahr`],
["Cash zu Planbeginn", formatChf(Math.round(plan.initialCash || 0))],
...plan.persons.map(
(p) =>
[
`Pensionsalter ${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")}`,
`${p.retirementAge} Jahre`,
] as [string, string]
),
],
});
// Startwerte und Raten je Element -- genau die Zahlen, mit denen gerechnet wurde.
const rows: [string, string][] = [];
for (const el of [...plan.elements].sort((a, b) => a.orderIndex - b.orderIndex)) {
const pd = firstPhaseId ? el.phaseValues[firstPhaseId] ?? {} : {};
const parts: string[] = [];
if (typeof pd.amount === "number") parts.push(`${formatChf(Math.round(pd.amount))}/Jahr`);
if (typeof pd.startValue === "number") parts.push(`Start ${formatChf(Math.round(pd.startValue))}`);
if (typeof pd.currentValue === "number") parts.push(`Start ${formatChf(Math.round(pd.currentValue))}`);
if (typeof pd.purchasePrice === "number") parts.push(`Kaufpreis ${formatChf(Math.round(pd.purchasePrice))}`);
if (typeof pd.mortgage === "number") parts.push(`Hypothek ${formatChf(Math.round(pd.mortgage))}`);
if (pd.expectedReturn != null) parts.push(`Rendite ${num(pd.expectedReturn)} %`);
if (pd.valueGrowth != null) parts.push(`Wertsteigerung ${num(pd.valueGrowth)} %`);
if (pd.interestRate != null) parts.push(`Zins ${num(pd.interestRate)} %`);
if (pd.teuerungsausgleich != null) parts.push(`Anpassung ${num(pd.teuerungsausgleich)} %/Jahr`);
if (pd.annualContribution != null && num(pd.annualContribution) !== 0)
parts.push(`Sparrate ${formatChf(Math.round(num(pd.annualContribution)))}`);
if (pd.annualWithdrawal != null && num(pd.annualWithdrawal) !== 0)
parts.push(`Bezug ${formatChf(Math.round(num(pd.annualWithdrawal)))}`);
if (parts.length > 0) rows.push([el.name, parts.join(" · ")]);
}
if (rows.length > 0) out.push({ title: "Elemente (Werte der ersten Lebensphase)", rows });
return out;
}
// --- Szenario ----------------------------------------------------------------------------
function buildScenario(
plan: PlanInput,
computed: PlanComputed,
name: string,
isBase: boolean,
metric: ReportMetric
): ReportScenario {
const lastIndex = computed.phases.length - 1;
const end = pick(computed, metric, lastIndex);
const atRet = wealthAtRetirement(plan, computed, metric);
const ahv = annualPensionOf(computed, "AHV");
const pkPension = annualPensionOf(computed, "PENSION_FUND");
const capital = capitalAtRetirement(plan, computed);
const open = totalOpenDecisions(plan, computed);
const figures: KeyFigure[] = [
{
label: `Endvermögen (${metric === "real" ? "real" : "nominal"})`,
value: formatChf(end),
basis: `Renditen und Inflation ${plan.inflationRateDefault} % laut Annahmen`,
},
{
label: "Kapital reicht",
value: computed.ruinAge === null ? "bis Planende" : `bis Alter ${computed.ruinAge}`,
tone: computed.ruinAge === null ? "success" : "danger",
basis: "Gesamtvermögen inkl. Cash, abzüglich Schulden",
},
];
if (atRet !== null) {
figures.push({
label: "Vermögen bei Pensionierung",
value: formatChf(atRet),
basis: "Stand im Jahr, in dem Person A das Pensionsalter erreicht",
});
}
if (capital > 0) {
figures.push({
label: "Vorsorgekapital bei Pensionierung",
value: formatChf(capital),
basis: "PK und Säule 3a, vor Bezug/Verrentung",
});
}
if (ahv > 0) {
figures.push({
label: "AHV-Rente pro Jahr",
value: formatChf(ahv),
basis: "amtliche Rentenformel (Skala 44) aus der Beitragskarriere",
});
}
if (pkPension > 0) {
figures.push({ label: "PK-Rente pro Jahr", value: formatChf(pkPension), basis: "Umwandlungssatz laut Systemparametern" });
}
figures.push({
label: "Offene Entscheide",
value: open === 0 ? "keine" : String(open),
tone: open === 0 ? "success" : undefined,
basis: "noch nicht getroffene Übergangs-Entscheide zwischen den Lebensphasen",
});
const phases: ReportTable = {
columns: ["Lebensphase", "Dauer", "Einkommen", "Ausgaben", "Vermögen am Ende"],
rows: computed.phases.map((p) => [
p.name,
`${p.durationYears} J.`,
formatChf(Math.round(p.incomeStart)),
formatChf(Math.round(p.expenseStart)),
formatChf(Math.round(metric === "real" ? p.endWealthReal : p.endWealthNominal)),
]),
};
return {
name,
isBase,
keyFigures: figures,
phases,
chart: {
title: `Vermögensverlauf (${metric === "real" ? "real" : "nominal"})`,
xLabel: "Alter",
series: [
{
label: name,
points: computed.yearly.map((y) => ({ x: y.age, y: metric === "real" ? y.wealthReal : y.wealthNominal })),
},
],
},
assumptions: assumptionsOf(plan),
openDecisions: open,
};
}
// --- Gesamtmodell ------------------------------------------------------------------------
export interface ReportScenarioInput {
name: string;
isBase: boolean;
plan: PlanInput;
}
export interface BuildReportInput {
planName: string;
author: string;
createdAt: Date;
config: ReportConfig;
scenarios: ReportScenarioInput[];
household: { householdType: "SINGLE" | "COUPLE"; startYear: number | null; persons: { name: string | null; age: number; role: string }[] };
actuals: ActualsSetInput[];
origins: ElementOrigin[];
analyses: { name: string; type: string; result: unknown }[];
}
export function buildReport(input: BuildReportInput): ReportModel {
const { config } = input;
const chosen = input.scenarios.slice(0, MAX_REPORT_SCENARIOS);
// Je Szenario die massgebende Rechnung: mit oder ohne effektive Werte.
const computedByScenario = chosen.map((s) => {
const resolved = config.source === "ACTUAL" ? resolveActuals(input.actuals, s.plan, input.origins) : [];
return {
...s,
computed: computePlan(s.plan, undefined, resolved.length > 0 ? { actuals: resolved } : undefined),
planOnly: computePlan(s.plan),
usedActuals: resolved.length,
};
});
const scenarios = computedByScenario.map((s) => buildScenario(s.plan, s.computed, s.name, s.isBase, config.metric));
// Leitszenario für die Zusammenfassung: das Basisszenario, sonst das erste.
const lead = computedByScenario.find((s) => s.isBase) ?? computedByScenario[0];
const leadScenario = scenarios.find((s) => s.isBase) ?? scenarios[0];
const statements: string[] = [];
if (lead) {
const end = pick(lead.computed, config.metric, lead.computed.phases.length - 1);
statements.push(
`Nach ${lead.computed.phases.reduce((s, p) => s + p.durationYears, 0)} Planjahren ergibt sich im Szenario «${lead.name}» ein Endvermögen von ${formatChf(end)} (${config.metric === "real" ? "real, heutige Kaufkraft" : "nominal"}).`
);
statements.push(
lead.computed.ruinAge === null
? "Das Kapital reicht über den gesamten Planungszeitraum."
: `Achtung: Das Gesamtvermögen fällt im Alter ${lead.computed.ruinAge} unter null die Planung trägt nicht bis ans Ende.`
);
const open = leadScenario?.openDecisions ?? 0;
statements.push(
open === 0
? "Alle Übergangs-Entscheide zwischen den Lebensphasen sind getroffen."
: `${open} Übergangs-Entscheid${open === 1 ? " ist" : "e sind"} noch offen bis dahin rechnet das Tool mit Vorgabewerten.`
);
}
// Vergleichstabelle nur, wenn es überhaupt etwas zu vergleichen gibt.
const comparison: ReportTable | undefined =
chosen.length > 1
? {
columns: ["Szenario", `Endvermögen (${config.metric === "real" ? "real" : "nominal"})`, "Kapital reicht", "Offene Entscheide"],
rows: computedByScenario.map((s, i) => [
s.name + (s.isBase ? " (Basis)" : ""),
formatChf(pick(s.computed, config.metric, s.computed.phases.length - 1)),
s.computed.ruinAge === null ? "bis Planende" : `bis Alter ${s.computed.ruinAge}`,
scenarios[i].openDecisions,
]),
}
: undefined;
// Plan/Ist nur, wenn effektive Werte gewählt UND vorhanden sind.
let actualsBlock: ReportModel["actuals"];
if (config.source === "ACTUAL" && lead && lead.usedActuals > 0) {
const withActuals = pick(lead.computed, config.metric, lead.computed.phases.length - 1);
const planOnly = pick(lead.planOnly, config.metric, lead.planOnly.phases.length - 1);
const years = resolveActuals(input.actuals, lead.plan, input.origins).map((r) => r.year);
actualsBlock = {
rows: [
["Erfasste Stichtage", years.join(", ")],
["Endvermögen laut Plan", formatChf(planOnly)],
["Endvermögen mit effektiven Werten", formatChf(withActuals)],
["Abweichung", `${withActuals - planOnly >= 0 ? "+" : ""}${formatChf(Math.abs(withActuals - planOnly))}`],
],
note: "Die Berechnung springt in jedem erfassten Jahr auf die tatsächlichen Werte und läuft von dort mit den Planannahmen weiter. Elemente ohne erfassten Wert bleiben auf ihrer Planlinie.",
};
}
return {
meta: {
planName: input.planName,
title: config.title,
createdAt: input.createdAt.toISOString(),
author: input.author,
metricLabel: config.metric === "real" ? "real (heutige Kaufkraft)" : "nominal",
sourceLabel: config.source === "ACTUAL" ? "Plandaten mit effektiven Werten" : "Plandaten",
comment: config.comment ?? null,
},
household: {
rows: [
["Haushaltsform", input.household.householdType === "COUPLE" ? "Paar" : "Einzelperson"],
...input.household.persons.map(
(p) => [p.name || (p.role === "PERSON_A" ? "Person A" : "Person B"), `${p.age} Jahre`] as [string, string]
),
["Planstart", input.household.startYear ? String(input.household.startYear) : "nicht gesetzt"],
],
},
summary: { figures: leadScenario ? leadScenario.keyFigures : [], statements },
scenarios,
comparison,
actuals: actualsBlock,
analyses: input.analyses.map((a) => {
const r = (a.result ?? {}) as { params?: { label: string; value: string }[]; table?: ReportTable };
return {
name: a.name,
type: a.type,
params: (r.params ?? []).map((p) => [p.label, p.value] as [string, string]),
table: r.table,
};
}),
disclaimer: DISCLAIMER,
};
}
+2
View File
@@ -34,6 +34,8 @@ const EXEMPT: Record<string, string> = {
"plans/[planId]/actuals/[setId]": "dito (Löschen eines Ist-Satzes)",
"plans/[planId]/analyses": "gespeicherte Analysen sind read-only Momentaufnahmen kein Szenario betroffen",
"plans/[planId]/analyses/[analysisId]": "dito (Öffnen/Löschen einer Analyse)",
"plans/[planId]/reports": "Berichte sind erzeugte Dokumente sie verändern kein Szenario",
"plans/[planId]/reports/[reportId]": "dito (Herunterladen/Löschen eines Berichts)",
};
describe("Versionierung: Abdeckung der Schreibpfade", () => {