mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
export loading
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PdfRenderService } from "./pdf-render.service";
|
||||
import {
|
||||
PdfColor,
|
||||
assembleSinglePagePdf,
|
||||
lineOp,
|
||||
rectOp,
|
||||
sealOp,
|
||||
textOp,
|
||||
textOpRight,
|
||||
wrapText,
|
||||
} from "./styled-pdf.util";
|
||||
|
||||
export type InvoiceDocumentKind = "INVOICE" | "RECEIPT";
|
||||
|
||||
@@ -62,10 +72,136 @@ export class InvoiceDocumentService {
|
||||
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
|
||||
return {
|
||||
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }),
|
||||
buffer: await this.pdf.htmlToPdfBuffer(html, {
|
||||
label: `${model.title} ${kindLabel}`,
|
||||
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
|
||||
// summary grid, line-item table, totals) from the model — not a flat
|
||||
// plain-text dump — so it still reads as a proper invoice document.
|
||||
fallback: () => this.buildFallbackPdf(model),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector-drawn styled invoice/receipt used when headless Chromium is
|
||||
* unavailable. Mirrors the HTML layout closely enough to pass as the same
|
||||
* document. Single A4 page; long summaries / line lists are capped to fit.
|
||||
*/
|
||||
buildFallbackPdf(model: InvoiceDocumentModel): Buffer {
|
||||
const currency = (cur?: string | null) =>
|
||||
(cur ?? model.currency) === "ETB" ? "ETB" : (cur ?? model.currency);
|
||||
const money = (amount: unknown, cur?: string | null) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency(cur)}`;
|
||||
const date = (value: unknown) =>
|
||||
value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-";
|
||||
|
||||
const heading = `${model.title} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}`;
|
||||
const sealText =
|
||||
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
|
||||
const showCategory = Boolean(model.categoryHeader);
|
||||
|
||||
const ops: string[] = [];
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 806, 559, 806, PdfColor.teal, 2.4));
|
||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", 36, 790, 8.5, "F2", PdfColor.gray));
|
||||
const titleSize = heading.length > 34 ? 18 : 22;
|
||||
ops.push(textOp(heading, 36, 762, titleSize, "F2", PdfColor.dark));
|
||||
|
||||
ops.push(textOpRight("DOCUMENT NO.", 559, 792, 7.5, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight(model.documentNumber, 559, 776, 12, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(`Issued ${date(model.issuedAt)}`, 559, 762, 8.5, "F1", PdfColor.gray));
|
||||
ops.push(
|
||||
textOpRight(
|
||||
`Status ${model.status}`,
|
||||
559,
|
||||
748,
|
||||
8.5,
|
||||
"F1",
|
||||
model.status === "PAID" ? PdfColor.teal : PdfColor.gray,
|
||||
),
|
||||
);
|
||||
ops.push(lineOp(36, 736, 470, 736, PdfColor.line, 1));
|
||||
|
||||
// ── Seal ──────────────────────────────────────────────────────────────
|
||||
ops.push(sealOp(516, 706, 27, sealText.split(/\s+/), PdfColor.teal));
|
||||
|
||||
// ── Summary grid (two columns) ────────────────────────────────────────
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
ops.push(textOp(this.clip(row.value ?? "-", 44), x, y - 11, 9, "F2", PdfColor.dark));
|
||||
ops.push(lineOp(x, y - 15, x + colW, y - 15, PdfColor.line, 0.6));
|
||||
});
|
||||
y -= 34;
|
||||
|
||||
// ── Line-item table ───────────────────────────────────────────────────
|
||||
const qtyR = 402;
|
||||
const rateR = 486;
|
||||
const amtR = 555;
|
||||
ops.push(rectOp(36, y - 18, 523, 18, PdfColor.shade, PdfColor.line, 0.7));
|
||||
ops.push(textOp("DESCRIPTION", 40, y - 13, 8, "F2", PdfColor.gray));
|
||||
if (showCategory) {
|
||||
ops.push(textOp((model.categoryHeader ?? "").toUpperCase(), 250, y - 13, 8, "F2", PdfColor.gray));
|
||||
}
|
||||
ops.push(textOpRight("QTY", qtyR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("RATE", rateR, y - 13, 8, "F2", PdfColor.gray));
|
||||
ops.push(textOpRight("AMOUNT", amtR, y - 13, 8, "F2", PdfColor.gray));
|
||||
y -= 18;
|
||||
|
||||
const descChars = showCategory ? 44 : 66;
|
||||
for (const item of model.lines) {
|
||||
if (y < 190) break; // leave room for totals + footer
|
||||
const descLines = wrapText(item.description ?? "-", descChars).slice(0, 2);
|
||||
const rowH = Math.max(18, descLines.length * 10 + 8);
|
||||
ops.push(rectOp(36, y - rowH, 523, rowH, "1 1 1", PdfColor.line, 0.6));
|
||||
descLines.forEach((line, k) => {
|
||||
ops.push(textOp(line, 40, y - 12 - k * 10, 8, "F1", PdfColor.dark));
|
||||
});
|
||||
if (showCategory) {
|
||||
ops.push(textOp(this.clip((item.category ?? "").replace(/_/g, " "), 18), 250, y - 12, 8, "F1", PdfColor.dark));
|
||||
}
|
||||
ops.push(textOpRight(String(item.quantity ?? 0), qtyR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.unitRate, item.currency), rateR, y - 12, 8, "F1", PdfColor.dark));
|
||||
ops.push(textOpRight(money(item.amount, item.currency), amtR, y - 12, 8, "F1", PdfColor.dark));
|
||||
y -= rowH;
|
||||
}
|
||||
|
||||
// ── Totals ────────────────────────────────────────────────────────────
|
||||
let ty = y - 16;
|
||||
for (const total of model.totals) {
|
||||
if (ty < 88) break;
|
||||
if (total.grand) {
|
||||
ops.push(lineOp(315, ty + 5, 559, ty + 5, PdfColor.dark, 0.9));
|
||||
ops.push(textOp(total.label, 320, ty - 9, 11, "F2", PdfColor.dark));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 9, 12, "F2", PdfColor.dark));
|
||||
ty -= 24;
|
||||
} else {
|
||||
ops.push(textOp(total.label, 320, ty - 8, 9.5, "F1", PdfColor.gray));
|
||||
ops.push(textOpRight(money(total.amount), 555, ty - 8, 10, "F1", PdfColor.dark));
|
||||
ty -= 17;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
ops.push(lineOp(36, 64, 250, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Prepared by EDR finance", 36, 52, 7.5, "F1", PdfColor.gray));
|
||||
ops.push(lineOp(340, 64, 559, 64, PdfColor.dark, 0.8));
|
||||
ops.push(textOp("Authorized seal / signature", 340, 52, 7.5, "F1", PdfColor.gray));
|
||||
|
||||
return assembleSinglePagePdf(ops);
|
||||
}
|
||||
|
||||
/** Truncate to `max` chars with an ellipsis. */
|
||||
private clip(value: string, max: number): string {
|
||||
const text = String(value ?? "");
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
buildHtml(model: InvoiceDocumentModel): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? "-")
|
||||
|
||||
Reference in New Issue
Block a user