Bericht: vollstaendige Fehlerabsicherung + robustes Laden von pdfkit
Deploy App / deploy (push) Successful in 1m2s

Der gemeldete 500 kam OHNE JSON-Koerper -- der Client konnte deshalb nur
"Fehler 500" zeigen. Ursache: Der Absturz lag ausserhalb der bisherigen
try/catch-Bloecke. Jetzt liegt der GESAMTE Handler in einem try/catch,
jede Ursache wird protokolliert und im Klartext zurueckgegeben.

Zusaetzlich zwei Risiken entfernt:
- Die verschachtelte Abfrage (Plan -> Szenarien -> Plan -> Personen) war
  ein Ringbezug; Szenarien werden jetzt einzeln nachgeladen.
- pdfkit wird ueber einen Resolver geholt, der statischen Import,
  .default und createRequire durchprobiert -- unabhaengig davon, wie der
  Bundler CJS-Interop aufloest.

Neuer Test mit realistischem Plan (Immobilie, Schuld, AHV, PK, 3a,
Kopie-Szenario) -- laeuft lokal durch, schliesst die Datenform als
Ursache aus. 223 Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 10:09:21 +02:00
parent e22f2cfa2b
commit 8b50f8d4ae
3 changed files with 195 additions and 90 deletions
+28 -27
View File
@@ -67,24 +67,40 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 }); if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
const cfg = parsed.data; const cfg = parsed.data;
// Der GESAMTE weitere Ablauf liegt in einem try/catch. Ein unbehandelter Fehler ergäbe ein
// nacktes 500 ohne JSON-Körper -- der Client könnte dann nur «Fehler 500» melden, und die
// Ursache bliebe im Dunkeln.
try {
const plan = await prisma.plan.findFirst({ const plan = await prisma.plan.findFirst({
where: { id: planId, userId }, where: { id: planId, userId },
include: { include: {
persons: { orderBy: { role: "asc" } }, persons: { orderBy: { role: "asc" } },
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], include: planInclude }, scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], select: { id: true, name: true, isBase: true } },
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] }, actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
}, },
}); });
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
// Nur Szenarien dieses Plans, in der Reihenfolge der Auswahl -- Basis zuerst. // Szenarien einzeln nachladen statt über eine verschachtelte Abfrage: Plan -> Szenarien ->
const chosen = cfg.scenarioIds // Plan -> Personen wäre ein Ringbezug, und ein paar Abfragen mehr sind hier belanglos.
.map((id) => plan.scenarios.find((s) => s.id === id)) const wanted = plan.scenarios
.filter((s): s is (typeof plan.scenarios)[number] => !!s) .filter((s) => cfg.scenarioIds.includes(s.id))
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1)); .sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
if (chosen.length === 0) { if (wanted.length === 0) {
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 }); return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
} }
const chosen = (
await Promise.all(wanted.map((s) => prisma.scenario.findUnique({ where: { id: s.id }, include: planInclude })))
).filter((s): s is NonNullable<typeof s> => !!s);
if (chosen.length === 0) {
return NextResponse.json({ error: "Szenarien konnten nicht geladen werden." }, { status: 400 });
}
// Element-Herkunft aller Szenarien -- Grundlage der Ist-Zuordnung.
const allElements = await prisma.financialElement.findMany({
where: { scenario: { planId } },
select: { id: true, sourceElementId: true },
});
const analyses = const analyses =
cfg.analysisIds.length === 0 cfg.analysisIds.length === 0
@@ -127,26 +143,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
values: a.values as Record<string, { value?: number; mortgage?: number }>, 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 }))), origins: allElements,
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })), analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
}); });
// Rendern und Ablegen sind die beiden Schritte, die zur Laufzeit scheitern können const pdf = await renderReportPdf(model);
// (Schriftdaten, Grösse des Datensatzes). Ein nacktes 500 wäre hier nicht diagnostizierbar,
// deshalb wird die Ursache protokolliert UND zurückgegeben -- es ist ein Ein-Personen-
// Werkzeug, und der Nutzer ist der Einzige, der den Fehler melden kann.
let pdf: Buffer;
try {
pdf = await renderReportPdf(model);
} catch (err) {
console.error("[reports] PDF-Erzeugung fehlgeschlagen", err);
return NextResponse.json(
{ error: `PDF-Erzeugung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}` },
{ status: 500 }
);
}
try {
const created = await prisma.report.create({ const created = await prisma.report.create({
data: { data: {
planId, planId,
@@ -161,10 +163,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}); });
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 }); return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
} catch (err) { } catch (err) {
console.error("[reports] Speichern fehlgeschlagen", err); // Jede Ursache landet hier -- Datenbank, Modellaufbau, Schriftdaten, Speichern.
return NextResponse.json( console.error("[reports] Bericht fehlgeschlagen", err);
{ error: `Bericht konnte nicht gespeichert werden: ${err instanceof Error ? err.message : String(err)}` }, const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
{ status: 500 } return NextResponse.json({ error: `Bericht fehlgeschlagen -- ${message}` }, { status: 500 });
);
} }
} }
+35 -7
View File
@@ -7,15 +7,42 @@
// Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen // Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen
// Umlaute; ein Font-Embedding ist nicht nötig. // Umlaute; ein Font-Embedding ist nicht nötig.
import { createRequire } from "node:module";
import PDFDocumentModule from "pdfkit"; import PDFDocumentModule from "pdfkit";
import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report"; import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report";
// pdfkit ist CommonJS und wird als EXTERNES Paket geladen (siehe next.config.ts). Je nach // pdfkit ist CommonJS, wird als EXTERNES Paket geladen (siehe next.config.ts) und liest seine
// Interop des Bundlers kommt der Konstruktor direkt oder unter `.default` an. Trifft man die // Schriftmetriken zur Laufzeit über Dateipfade. Je nach Interop des Bundlers kommt der
// falsche Form, gelingt der Import trotzdem -- und erst `new PDFDocument()` scheitert zur // Konstruktor direkt, unter `.default` -- oder gar nicht. Der Import gelingt dabei immer;
// Laufzeit. Deshalb hier beide Formen akzeptieren. // erst `new PDFDocument()` scheitert. Deshalb wird die brauchbare Form hier einmal ermittelt,
const PDFDocument = ((PDFDocumentModule as unknown as { default?: typeof PDFDocumentModule }).default ?? // mit `createRequire` als letzter Rückfallebene (lädt garantiert aus node_modules).
PDFDocumentModule) as typeof PDFDocumentModule; type PdfCtor = typeof PDFDocumentModule;
let ctor: PdfCtor | null = null;
function pdfConstructor(): PdfCtor {
if (ctor) return ctor;
const candidates: unknown[] = [
PDFDocumentModule,
(PDFDocumentModule as unknown as { default?: unknown })?.default,
];
try {
candidates.push(createRequire(import.meta.url)("pdfkit"));
} catch {
// Kein createRequire verfügbar -- dann müssen die statischen Formen genügen.
}
for (const c of candidates) {
if (typeof c === "function") {
ctor = c as PdfCtor;
return ctor;
}
const d = (c as { default?: unknown })?.default;
if (typeof d === "function") {
ctor = d as PdfCtor;
return ctor;
}
}
throw new Error("pdfkit konnte nicht geladen werden (kein Konstruktor gefunden).");
}
const A4 = { width: 595.28, height: 841.89 }; const A4 = { width: 595.28, height: 841.89 };
const M = 56; // Seitenrand const M = 56; // Seitenrand
@@ -34,7 +61,7 @@ const COLORS = {
const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"]; const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"];
type Doc = InstanceType<typeof PDFDocument>; type Doc = InstanceType<PdfCtor>;
// --- Grundbausteine ---------------------------------------------------------------------- // --- Grundbausteine ----------------------------------------------------------------------
@@ -224,6 +251,7 @@ function lineChart(doc: Doc, chart: ReportChart) {
// --- Bericht ----------------------------------------------------------------------------- // --- Bericht -----------------------------------------------------------------------------
export function renderReportPdf(model: ReportModel): Promise<Buffer> { export function renderReportPdf(model: ReportModel): Promise<Buffer> {
const PDFDocument = pdfConstructor();
const doc = new PDFDocument({ const doc = new PDFDocument({
size: "A4", size: "A4",
margins: { top: M, bottom: M, left: M, right: M }, margins: { top: M, bottom: M, left: M, right: M },
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { buildReport } from "@/lib/report";
import { renderReportPdf } from "@/lib/report-pdf";
import type { PlanInput } from "@/lib/types";
// Realistischer Plan: Paar, Immobilie mit Hypothek, Schuld, AHV, PK, 3a, ETF -- also
// deutlich mehr Element-Arten als im ersten Test.
function realistic(): PlanInput {
return {
id: "s1", name: "Basisszenario", householdType: "COUPLE",
inflationRateDefault: 1.5, initialCash: 25000, 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: "Mischphase", durationYears: 2, cashTransition: {} },
{ id: "p3", sequenceNumber: 3, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{ id: "i1", category: "INCOME", name: "Lohn Anna", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { amount: 130000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null },
{ id: "i2", category: "INCOME", name: "Lohn Beat", ownerRole: "PERSON_B", orderIndex: 2,
phaseValues: { p1: { amount: 95000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null },
{ id: "e1", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 3,
phaseValues: { p1: { amount: 105000 }, p3: { amount: 90000 } }, transitionValues: {}, sourceElementId: null },
{ id: "ahvA", category: "AHV", name: "AHV Anna", ownerRole: "PERSON_A", orderIndex: 4,
phaseValues: { p1: { gapYears: 0 } }, transitionValues: {}, sourceElementId: null },
{ id: "ahvB", category: "AHV", name: "AHV Beat", ownerRole: "PERSON_B", orderIndex: 5,
phaseValues: { p1: { gapYears: 2 } }, transitionValues: {}, sourceElementId: null },
{ id: "pkA", category: "PENSION_FUND", name: "PK Anna", ownerRole: "PERSON_A", orderIndex: 6,
phaseValues: { p1: { currentValue: 340000, expectedReturn: 2, annualContribution: 14000 } },
transitionValues: {}, sourceElementId: null },
{ id: "s3a", category: "PILLAR_3A", name: "Säule 3a", ownerRole: "PERSON_A", orderIndex: 7,
phaseValues: { p1: { currentValue: 90000, expectedReturn: 2.5, annualContribution: 7056 } },
transitionValues: {}, sourceElementId: null },
{ id: "haus", category: "REAL_ESTATE", name: "Wohneigentum", ownerRole: "HOUSEHOLD", orderIndex: 8,
phaseValues: { p1: { purchasePrice: 1100000, mortgage: 750000, amortization: 12000,
valueGrowth: 1, interestRate: 1.8, interestHandling: "ADD" } },
transitionValues: {}, sourceElementId: null },
{ id: "debt", category: "OTHER_DEBT", name: "Privatdarlehen", ownerRole: "HOUSEHOLD", orderIndex: 9,
phaseValues: { p1: { startValue: 40000, annualRepayment: 5000 } }, transitionValues: {}, sourceElementId: null },
{ id: "etf", category: "OTHER_ASSET", name: "ETF-Depot", ownerRole: "HOUSEHOLD", orderIndex: 10,
phaseValues: { p1: { startValue: 160000, expectedReturn: 5, annualContribution: 10000 },
p3: { expectedReturn: 4, annualWithdrawal: 30000 } },
transitionValues: {}, sourceElementId: null },
],
} as unknown as PlanInput;
}
describe("Reproduktion: realistischer Plan", () => {
it("baut Modell und PDF ohne Absturz", async () => {
const p = realistic();
// Kind-Szenario wie beim Kopieren: eigene IDs, Herkunftsverweise.
const child = {
...p, id: "s2", name: "Tiefere Rendite",
elements: p.elements.map((e) => ({ ...e, id: e.id + "-k", sourceElementId: e.id })),
} as unknown as PlanInput;
const m = buildReport({
planName: "Meine Planung", author: "kelle", createdAt: new Date(),
config: { title: "Bericht", metric: "nominal", source: "PLAN", scenarioIds: ["s1", "s2"], analysisIds: [], comment: null },
scenarios: [
{ name: "Basisszenario", isBase: true, plan: p },
{ name: "Tiefere Rendite", isBase: false, plan: child },
],
household: { householdType: "COUPLE", startYear: 2026,
persons: [{ name: "Anna", age: 45, role: "PERSON_A" }, { name: "Beat", age: 43, role: "PERSON_B" }] },
actuals: [], origins: [], analyses: [],
});
expect(m.scenarios).toHaveLength(2);
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
}, 30000);
});