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; } export interface ActPdfContext { companyName?: string | null; departmentName?: string | null; areaName?: string | null; scopeName?: string | null; scopeCode?: string | null; scopeTypeName?: string | null; scopeTypeCode?: string | null; yacimientoName?: string | null; yacimientoCode?: string | null; leadInspectorName?: string | null; } function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function asArray(value: unknown): Array> { 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()) ? new Intl.DateTimeFormat('es-AR', { timeZone: 'America/Argentina/Mendoza', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, }).format(parsed) : '-'; } function fullName(value: Record): string { return `${text(value.firstName)} ${text(value.lastName)}`.trim() || text(value.username); } function isPlaceholder(value: unknown): boolean { return text(value).startsWith('Acta de inspección en curso. Los Hallazgos'); } function urgency(value: unknown): string { return text(value) === 'URGENT' ? 'Urgente' : text(value) === 'NON_URGENT' ? 'No urgente' : '-'; } function dayType(value: unknown): string { return text(value) === 'BUSINESS' ? 'días hábiles' : text(value) === 'CALENDAR' ? 'días corridos' : ''; } function deadlineBasis(value: unknown): string { return text(value) === 'ACT_DATE' ? 'Desde la fecha del Acta' : text(value) === 'GEDO_DATE' ? 'Desde la notificación formal' : ''; } function manifestation(value: unknown): string { return text(value) === 'CONFORMITY' ? 'Firma sin disconformidad' : text(value) === 'DISSENT' ? 'Firma en disconformidad' : ''; } function signatureStatus(value: unknown): string { return text(value) === 'SIGNED' ? 'Firmó' : text(value) === 'REFUSED' ? 'Se negó a firmar' : text(value) === 'ABSENT' ? 'Ausente' : 'No firmó'; } // %PDF-1.4 is the document-version contract for consolidated Actas. export async function buildInspectionActPdf( snapshot: Record, images: ActPdfImage[] = [], fallbackContext: ActPdfContext = {}, ): 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 context = asRecord(locked.context); const company = asRecord(context.company); const department = asRecord(context.department); const area = asRecord(context.area); const scope = asRecord(context.scope); const leadInspector = asRecord(context.leadInspector); const inspectors = asArray(context.inspectors); const responsible = asRecord(locked.responsible); const inventories = asArray(locked.inventories); const findings = asArray(locked.findings); const signatures = asArray(sealed.signatures); const seal = asRecord(sealed.seal); 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((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); }); const blue = '#162D69'; const ink = '#202939'; const muted = '#5A667C'; 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 section = (label: string) => { need(46); doc.moveDown(0.9); doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); doc.moveTo(54, doc.y + 3).lineTo(540, doc.y + 3).strokeColor('#D5DCE8').stroke(); doc.moveDown(0.55); }; const body = (value: unknown) => { need(24); doc.font('body').fillColor(ink).fontSize(10.2).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); }; const label = (name: string, value: unknown, allowEmpty = false) => { const rendered = text(value); if (!rendered && !allowEmpty) return; need(24); doc.font('body-bold').fillColor(ink).fontSize(9.8).text(`${name}: `, { continued: true }); doc.font('body').text(rendered || '-'); doc.moveDown(0.28); }; const subheading = (value: string) => { need(32); doc.font('body-bold').fillColor(blue).fontSize(10.8).text(value); doc.moveDown(0.25); }; 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(muted).text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 }); doc.moveDown(0.5); }; doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`); doc.moveDown(0.15); doc.font('body').fillColor(muted).fontSize(9).text('Documento consolidado de actuación inspectiva'); section('1. Identificación del Acta'); label('Código del Acta', act.code); if (act.actNumber || act.actYear) label('Número / Año', `${text(act.actNumber, '-')} / ${text(act.actYear, '-')}`); label('Inspección', inspection.code); label('Fecha y hora de actuación', date(act.occurredAt)); label('Estado documental', 'Sellada'); label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion); section('2. Contexto territorial y operativo'); label('Empresa / Operadora', company.name ?? fallbackContext.companyName); label('Departamento', department.name ?? fallbackContext.departmentName); label('Área', area.name ?? fallbackContext.areaName); const pathYacimientos = inventories.flatMap((inventory) => asArray(inventory.path)) .filter((node) => text(node.typeCode).toLowerCase() === 'yacimiento'); const scopeTypeCode = text(scope.typeCode ?? fallbackContext.scopeTypeCode).toLowerCase(); const yacimientoNames = Array.from(new Set([ ...(scopeTypeCode === 'yacimiento' ? [text(scope.name ?? fallbackContext.scopeName)] : []), ...pathYacimientos.map((node) => text(node.name)), text(fallbackContext.yacimientoName), ].filter(Boolean))); const yacimientoCodes = Array.from(new Set([ ...(scopeTypeCode === 'yacimiento' ? [text(scope.code ?? fallbackContext.scopeCode)] : []), ...pathYacimientos.map((node) => text(node.code)), text(fallbackContext.yacimientoCode), ].filter(Boolean))); if (yacimientoNames.length) label(yacimientoNames.length > 1 ? 'Yacimientos' : 'Yacimiento', yacimientoNames.join(', ')); if (yacimientoCodes.length) label(yacimientoCodes.length > 1 ? 'Códigos de yacimiento' : 'Código de yacimiento', yacimientoCodes.join(', ')); if (scopeTypeCode && scopeTypeCode !== 'yacimiento') { const scopeLabel = `${text(scope.typeName ?? fallbackContext.scopeTypeName)} - ${text(scope.name ?? fallbackContext.scopeName)}${text(scope.code ?? fallbackContext.scopeCode) ? ` [${text(scope.code ?? fallbackContext.scopeCode)}]` : ''}`; label('Alcance de la inspección', scopeLabel); } label('Inicio efectivo de la inspección', date(inspection.actualStartedAt)); section('3. Intervinientes'); const leadName = fullName(leadInspector) || fallbackContext.leadInspectorName || ''; label('Inspector/a responsable', leadName); const otherInspectors = inspectors.map(fullName).filter((name) => name && name !== leadName); if (otherInspectors.length) label('Otros inspectores actuantes', otherInspectors.join(', ')); label('Situación del representante', text(responsible.attendanceStatus) === 'ABSENT' ? 'Ausente' : 'Presente'); label('Representante de la empresa', responsible.fullName); const document = [text(responsible.documentType), text(responsible.documentNumber)].filter(Boolean).join(' '); label('Documento', document); label('Cargo o función', responsible.position); label('Correo electrónico', responsible.email); label('Teléfono', responsible.phone); if (text(responsible.attendanceStatus) === 'ABSENT') label('Motivo de ausencia', responsible.absenceReason); section('4. Datos del Acta'); label('Objeto / Denominación', act.title); label('Urgencia', urgency(act.urgency)); if (act.deadlineDays != null) { const duration = `${text(act.deadlineDays)} ${dayType(act.deadlineDayType)}`.trim(); label('Plazo', duration); label('Cómputo del plazo', deadlineBasis(act.deadlineBasis)); if (act.deadlineBaseAt) label('Fecha base', date(act.deadlineBaseAt)); if (act.deadlineAt) label('Vencimiento', date(act.deadlineAt)); } subheading('Descripción de 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 (text(act.observations)) { subheading('Observaciones generales'); body(act.observations); } section('5. Hallazgos'); if (!findings.length) body('No se registraron hallazgos en esta Acta.'); for (const finding of findings) { const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {}; const catalog = asRecord(finding.catalog); const recurrence = asRecord(finding.recurrence); const path = asArray(inventory.path); const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > '); const number = text(finding.findingNumber, '?'); need(100); doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`); doc.moveDown(0.2); label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`); if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName); if (route) label('Ubicación / Ruta jerárquica', route); label('Denominación del hallazgo', finding.title); subheading('Qué se constató'); body(finding.description); if (text(catalog.id)) { const source = [text(catalog.categoryName), text(catalog.title), text(catalog.code), catalog.sourceNumber ? `Ítem ${text(catalog.sourceNumber)}` : ''].filter(Boolean).join(' · '); label('Referencia de catálogo', source); } else { label('Referencia de catálogo', 'OTROS / hallazgo cargado en campo'); } if (text(finding.glossary)) label('Criterio / Referencia técnica', finding.glossary); if (text(finding.legalBasis)) label('Normativa / Base legal', finding.legalBasis); if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`); if (finding.isRecurrence) { label('Reincidencia', 'Sí'); if (text(recurrence.code) || text(recurrence.title)) label('Antecedente relacionado', `${text(recurrence.code)}${text(recurrence.title) ? ` - ${text(recurrence.title)}` : ''}`); } else { label('Reincidencia', 'No'); } const photos = images.filter((item) => item.findingId === text(finding.id)); if (photos.length) { subheading(`Evidencia fotográfica (${photos.length})`); for (const photo of photos) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); } else { label('Evidencia fotográfica', 'Sin fotografías asociadas'); } doc.moveDown(0.7); } section('6. Firmas y manifestaciones'); for (const signature of signatures) { const name = text(signature.signerName); const inspector = text(signature.signerType) === 'INSPECTOR'; const role = inspector ? 'Inspector/a' : 'Representante de la empresa'; subheading(`${role}: ${name}`); const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' '); if (signedDocument) label('Documento', signedDocument); label('Cargo o función', signature.position); label('Resultado', signatureStatus(signature.status)); if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation)); if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement); if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.reason); label('Fecha y hora de la constancia', date(signature.signedAt ?? signature.createdAt)); if (text(signature.source)) label('Origen de la constancia', text(signature.source) === 'ANDROID' ? 'Aplicación móvil' : 'Dashboard web'); 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; } doc.moveDown(0.5); } section('7. Integridad y cierre'); label('Contenido bloqueado', date(locked.lockedAt)); label('Sellado en servidor', date(seal.serverSealedAt)); label('Fecha informada por dispositivo', date(seal.deviceSealedAt)); label('Modo de cierre', seal.uploadMode); body(`SHA-256 del cierre: ${hash}`); body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`); need(28); doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y); doc.end(); const buffer = await done; return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; }