export loading

This commit is contained in:
Hagernesh
2026-07-06 18:53:25 +00:00
parent 692d9074d0
commit bc47a42e1e
7 changed files with 856 additions and 4 deletions

View File

@@ -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 ?? "-")