122 lines
6.8 KiB
TypeScript
122 lines
6.8 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { existsSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import PDFDocument = require('pdfkit');
|
|
|
|
export interface ActPdfImage {
|
|
id: string;
|
|
findingId?: string;
|
|
assetId?: string;
|
|
signerName?: string;
|
|
title?: string;
|
|
capturedAt?: string | Date | null;
|
|
sha256: string;
|
|
buffer: Buffer;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
function asArray(value: unknown): Array<Record<string, unknown>> {
|
|
return Array.isArray(value) ? value.map(asRecord) : [];
|
|
}
|
|
function text(value: unknown, fallback = ''): string {
|
|
return String(value ?? '').trim() || fallback;
|
|
}
|
|
function date(value: unknown): string {
|
|
const parsed = new Date(String(value ?? ''));
|
|
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString('es-AR', { timeZone: 'America/Argentina/Mendoza', dateStyle: 'short', timeStyle: 'short' }) : '-';
|
|
}
|
|
function isPlaceholder(value: unknown): boolean {
|
|
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
|
|
}
|
|
|
|
// %PDF-1.4 is the document-version contract for consolidated Actas.
|
|
export async function buildInspectionActPdf(snapshot: Record<string, unknown>, images: ActPdfImage[] = [], context: { companyName?: string | null; areaName?: string | null; scopeName?: string | null } = {}): Promise<{ buffer: Buffer; sha256: string }> {
|
|
const sealed = asRecord(snapshot);
|
|
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
|
const act = asRecord(locked.act);
|
|
const inspection = asRecord(act.inspection ?? act.visit);
|
|
const responsible = asRecord(locked.responsible);
|
|
const findings = asArray(locked.findings);
|
|
const signatures = asArray(sealed.signatures);
|
|
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
|
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
|
const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
|
|
doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
|
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
|
|
const chunks: Buffer[] = [];
|
|
doc.on('data', (part: Buffer) => chunks.push(part));
|
|
const done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
|
|
const blue = '#162D69';
|
|
const header = () => {
|
|
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
|
doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
|
|
if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] });
|
|
doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke();
|
|
doc.y = 146;
|
|
};
|
|
doc.on('pageAdded', header);
|
|
header();
|
|
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
|
const heading = (label: string) => { need(48); doc.moveDown(1); doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); doc.moveDown(0.35); };
|
|
const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); };
|
|
const label = (name: string, value: unknown) => { if (!text(value)) return; need(22); doc.font('body-bold').fillColor('#202939').fontSize(10).text(`${name}: `, { continued: true }); doc.font('body').text(text(value)); doc.moveDown(0.35); };
|
|
const image = (entry: ActPdfImage, caption: string) => {
|
|
need(235);
|
|
const y = doc.y;
|
|
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
|
|
doc.y = y + 195;
|
|
doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
|
doc.moveDown(0.5);
|
|
};
|
|
|
|
doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
|
doc.moveDown(0.5);
|
|
label('Inspección', inspection.code);
|
|
label('Empresa inspeccionada', context.companyName);
|
|
label('Área', context.areaName);
|
|
label('Yacimiento o instalación', context.scopeName);
|
|
label('Fecha y hora', date(act.occurredAt));
|
|
label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : '');
|
|
label('Representante de la empresa', responsible.fullName);
|
|
label('DNI', responsible.documentNumber);
|
|
label('Cargo o función', responsible.position);
|
|
heading('Lo actuado');
|
|
if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
|
|
else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`);
|
|
if (act.observations) { label('Observaciones', act.observations); }
|
|
heading('Hallazgos y fotografías');
|
|
if (!findings.length) body('No se registraron hallazgos.');
|
|
for (const finding of findings) {
|
|
need(75);
|
|
doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`);
|
|
label('Descripción', finding.description);
|
|
if (finding.legalBasis) label('Normativa consignada', finding.legalBasis);
|
|
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
|
for (const photo of images.filter((item) => item.findingId === text(finding.id))) {
|
|
image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
|
}
|
|
doc.moveDown(0.4);
|
|
}
|
|
heading('Intervinientes y firmas');
|
|
for (const signature of signatures) {
|
|
const name = text(signature.signerName);
|
|
const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa';
|
|
const status = text(signature.status);
|
|
label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`);
|
|
if (signature.companyManifestation === 'DISSENT') label('Disconformidad', signature.companyStatement);
|
|
if (status === 'REFUSED') label('Motivo de negativa', signature.reason);
|
|
const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256));
|
|
if (signatureImage) { need(100); const y = doc.y; doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] }); doc.y = y + 66; }
|
|
}
|
|
heading('Integridad del Acta');
|
|
body(`SHA-256 del cierre: ${hash}`);
|
|
body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`);
|
|
need(25);
|
|
doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y);
|
|
doc.end();
|
|
const buffer = await done;
|
|
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
|
}
|