Bericht: vollstaendige Fehlerabsicherung + robustes Laden von pdfkit
Deploy App / deploy (push) Successful in 1m2s
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:
@@ -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<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 })),
|
||||
});
|
||||
|
||||
// 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<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 =
|
||||
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: 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 });
|
||||
}
|
||||
}
|
||||
|
||||
+35
-7
@@ -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<typeof PDFDocument>;
|
||||
type Doc = InstanceType<PdfCtor>;
|
||||
|
||||
// --- Grundbausteine ----------------------------------------------------------------------
|
||||
|
||||
@@ -224,6 +251,7 @@ function lineChart(doc: Doc, chart: ReportChart) {
|
||||
// --- Bericht -----------------------------------------------------------------------------
|
||||
|
||||
export function renderReportPdf(model: ReportModel): Promise<Buffer> {
|
||||
const PDFDocument = pdfConstructor();
|
||||
const doc = new PDFDocument({
|
||||
size: "A4",
|
||||
margins: { top: M, bottom: M, left: M, right: M },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user