mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
feat(billing): 80mm thermal invoice layout (ADD-P001)
GET billing/invoices/:id/document?format=thermal renders a dedicated 80mm receipt template (72mm printable, 4mm margins each side), not a CSS variant of the A4 layout — the A4 CSS is absolutely-positioned/fixed-px, tuned for a 210mm page, and doesn't reflow at thermal width. No seal (not a thermal convention, renders badly on 1-bit thermal heads); line items stack (description, then qty x rate = amount) instead of a table, since a real table leaves ~10-14 chars for description at this width. PdfRenderService gains a thermal render path: full 80mm-width viewport, content height measured via page.evaluate after settle (continuous-roll receipts have no fixed page length), and a noFallback option — a Chromium failure throws a clear error instead of silently degrading to the generic A4/no-QR fallback, which would hand back a different document than what was asked for. The frontend surfaces that as a toast pointing at the existing A4 download. format is strictly validated (a4|thermal only, BadRequestException otherwise), not silently coerced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -113,9 +114,19 @@ export class BillingController {
|
|||||||
|
|
||||||
@Get("invoices/:id/document")
|
@Get("invoices/:id/document")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
@ApiOperation({
|
||||||
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
summary:
|
||||||
const { filename, buffer } = await this.billingService.document(id);
|
'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).',
|
||||||
|
})
|
||||||
|
async document(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query("format") format: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (format !== undefined && format !== "a4" && format !== "thermal") {
|
||||||
|
throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`);
|
||||||
|
}
|
||||||
|
const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4");
|
||||||
sendPdf(res, filename, buffer);
|
sendPdf(res, filename, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -927,6 +927,7 @@ describe("BillingService.document", () => {
|
|||||||
|
|
||||||
const build = (invoice: Record<string, unknown>) => {
|
const build = (invoice: Record<string, unknown>) => {
|
||||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||||
|
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||||
const service = new BillingService(
|
const service = new BillingService(
|
||||||
{} as never,
|
{} as never,
|
||||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||||
@@ -934,7 +935,7 @@ describe("BillingService.document", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ render } as never,
|
{ render, renderThermal } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{
|
{
|
||||||
get: (key: string) =>
|
get: (key: string) =>
|
||||||
@@ -943,7 +944,7 @@ describe("BillingService.document", () => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
} as never, // config
|
} as never, // config
|
||||||
);
|
);
|
||||||
return { service, render };
|
return { service, render, renderThermal };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
||||||
@@ -1000,4 +1001,24 @@ describe("BillingService.document", () => {
|
|||||||
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
||||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls render (not renderThermal) for the default format", async () => {
|
||||||
|
const { service, render, renderThermal } = build(invoiceRow());
|
||||||
|
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
await service.document("inv-1");
|
||||||
|
|
||||||
|
expect(render).toHaveBeenCalledTimes(1);
|
||||||
|
expect(renderThermal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls renderThermal (not render) for format 'thermal'", async () => {
|
||||||
|
const { service, render, renderThermal } = build(invoiceRow());
|
||||||
|
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
await service.document("inv-1", "thermal");
|
||||||
|
|
||||||
|
expect(renderThermal).toHaveBeenCalledTimes(1);
|
||||||
|
expect(render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -411,12 +411,20 @@ export class BillingService {
|
|||||||
|
|
||||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
/**
|
||||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
* Sealed PDF invoice for any source, rendered by the shared document service. `format`
|
||||||
|
* validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input
|
||||||
|
* boundary check, not a business rule.
|
||||||
|
*/
|
||||||
|
async document(
|
||||||
|
id: string,
|
||||||
|
format: "a4" | "thermal" = "a4",
|
||||||
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
return this.invoiceDocuments.render(
|
const model = await this.toDocumentModel(invoice, "INVOICE");
|
||||||
await this.toDocumentModel(invoice, "INVOICE"),
|
return format === "thermal"
|
||||||
);
|
? this.invoiceDocuments.renderThermal(model)
|
||||||
|
: this.invoiceDocuments.render(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||||
|
|||||||
@@ -44,3 +44,46 @@ describe("InvoiceDocumentService.buildHtml — EIMS QR", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("InvoiceDocumentService.buildThermalHtml", () => {
|
||||||
|
const service = new InvoiceDocumentService({} as never, {} as never, {} as never);
|
||||||
|
|
||||||
|
it("renders no seal markup at all — dropped for thermal, not shrunk", () => {
|
||||||
|
const html = service.buildThermalHtml(model());
|
||||||
|
expect(html).not.toContain('class="seal"');
|
||||||
|
expect(html).not.toContain("seal-image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the QR image when qrImageUrl is set, centered rather than absolutely positioned", () => {
|
||||||
|
const html = service.buildThermalHtml(model({ qrImageUrl: "data:image/png;base64,QR" }));
|
||||||
|
expect(html).toContain('class="qr"');
|
||||||
|
expect(html).toContain('src="data:image/png;base64,QR"');
|
||||||
|
expect(html).not.toContain("position: absolute");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps a long IRN summary value rather than truncating it", () => {
|
||||||
|
const irn = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
const html = service.buildThermalHtml(model({ summary: [{ label: "EIMS IRN", value: irn }] }));
|
||||||
|
expect(html).toContain(irn);
|
||||||
|
expect(html).toContain("overflow-wrap: anywhere");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a line item as stacked description + qty x rate = amount, not a table row", () => {
|
||||||
|
const html = service.buildThermalHtml(
|
||||||
|
model({
|
||||||
|
lines: [{ description: "40ft container rail freight", quantity: 12, unitRate: 245683.95, amount: 2948207.4 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("<table");
|
||||||
|
expect(html).not.toContain("<td");
|
||||||
|
expect(html).toContain("40ft container rail freight");
|
||||||
|
expect(html).toContain("12 x");
|
||||||
|
expect(html).toContain("2,948,207.4 Birr (ETB)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fluid, full-width layout — no fixed-px A4 geometry", () => {
|
||||||
|
const html = service.buildThermalHtml(model());
|
||||||
|
expect(html).not.toContain("width: 330px");
|
||||||
|
expect(html).not.toContain("right: 160px");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,27 @@ export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
|||||||
*/
|
*/
|
||||||
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
export const pngDataUrl = (base64: string): string => `data:image/png;base64,${base64}`;
|
||||||
|
|
||||||
|
// ── Shared HTML-builder helpers (buildHtml + buildThermalHtml) ──────────────────────────────────
|
||||||
|
// `buildFallbackPdf`'s own currency/money/date closures are a deliberately different, already-
|
||||||
|
// established convention (bare "ETB" vs "Birr (ETB)") for the vector renderer — not touched here.
|
||||||
|
|
||||||
|
function esc(value: unknown): string {
|
||||||
|
return String(value ?? "-")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(amount: unknown, currency: string): string {
|
||||||
|
return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: unknown): string {
|
||||||
|
return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||||
|
}
|
||||||
|
|
||||||
/** One billed line on the document (charge type / fee type agnostic). */
|
/** One billed line on the document (charge type / fee type agnostic). */
|
||||||
export interface InvoiceDocumentLine {
|
export interface InvoiceDocumentLine {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -129,6 +150,117 @@ export class InvoiceDocumentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 80mm thermal invoice (ADD-P001) — physical page is the 80mm roll width; content stays within
|
||||||
|
* `THERMAL_MARGIN_MM` of each edge via `PdfRenderService`'s margin, not a narrower page, since
|
||||||
|
* thermal print mechanisms have a dead zone at the roll edge they can't reach either way.
|
||||||
|
*
|
||||||
|
* A genuinely different template from `buildHtml`, not a CSS variant of it: the A4 layout is
|
||||||
|
* absolutely-positioned and fixed-px (`.seal{right:28px}`, `.qr{right:160px}`,
|
||||||
|
* `.totals{width:330px}`), tuned for a 210mm page — none of it reflows at 72mm printable width.
|
||||||
|
* No seal here at all (a decorative wet-ink-style stamp is an A4/laser convention; no real POS
|
||||||
|
* thermal receipt carries one, and thermal heads render rotated circles badly) and line items
|
||||||
|
* are stacked (description, then `qty x rate = amount` below it) rather than a table — a real
|
||||||
|
* multi-column table leaves ~10-14 chars for description at this width, truncating almost every
|
||||||
|
* line, which stacking avoids entirely. No Chromium-less fallback — see `renderThermal`.
|
||||||
|
*/
|
||||||
|
async renderThermal(model: InvoiceDocumentModel): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const logoImageUrl =
|
||||||
|
model.logoImageUrl !== undefined ? model.logoImageUrl : await this.logoSettings.getLogoImageUrl();
|
||||||
|
// Seal deliberately dropped — never fetched, so no stampSettings call either.
|
||||||
|
const resolvedModel: InvoiceDocumentModel = { ...model, logoImageUrl, stampImageUrl: null };
|
||||||
|
|
||||||
|
const html = this.buildThermalHtml(resolvedModel);
|
||||||
|
return {
|
||||||
|
filename: `${this.safeFilename(model.documentNumber)}-thermal.pdf`,
|
||||||
|
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||||
|
label: `${model.title} thermal invoice`,
|
||||||
|
thermal: true,
|
||||||
|
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||||
|
// printer output" — fail loudly instead; the caller has the A4 download to fall back to.
|
||||||
|
noFallback: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildThermalHtml(model: InvoiceDocumentModel): string {
|
||||||
|
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||||
|
const logoInner = logoMarkup(model.logoImageUrl, "thermal-logo");
|
||||||
|
|
||||||
|
const summaryRows = model.summary
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<div class="row"><span class="label">${esc(row.label)}</span><span class="value">${esc(row.value)}</span></div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const itemBlocks = model.lines
|
||||||
|
.map((item) => {
|
||||||
|
const currency = item.currency ?? model.currency;
|
||||||
|
return `<div class="item">
|
||||||
|
<div class="item-desc">${esc(item.description)}</div>
|
||||||
|
<div class="item-calc">${esc(item.quantity ?? 0)} x ${esc(money(item.unitRate, currency))} = <strong>${esc(money(item.amount, currency))}</strong></div>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const totalRows = model.totals
|
||||||
|
.map(
|
||||||
|
(total) =>
|
||||||
|
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const qrMarkup = model.qrImageUrl
|
||||||
|
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${esc(heading)}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 9px; color: #0f172a; margin: 0; }
|
||||||
|
.doc { width: 100%; box-sizing: border-box; }
|
||||||
|
.thermal-logo { display: block; max-height: 28px; max-width: 100%; object-fit: contain; margin: 0 auto 4px; }
|
||||||
|
.brand { text-align: center; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.title { text-align: center; font-size: 13px; font-weight: 800; margin: 2px 0; }
|
||||||
|
.meta { text-align: center; font-size: 8px; color: #475569; margin-bottom: 4px; }
|
||||||
|
.rule { border-top: 1px dashed #334155; margin: 6px 0; }
|
||||||
|
.row { display: flex; justify-content: space-between; gap: 6px; font-family: monospace; font-size: 8.5px; padding: 1px 0; }
|
||||||
|
.row .label { color: #64748b; white-space: nowrap; }
|
||||||
|
.row .value { text-align: right; overflow-wrap: anywhere; }
|
||||||
|
.item { margin: 4px 0; }
|
||||||
|
.item-desc { font-size: 9px; overflow-wrap: anywhere; }
|
||||||
|
.item-calc { text-align: right; font-family: monospace; font-size: 8.5px; }
|
||||||
|
.total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; }
|
||||||
|
.total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; }
|
||||||
|
.qr { text-align: center; margin: 8px 0; }
|
||||||
|
.qr img { width: 150px; height: 150px; }
|
||||||
|
.qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; }
|
||||||
|
.footer { text-align: center; font-size: 7px; color: #94a3b8; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
${logoInner}
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<div class="title">${esc(heading)}</div>
|
||||||
|
<div class="meta">${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}</div>
|
||||||
|
<div class="rule"></div>
|
||||||
|
${summaryRows}
|
||||||
|
<div class="rule"></div>
|
||||||
|
${itemBlocks}
|
||||||
|
<div class="rule"></div>
|
||||||
|
${totalRows}
|
||||||
|
${qrMarkup}
|
||||||
|
<div class="footer">Thank you</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||||
@@ -250,18 +382,7 @@ export class InvoiceDocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildHtml(model: InvoiceDocumentModel): string {
|
buildHtml(model: InvoiceDocumentModel): string {
|
||||||
const esc = (value: unknown) =>
|
const date = formatDate;
|
||||||
String(value ?? "-")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
const money = (amount: unknown, currency = model.currency) =>
|
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
|
||||||
const date = (value: unknown) =>
|
|
||||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
|
||||||
|
|
||||||
const showCategory = Boolean(model.categoryHeader);
|
const showCategory = Boolean(model.categoryHeader);
|
||||||
const sealText =
|
const sealText =
|
||||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||||
@@ -292,7 +413,7 @@ export class InvoiceDocumentService {
|
|||||||
const totalRows = model.totals
|
const totalRows = model.totals
|
||||||
.map(
|
.map(
|
||||||
(total) =>
|
(total) =>
|
||||||
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount))}</strong></div>`,
|
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,43 @@ const PDF_PRINT_STYLES = `
|
|||||||
}
|
}
|
||||||
</style>`;
|
</style>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream
|
||||||
|
* ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll
|
||||||
|
* it physically can't reach, so the page itself must stay 80mm (matching the roll the printer
|
||||||
|
* driver expects) with the safe area carved out by margin, not by shrinking the page.
|
||||||
|
*/
|
||||||
|
const THERMAL_PAGE_WIDTH_MM = 80;
|
||||||
|
const THERMAL_MARGIN_MM = 4;
|
||||||
|
/** Extra length past the measured content, so the cut isn't flush against the last line. */
|
||||||
|
const THERMAL_FEED_MM = 6;
|
||||||
|
/** Guard against a runaway line-item list producing an absurd page. */
|
||||||
|
const THERMAL_MAX_HEIGHT_MM = 1500;
|
||||||
|
|
||||||
export interface PdfRenderOptions {
|
export interface PdfRenderOptions {
|
||||||
/** Label used in logs to identify the document kind. */
|
/** Label used in logs to identify the document kind. */
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
/** Landscape A4 instead of the default portrait — wide tables need it. */
|
||||||
landscape?: boolean;
|
landscape?: boolean;
|
||||||
|
/**
|
||||||
|
* Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is
|
||||||
|
* measured and the page height grows to fit it, rather than a fixed page with the format's
|
||||||
|
* `format: "A4"`.
|
||||||
|
*/
|
||||||
|
thermal?: boolean;
|
||||||
|
/**
|
||||||
|
* Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a
|
||||||
|
* generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer
|
||||||
|
* output" (it silently hands back a different document shape than what was asked for); the
|
||||||
|
* caller has an existing A4 download to point the user at instead. Ignored when `fallback` is
|
||||||
|
* also supplied — an explicit fallback always wins.
|
||||||
|
*/
|
||||||
|
noFallback?: boolean;
|
||||||
/**
|
/**
|
||||||
* Degraded renderer used when Chromium is unavailable. Receives the
|
* Degraded renderer used when Chromium is unavailable. Receives the
|
||||||
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
* print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-`
|
||||||
* header). When omitted, a generic single-page fallback is produced.
|
* header). When omitted (and `noFallback` is not set), a generic single-page fallback is
|
||||||
|
* produced.
|
||||||
*/
|
*/
|
||||||
fallback?: (preparedHtml: string) => Buffer;
|
fallback?: (preparedHtml: string) => Buffer;
|
||||||
}
|
}
|
||||||
@@ -54,17 +82,31 @@ export class PdfRenderService {
|
|||||||
const browser = await puppeteer.default.launch(launchOptions);
|
const browser = await puppeteer.default.launch(launchOptions);
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
const thermal = opts.thermal ?? false;
|
||||||
|
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
||||||
|
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
|
||||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||||
await page.emulateMediaType("print");
|
await page.emulateMediaType("print");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = thermal
|
||||||
format: "A4",
|
? await page.pdf({
|
||||||
landscape: opts.landscape ?? false,
|
width: `${THERMAL_PAGE_WIDTH_MM}mm`,
|
||||||
printBackground: true,
|
height: `${await this.thermalContentHeightMm(page)}mm`,
|
||||||
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
printBackground: true,
|
||||||
});
|
margin: {
|
||||||
|
top: `${THERMAL_MARGIN_MM}mm`,
|
||||||
|
bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`,
|
||||||
|
left: `${THERMAL_MARGIN_MM}mm`,
|
||||||
|
right: `${THERMAL_MARGIN_MM}mm`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await page.pdf({
|
||||||
|
format: "A4",
|
||||||
|
landscape: opts.landscape ?? false,
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" },
|
||||||
|
});
|
||||||
|
|
||||||
const buffer = Buffer.from(pdf);
|
const buffer = Buffer.from(pdf);
|
||||||
if (!this.isValidPdf(buffer)) {
|
if (!this.isValidPdf(buffer)) {
|
||||||
@@ -79,6 +121,15 @@ export class PdfRenderService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`);
|
||||||
|
if (!opts.fallback && opts.noFallback) {
|
||||||
|
// A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal
|
||||||
|
// printer output" — it silently hands back a different document than what was asked for.
|
||||||
|
// Fail loudly instead; the caller already has a working A4 download to fall back to.
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
`${label} could not be generated — thermal rendering requires Chromium. ` +
|
||||||
|
"Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml);
|
||||||
if (this.isValidPdf(fallback)) {
|
if (this.isValidPdf(fallback)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -92,6 +143,20 @@ export class PdfRenderService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered
|
||||||
|
* content's actual height and adds feed clearance, so the PDF page is exactly as long as the
|
||||||
|
* receipt, not a fixed A4-length page with blank space at the bottom.
|
||||||
|
*/
|
||||||
|
private async thermalContentHeightMm(page: import("puppeteer").Page): Promise<number> {
|
||||||
|
// String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document`
|
||||||
|
// isn't a known global to type-check against — the string is evaluated in the page's own
|
||||||
|
// browser context regardless, same as the closure form would be.
|
||||||
|
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||||
|
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
||||||
|
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
|
||||||
|
}
|
||||||
|
|
||||||
private injectPdfPrintStyles(html: string): string {
|
private injectPdfPrintStyles(html: string): string {
|
||||||
if (html.includes("edr-pdf-print-fix")) return html;
|
if (html.includes("edr-pdf-print-fix")) return html;
|
||||||
if (html.includes("</head>")) {
|
if (html.includes("</head>")) {
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
|
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ export default function InvoiceDetailPage() {
|
|||||||
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
const { data: invoice, isLoading } = useQuery(
|
const { data: invoice, isLoading } = useQuery(
|
||||||
@@ -154,12 +157,27 @@ export default function InvoiceDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadDocument = async () => {
|
const downloadDocument = async (format?: "a4" | "thermal") => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await invoicesService.downloadDocument(id);
|
const { data } = await invoicesService.downloadDocument(id, format);
|
||||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
const suffix = format === "thermal" ? "-thermal" : "";
|
||||||
|
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
|
||||||
|
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
|
||||||
|
// let it become a silent unhandled rejection with just a spinner stopping.
|
||||||
|
toast({
|
||||||
|
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
|
||||||
|
description:
|
||||||
|
format === "thermal"
|
||||||
|
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: undefined,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
@@ -202,17 +220,34 @@ export default function InvoiceDetailPage() {
|
|||||||
subtitle={humanize(invoice.source)}
|
subtitle={humanize(invoice.source)}
|
||||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||||
action={
|
action={
|
||||||
<ActionIcon
|
<Menu position="bottom-end" withinPortal>
|
||||||
variant="default"
|
<Menu.Target>
|
||||||
size="lg"
|
<ActionIcon
|
||||||
radius="md"
|
variant="default"
|
||||||
aria-label="Download invoice"
|
size="lg"
|
||||||
disabled={!canExport}
|
radius="md"
|
||||||
loading={downloading}
|
aria-label="Download invoice"
|
||||||
onClick={() => void downloadDocument()}
|
disabled={!canExport}
|
||||||
>
|
loading={downloading}
|
||||||
<Download size={16} />
|
>
|
||||||
</ActionIcon>
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
onClick={() => void downloadDocument("a4")}
|
||||||
|
>
|
||||||
|
Download PDF (A4)
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Printer size={14} />}
|
||||||
|
onClick={() => void downloadDocument("thermal")}
|
||||||
|
>
|
||||||
|
Download thermal invoice (80mm)
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ export const invoicesService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadDocument(id: string) {
|
/** `format` omitted or "a4" → standard A4 PDF; "thermal" → 80mm thermal layout (ADD-P001). */
|
||||||
|
downloadDocument(id: string, format?: "a4" | "thermal") {
|
||||||
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
return apiClient.get<Blob>(URL_CONSTANTS.BILLING.INVOICE_DOCUMENT(id), {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
|
params: format ? { format } : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user