|
|
|
|
@@ -4,7 +4,13 @@ import PDFDocument from "pdfkit";
|
|
|
|
|
|
|
|
|
|
import { getFlightReportStatus } from "./report-status.js";
|
|
|
|
|
import { normalizeFlightReportRequest } from "./request-normalizer.js";
|
|
|
|
|
import type { FlightReportPayload } from "./types.js";
|
|
|
|
|
import type {
|
|
|
|
|
FlightLegWindow,
|
|
|
|
|
FlightPassengerGroup,
|
|
|
|
|
FlightQuote,
|
|
|
|
|
FlightRecommendationOption,
|
|
|
|
|
FlightReportPayload
|
|
|
|
|
} from "./types.js";
|
|
|
|
|
|
|
|
|
|
export class ReportValidationError extends Error {}
|
|
|
|
|
|
|
|
|
|
@@ -12,14 +18,194 @@ function bulletLines(value: string[] | undefined, fallback = "Not provided."): s
|
|
|
|
|
return value?.length ? value : [fallback];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asLegLabelMap(legs: FlightLegWindow[]): Map<string, string> {
|
|
|
|
|
return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map<string, string> {
|
|
|
|
|
return new Map(
|
|
|
|
|
groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`])
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatUsd(value: number): string {
|
|
|
|
|
return new Intl.NumberFormat("en-US", {
|
|
|
|
|
style: "currency",
|
|
|
|
|
currency: "USD",
|
|
|
|
|
maximumFractionDigits: 0
|
|
|
|
|
}).format(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDateWindow(leg: FlightLegWindow): string {
|
|
|
|
|
if (leg.earliest && leg.latest) {
|
|
|
|
|
return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`;
|
|
|
|
|
}
|
|
|
|
|
if (leg.relativeToLegId) {
|
|
|
|
|
const min = leg.minDaysAfter ?? "?";
|
|
|
|
|
const max = leg.maxDaysAfter ?? "?";
|
|
|
|
|
return `${min}-${max} days after ${leg.relativeToLegId}`;
|
|
|
|
|
}
|
|
|
|
|
return "Not provided";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
|
|
|
|
const x = doc.page.margins.left;
|
|
|
|
|
const y = doc.y;
|
|
|
|
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
|
|
|
const height = 18;
|
|
|
|
|
|
|
|
|
|
doc.save();
|
|
|
|
|
doc.roundedRect(x, y, width, height, 3).fill("#10384E");
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("white")
|
|
|
|
|
.font("Helvetica-Bold")
|
|
|
|
|
.fontSize(12)
|
|
|
|
|
.text(title, x + 8, y + 4, { width: width - 16 });
|
|
|
|
|
doc.restore();
|
|
|
|
|
doc.moveDown(1.2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
|
|
|
|
|
const lines = bulletLines(items, fallback);
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
const startY = doc.y;
|
|
|
|
|
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("#1E2329")
|
|
|
|
|
.font("Helvetica")
|
|
|
|
|
.fontSize(10.5)
|
|
|
|
|
.text(line, doc.page.margins.left + 14, startY, {
|
|
|
|
|
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
|
|
|
|
lineGap: 2
|
|
|
|
|
});
|
|
|
|
|
doc.moveDown(0.35);
|
|
|
|
|
}
|
|
|
|
|
doc.moveDown(0.2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
|
|
|
|
const left = doc.page.margins.left;
|
|
|
|
|
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
|
|
|
const keyWidth = 170;
|
|
|
|
|
const valueWidth = totalWidth - keyWidth;
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of rows) {
|
|
|
|
|
const startY = doc.y;
|
|
|
|
|
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
|
|
|
|
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
|
|
|
|
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
|
|
|
|
|
|
|
|
|
doc.save();
|
|
|
|
|
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
|
|
|
|
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
|
|
|
|
|
doc
|
|
|
|
|
.lineWidth(0.5)
|
|
|
|
|
.strokeColor("#C7D0D9")
|
|
|
|
|
.rect(left, startY, totalWidth, rowHeight)
|
|
|
|
|
.stroke();
|
|
|
|
|
doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke();
|
|
|
|
|
doc.restore();
|
|
|
|
|
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("#1E2329")
|
|
|
|
|
.font("Helvetica-Bold")
|
|
|
|
|
.fontSize(9.5)
|
|
|
|
|
.text(key, left + 6, startY + 6, { width: keyWidth - 12 });
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("#1E2329")
|
|
|
|
|
.font("Helvetica")
|
|
|
|
|
.fontSize(9.5)
|
|
|
|
|
.text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
|
|
|
|
|
doc.y = startY + rowHeight;
|
|
|
|
|
}
|
|
|
|
|
doc.moveDown(0.8);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureFreshSearch(payload: FlightReportPayload): void {
|
|
|
|
|
const searchExecution = payload.searchExecution;
|
|
|
|
|
if (!searchExecution?.freshSearch) {
|
|
|
|
|
throw new ReportValidationError(
|
|
|
|
|
"The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startedAt = Date.parse(searchExecution.startedAt);
|
|
|
|
|
const completedAt = Date.parse(searchExecution.completedAt);
|
|
|
|
|
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) {
|
|
|
|
|
throw new ReportValidationError(
|
|
|
|
|
"The flight report payload must include a valid fresh-search time window."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!String(searchExecution.artifactsRoot || "").trim()) {
|
|
|
|
|
throw new ReportValidationError(
|
|
|
|
|
"The flight report payload must include the artifacts root for the fresh search run."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function drawItineraryCard(
|
|
|
|
|
doc: PDFKit.PDFDocument,
|
|
|
|
|
option: FlightRecommendationOption,
|
|
|
|
|
quotesById: Map<string, FlightQuote>,
|
|
|
|
|
legLabels: Map<string, string>,
|
|
|
|
|
passengerLabels: Map<string, string>
|
|
|
|
|
): void {
|
|
|
|
|
const left = doc.page.margins.left;
|
|
|
|
|
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
|
|
|
const top = doc.y;
|
|
|
|
|
const badge = option.backup ? "Backup" : "Primary";
|
|
|
|
|
const quoteLines = option.quoteIds.flatMap((quoteId) => {
|
|
|
|
|
const quote = quotesById.get(quoteId);
|
|
|
|
|
if (!quote) return [`Missing quote reference: ${quoteId}`];
|
|
|
|
|
const passengerLine = quote.passengerGroupIds
|
|
|
|
|
.map((groupId) => passengerLabels.get(groupId) || groupId)
|
|
|
|
|
.join(", ");
|
|
|
|
|
const legLine = legLabels.get(quote.legId) || quote.legId;
|
|
|
|
|
return [
|
|
|
|
|
`${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
|
|
|
|
|
`Passengers: ${passengerLine}`,
|
|
|
|
|
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}; ${quote.stopsText || "stops?"}; ${quote.totalDurationText || "duration?"}`,
|
|
|
|
|
...(quote.notes || []).map((note) => `Note: ${note}`)
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
const bodyLines = [
|
|
|
|
|
`Why it ranked here: ${option.rationale}`,
|
|
|
|
|
...quoteLines
|
|
|
|
|
];
|
|
|
|
|
const bodyText = bodyLines.join("\n");
|
|
|
|
|
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20, lineGap: 2 }) + 24;
|
|
|
|
|
|
|
|
|
|
doc.save();
|
|
|
|
|
doc.roundedRect(left, top, width, 24, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("white")
|
|
|
|
|
.font("Helvetica-Bold")
|
|
|
|
|
.fontSize(11)
|
|
|
|
|
.text(`${badge}: ${option.title} (${formatUsd(option.totalPriceUsd)})`, left + 10, top + 7, {
|
|
|
|
|
width: width - 20
|
|
|
|
|
});
|
|
|
|
|
doc
|
|
|
|
|
.roundedRect(left, top + 24, width, bodyHeight, 4)
|
|
|
|
|
.fillAndStroke("#F8FAFC", "#C7D0D9");
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("#1E2329")
|
|
|
|
|
.font("Helvetica")
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
.text(bodyText, left + 10, top + 34, {
|
|
|
|
|
width: width - 20,
|
|
|
|
|
lineGap: 2
|
|
|
|
|
});
|
|
|
|
|
doc.restore();
|
|
|
|
|
doc.y = top + 24 + bodyHeight + 10;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function renderFlightReportPdf(
|
|
|
|
|
payload: FlightReportPayload,
|
|
|
|
|
outputPath: string
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const status = getFlightReportStatus(
|
|
|
|
|
normalizeFlightReportRequest(payload.request),
|
|
|
|
|
payload
|
|
|
|
|
);
|
|
|
|
|
const normalizedRequest = normalizeFlightReportRequest(payload.request);
|
|
|
|
|
const status = getFlightReportStatus(normalizedRequest, payload);
|
|
|
|
|
|
|
|
|
|
if (!status.pdfReady) {
|
|
|
|
|
throw new ReportValidationError(
|
|
|
|
|
@@ -27,56 +213,89 @@ export async function renderFlightReportPdf(
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensureFreshSearch(payload);
|
|
|
|
|
|
|
|
|
|
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
|
|
|
|
|
|
|
|
|
const doc = new PDFDocument({
|
|
|
|
|
size: "LETTER",
|
|
|
|
|
margin: 50
|
|
|
|
|
margin: 50,
|
|
|
|
|
info: {
|
|
|
|
|
Title: payload.request.tripName || "Flight report",
|
|
|
|
|
Author: "OpenClaw flight-finder"
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const stream = fs.createWriteStream(outputPath);
|
|
|
|
|
doc.pipe(stream);
|
|
|
|
|
|
|
|
|
|
const request = normalizedRequest.request;
|
|
|
|
|
const legLabels = asLegLabelMap(request.legs);
|
|
|
|
|
const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups);
|
|
|
|
|
const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote]));
|
|
|
|
|
|
|
|
|
|
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.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
|
|
|
|
|
doc.moveDown(0.2);
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("#4F5B66")
|
|
|
|
|
.font("Helvetica")
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
.text(`Generated: ${payload.generatedAt}`);
|
|
|
|
|
doc
|
|
|
|
|
.fillColor("#4F5B66")
|
|
|
|
|
.font("Helvetica")
|
|
|
|
|
.fontSize(10)
|
|
|
|
|
.text(
|
|
|
|
|
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search: ${payload.searchExecution.completedAt}`
|
|
|
|
|
);
|
|
|
|
|
doc.fontSize(10).text(option.rationale);
|
|
|
|
|
doc.moveDown(0.5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
doc.moveDown();
|
|
|
|
|
doc.fontSize(14).text("Source Findings");
|
|
|
|
|
|
|
|
|
|
drawSectionHeader(doc, "Trip Snapshot");
|
|
|
|
|
drawKeyValueTable(doc, [
|
|
|
|
|
["Recipient", request.recipientEmail || "Missing"],
|
|
|
|
|
["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")],
|
|
|
|
|
["Preferences", [
|
|
|
|
|
request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates",
|
|
|
|
|
request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped",
|
|
|
|
|
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
|
|
|
|
|
].join(" | ")],
|
|
|
|
|
["Market-country mode", request.preferences.marketCountry || "Off"],
|
|
|
|
|
["Search artifacts", payload.searchExecution.artifactsRoot]
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
drawSectionHeader(doc, "Legs And Travelers");
|
|
|
|
|
for (const leg of request.legs) {
|
|
|
|
|
const assignedGroups = request.legAssignments
|
|
|
|
|
.find((assignment) => assignment.legId === leg.id)
|
|
|
|
|
?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId)
|
|
|
|
|
.join(", ") || "Not assigned";
|
|
|
|
|
drawKeyValueTable(doc, [
|
|
|
|
|
["Leg", leg.label || leg.id],
|
|
|
|
|
["Route", `${leg.origin} -> ${leg.destination}`],
|
|
|
|
|
["Window", formatDateWindow(leg)],
|
|
|
|
|
["Travelers", assignedGroups]
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
drawSectionHeader(doc, "Executive Summary");
|
|
|
|
|
drawBulletList(doc, payload.executiveSummary);
|
|
|
|
|
|
|
|
|
|
drawSectionHeader(doc, "Recommended Options");
|
|
|
|
|
payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels));
|
|
|
|
|
|
|
|
|
|
drawSectionHeader(doc, "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);
|
|
|
|
|
drawKeyValueTable(doc, [
|
|
|
|
|
["Source", finding.source],
|
|
|
|
|
["Status", finding.status],
|
|
|
|
|
["Checked", finding.checkedAt]
|
|
|
|
|
]);
|
|
|
|
|
drawBulletList(doc, finding.notes, "No source notes.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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}`);
|
|
|
|
|
});
|
|
|
|
|
drawSectionHeader(doc, "Warnings And Degraded Conditions");
|
|
|
|
|
drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
|
|
|
|
|
|
|
|
|
|
doc.end();
|
|
|
|
|
|
|
|
|
|
|