mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
273 lines
11 KiB
TypeScript
273 lines
11 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
|
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
|
|
|
const MIN_VALID_PDF_BYTES = 2_000;
|
|
|
|
@Injectable()
|
|
export class WarehouseReleaseDocumentService {
|
|
constructor(private readonly pdf: PdfRenderService) {}
|
|
|
|
/**
|
|
* Render the gate-clearance release document to PDF via the shared renderer,
|
|
* falling back to the release-specific hand-built layout when Chromium is
|
|
* unavailable.
|
|
*/
|
|
htmlToPdfBuffer(html: string): Promise<Buffer> {
|
|
return this.pdf.htmlToPdfBuffer(html, {
|
|
label: 'Warehouse release',
|
|
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render arbitrary document HTML to PDF via the shared renderer WITHOUT the
|
|
* release-order fallback. Non-release documents (e.g. the import/export
|
|
* marshalling load list) must use this so a Chromium-less fallback degrades to
|
|
* a plain-text dump of *their own* content — instead of masquerading as a
|
|
* "Warehouse Gate Clearance / Release Order", which the release-specific
|
|
* fallback would otherwise draw regardless of the input HTML.
|
|
*/
|
|
renderDocumentHtml(html: string, label = 'Document'): Promise<Buffer> {
|
|
return this.pdf.htmlToPdfBuffer(html, { label });
|
|
}
|
|
|
|
/**
|
|
* Render a "summary tiles + one table + notice + signatures" document (the
|
|
* marshalling / load-list layout) with a STYLED table-aware fallback for when
|
|
* Chromium is unavailable — so the manifest draws as a real gridded document
|
|
* instead of a flat plain-text dump.
|
|
*/
|
|
renderTabularDocument(html: string, label = 'Document'): Promise<Buffer> {
|
|
return this.pdf.htmlToPdfBuffer(html, {
|
|
label,
|
|
fallback: (preparedHtml) => buildTabularFallbackPdf(preparedHtml),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render document HTML with a STYLED hand-built fallback (the release layout,
|
|
* but with a custom title + section heading) for when Chromium is unavailable.
|
|
* Handover / GRN use this so their fallback looks like a proper document —
|
|
* not a plain-text dump, and not mislabelled as a release order.
|
|
*/
|
|
renderStyledDocument(
|
|
html: string,
|
|
fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
|
|
label = 'Document',
|
|
): Promise<Buffer> {
|
|
return this.pdf.htmlToPdfBuffer(html, {
|
|
label,
|
|
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts),
|
|
});
|
|
}
|
|
|
|
private htmlToBasicPdfBuffer(
|
|
html: string,
|
|
opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
|
|
): Buffer {
|
|
const doc = this.extractReleaseDocument(html);
|
|
const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3);
|
|
const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION';
|
|
const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS';
|
|
const refLabel = opts?.refLabel ?? 'Document / Release No.';
|
|
const body: string[] = [
|
|
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
|
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
|
...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')),
|
|
this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'),
|
|
this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
|
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
|
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
|
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
|
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
|
|
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
|
|
...this.wrapLines(doc.notice, 68)
|
|
.slice(0, 4)
|
|
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
|
this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
|
];
|
|
|
|
let y = 586;
|
|
const rowHeight = 20;
|
|
for (const [label, value] of doc.rows.slice(0, 14)) {
|
|
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
|
|
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
|
|
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
|
|
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
|
|
y -= rowHeight;
|
|
}
|
|
|
|
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
|
|
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
|
|
body.push(
|
|
...this.wrapLines(doc.clause, 92)
|
|
.slice(0, 4)
|
|
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
|
|
);
|
|
|
|
const stream = [
|
|
...body,
|
|
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
|
|
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
|
|
this.circularSealOps(286, 62, 38),
|
|
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
|
|
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
|
|
].join('\n');
|
|
|
|
const objects = [
|
|
'<< /Type /Catalog /Pages 2 0 R >>',
|
|
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
|
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
|
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>',
|
|
'<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>',
|
|
`<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`,
|
|
];
|
|
|
|
let pdf = '%PDF-1.4\n';
|
|
const offsets: number[] = [0];
|
|
objects.forEach((object, index) => {
|
|
offsets.push(Buffer.byteLength(pdf, 'latin1'));
|
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
|
});
|
|
while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) {
|
|
pdf += '% fallback padding\n';
|
|
}
|
|
const xrefOffset = Buffer.byteLength(pdf, 'latin1');
|
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
|
pdf += '0000000000 65535 f \n';
|
|
for (const offset of offsets.slice(1)) {
|
|
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
|
|
}
|
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
|
return Buffer.from(pdf, 'latin1');
|
|
}
|
|
|
|
private extractReleaseDocument(html: string): {
|
|
reference: string;
|
|
issuedAt: string;
|
|
notice: string;
|
|
clause: string;
|
|
rows: Array<[string, string]>;
|
|
} {
|
|
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
|
|
const reference = textFromHtml(html.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
|
|
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
|
|
const notice = textFromHtml(
|
|
html.match(/<div class="notice">([\s\S]*?)<\/div>/i)?.[1] ??
|
|
'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.',
|
|
);
|
|
const clause = textFromHtml(
|
|
html.match(/<div class="clause">([\s\S]*?)<\/div>/i)?.[1] ??
|
|
'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.',
|
|
);
|
|
const rows: Array<[string, string]> = [];
|
|
for (const match of html.matchAll(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
|
|
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
|
|
}
|
|
return { reference, issuedAt, notice, clause, rows };
|
|
}
|
|
|
|
private htmlToPlainText(html: string): string {
|
|
return html
|
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ /gi, ' ')
|
|
.replace(/&/gi, '&')
|
|
.replace(/</gi, '<')
|
|
.replace(/>/gi, '>')
|
|
.replace(/"/gi, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-')
|
|
.split('\n')
|
|
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
private wrapLines(text: string, width: number): string[] {
|
|
const wrapped: string[] = [];
|
|
for (const rawLine of text.split('\n')) {
|
|
const words = rawLine.split(' ');
|
|
let line = '';
|
|
for (const word of words) {
|
|
const next = line ? `${line} ${word}` : word;
|
|
if (next.length > width && line) {
|
|
wrapped.push(line);
|
|
line = word;
|
|
} else {
|
|
line = next;
|
|
}
|
|
}
|
|
if (line) wrapped.push(line);
|
|
}
|
|
return wrapped.length ? wrapped : ['Warehouse release document'];
|
|
}
|
|
|
|
private escapePdfText(value: string): string {
|
|
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
|
}
|
|
|
|
private textOp(
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
size: number,
|
|
font: 'F1' | 'F2' = 'F1',
|
|
color = '0 0 0',
|
|
): string {
|
|
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
|
|
}
|
|
|
|
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
|
|
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
|
}
|
|
|
|
private rectOp(
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
fillColor = '1 1 1',
|
|
strokeColor = '0.08 0.32 0.18',
|
|
lineWidth = 0.8,
|
|
): string {
|
|
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
|
}
|
|
|
|
private circularSealOps(cx: number, cy: number, radius = 51): string {
|
|
return [
|
|
'q',
|
|
'0.08 0.32 0.18 RG',
|
|
'0.08 0.32 0.18 rg',
|
|
'2.2 w',
|
|
this.circlePath(cx, cy, radius),
|
|
'S',
|
|
'0.8 w',
|
|
this.circlePath(cx, cy, radius - 10),
|
|
'S',
|
|
this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
|
|
this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
|
|
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
|
|
'Q',
|
|
].join('\n');
|
|
}
|
|
|
|
private circlePath(cx: number, cy: number, r: number): string {
|
|
const k = 0.5522847498;
|
|
const c = r * k;
|
|
return [
|
|
`${cx + r} ${cy} m`,
|
|
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
|
|
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
|
|
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
|
|
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
|
|
'h',
|
|
].join('\n');
|
|
}
|
|
}
|