feat(flight-finder): implement milestone M2 - report workflow and delivery gates
This commit is contained in:
94
skills/flight-finder/src/report-pdf.ts
Normal file
94
skills/flight-finder/src/report-pdf.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
import { getFlightReportStatus } from "./report-status.js";
|
||||
import { normalizeFlightReportRequest } from "./request-normalizer.js";
|
||||
import type { FlightReportPayload } from "./types.js";
|
||||
|
||||
export class ReportValidationError extends Error {}
|
||||
|
||||
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
|
||||
return value?.length ? value : [fallback];
|
||||
}
|
||||
|
||||
export async function renderFlightReportPdf(
|
||||
payload: FlightReportPayload,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
const status = getFlightReportStatus(
|
||||
normalizeFlightReportRequest(payload.request),
|
||||
payload
|
||||
);
|
||||
|
||||
if (!status.pdfReady) {
|
||||
throw new ReportValidationError(
|
||||
"The flight report payload is still incomplete. Finish the report before generating the PDF."
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margin: 50
|
||||
});
|
||||
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
try {
|
||||
doc.fontSize(20).text(payload.request.tripName || "Flight report", {
|
||||
align: "left"
|
||||
});
|
||||
doc.moveDown(0.25);
|
||||
doc.fontSize(10).fillColor("#555").text(`Generated: ${payload.generatedAt}`);
|
||||
doc.fillColor("#000");
|
||||
doc.moveDown();
|
||||
|
||||
doc.fontSize(14).text("Executive Summary");
|
||||
bulletLines(payload.executiveSummary).forEach((line) => {
|
||||
doc.fontSize(10).text(`• ${line}`);
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text("Recommended Options");
|
||||
payload.rankedOptions.forEach((option) => {
|
||||
doc.fontSize(11).text(
|
||||
`${option.backup ? "Backup" : "Primary"}: ${option.title} — $${option.totalPriceUsd.toFixed(2)}`
|
||||
);
|
||||
doc.fontSize(10).text(option.rationale);
|
||||
doc.moveDown(0.5);
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text("Source Findings");
|
||||
payload.sourceFindings.forEach((finding) => {
|
||||
doc.fontSize(11).text(`${finding.source}: ${finding.status}`);
|
||||
bulletLines(finding.notes).forEach((line) => doc.fontSize(10).text(`• ${line}`));
|
||||
doc.moveDown(0.25);
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
doc.fontSize(14).text("Warnings And Degraded Conditions");
|
||||
bulletLines(
|
||||
[...payload.reportWarnings, ...payload.degradedReasons],
|
||||
"No additional warnings."
|
||||
).forEach((line) => {
|
||||
doc.fontSize(10).text(`• ${line}`);
|
||||
});
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
await fs.promises.unlink(outputPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
Reference in New Issue
Block a user