From 8b50f8d4ae9cf3d6a7724aa642654d8d91067762 Mon Sep 17 00:00:00 2001 From: kelle Date: Tue, 21 Jul 2026 10:09:21 +0200 Subject: [PATCH] Bericht: vollstaendige Fehlerabsicherung + robustes Laden von pdfkit 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 --- src/app/api/plans/[planId]/reports/route.ts | 167 ++++++++++---------- src/lib/report-pdf.ts | 42 ++++- src/lib/report-realistic.test.ts | 76 +++++++++ 3 files changed, 195 insertions(+), 90 deletions(-) create mode 100644 src/lib/report-realistic.test.ts diff --git a/src/app/api/plans/[planId]/reports/route.ts b/src/app/api/plans/[planId]/reports/route.ts index dc6e68e..9b5ed92 100644 --- a/src/app/api/plans/[planId]/reports/route.ts +++ b/src/app/api/plans/[planId]/reports/route.ts @@ -67,86 +67,88 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ 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, - }) - ), - 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 })), - }); - - // Rendern und Ablegen sind die beiden Schritte, die zur Laufzeit scheitern können - // (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; + // 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 { - 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 } - ); - } + const plan = await prisma.plan.findFirst({ + where: { id: planId, userId }, + include: { + persons: { orderBy: { role: "asc" } }, + scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], select: { id: true, name: true, isBase: true } }, + actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] }, + }, + }); + if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 }); + + // Szenarien einzeln nachladen statt über eine verschachtelte Abfrage: Plan -> Szenarien -> + // Plan -> Personen wäre ein Ringbezug, und ein paar Abfragen mehr sind hier belanglos. + const wanted = plan.scenarios + .filter((s) => cfg.scenarioIds.includes(s.id)) + .sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1)); + if (wanted.length === 0) { + 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 => !!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 = + 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, + }) + ), + origins: allElements, + analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })), + }); + + const pdf = await renderReportPdf(model); - try { const created = await prisma.report.create({ data: { 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 }); } catch (err) { - console.error("[reports] Speichern fehlgeschlagen", err); - return NextResponse.json( - { error: `Bericht konnte nicht gespeichert werden: ${err instanceof Error ? err.message : String(err)}` }, - { status: 500 } - ); + // Jede Ursache landet hier -- Datenbank, Modellaufbau, Schriftdaten, Speichern. + console.error("[reports] Bericht fehlgeschlagen", err); + const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + return NextResponse.json({ error: `Bericht fehlgeschlagen -- ${message}` }, { status: 500 }); } } diff --git a/src/lib/report-pdf.ts b/src/lib/report-pdf.ts index 311892f..e36669a 100644 --- a/src/lib/report-pdf.ts +++ b/src/lib/report-pdf.ts @@ -7,15 +7,42 @@ // Die eingebauten Schriften (Helvetica) decken WinAnsi ab und damit alle deutschen // Umlaute; ein Font-Embedding ist nicht nötig. +import { createRequire } from "node:module"; import PDFDocumentModule from "pdfkit"; import type { KeyFigure, ReportChart, ReportModel, ReportTable } from "@/lib/report"; -// pdfkit ist CommonJS und wird als EXTERNES Paket geladen (siehe next.config.ts). Je nach -// Interop des Bundlers kommt der Konstruktor direkt oder unter `.default` an. Trifft man die -// falsche Form, gelingt der Import trotzdem -- und erst `new PDFDocument()` scheitert zur -// Laufzeit. Deshalb hier beide Formen akzeptieren. -const PDFDocument = ((PDFDocumentModule as unknown as { default?: typeof PDFDocumentModule }).default ?? - PDFDocumentModule) as typeof PDFDocumentModule; +// pdfkit ist CommonJS, wird als EXTERNES Paket geladen (siehe next.config.ts) und liest seine +// Schriftmetriken zur Laufzeit über Dateipfade. Je nach Interop des Bundlers kommt der +// Konstruktor direkt, unter `.default` -- oder gar nicht. Der Import gelingt dabei immer; +// erst `new PDFDocument()` scheitert. Deshalb wird die brauchbare Form hier einmal ermittelt, +// mit `createRequire` als letzter Rückfallebene (lädt garantiert aus node_modules). +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 M = 56; // Seitenrand @@ -34,7 +61,7 @@ const COLORS = { const SERIES_COLORS = ["#4f46e5", "#0ea5e9", "#16a34a"]; -type Doc = InstanceType; +type Doc = InstanceType; // --- Grundbausteine ---------------------------------------------------------------------- @@ -224,6 +251,7 @@ function lineChart(doc: Doc, chart: ReportChart) { // --- Bericht ----------------------------------------------------------------------------- export function renderReportPdf(model: ReportModel): Promise { + const PDFDocument = pdfConstructor(); const doc = new PDFDocument({ size: "A4", margins: { top: M, bottom: M, left: M, right: M }, diff --git a/src/lib/report-realistic.test.ts b/src/lib/report-realistic.test.ts new file mode 100644 index 0000000..7b5e75a --- /dev/null +++ b/src/lib/report-realistic.test.ts @@ -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); +});