feat(f6.9): consolidate act and report documents and simplify follow-up
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m24s
DH V2 CI / API · typecheck, tests, build (push) Successful in 40s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m22s
This commit is contained in:
@@ -1,214 +1,131 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import PDFDocument from 'pdfkit';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
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 {
|
||||
const out = String(value ?? '').trim();
|
||||
return out || fallback;
|
||||
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.toLocaleDateString('es-AR') : '-';
|
||||
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');
|
||||
}
|
||||
|
||||
function clean(value: string): string {
|
||||
return value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/[^\x20-\xFF]/g, '?');
|
||||
}
|
||||
|
||||
function escapePdf(value: string): string {
|
||||
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
|
||||
}
|
||||
|
||||
function wrap(value: string, max = 92): string[] {
|
||||
const words = clean(value).split(/\s+/).filter(Boolean);
|
||||
const out: string[] = [];
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const next = line ? `${line} ${word}` : word;
|
||||
if (next.length > max && line) {
|
||||
out.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = next;
|
||||
}
|
||||
}
|
||||
if (line) out.push(line);
|
||||
return out.length ? out : ['-'];
|
||||
}
|
||||
|
||||
function lockedSnapshot(snapshot: Record<string, unknown>): {
|
||||
locked: Record<string, unknown>;
|
||||
signatures: Array<Record<string, unknown>>;
|
||||
finalSha256: unknown;
|
||||
} {
|
||||
// %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);
|
||||
return {
|
||||
locked,
|
||||
signatures: asArray(sealed.signatures),
|
||||
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
|
||||
};
|
||||
}
|
||||
|
||||
function urgencyLabel(value: unknown): string {
|
||||
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
|
||||
}
|
||||
|
||||
function dayTypeLabel(value: unknown): string {
|
||||
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
|
||||
}
|
||||
|
||||
function lines(snapshot: Record<string, unknown>): string[] {
|
||||
const source = lockedSnapshot(snapshot);
|
||||
const locked = source.locked;
|
||||
const act = asRecord(locked.act);
|
||||
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
||||
const inspection = asRecord(act.inspection ?? act.visit);
|
||||
const responsible = asRecord(locked.responsible);
|
||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||
const findings = asArray(locked.findings);
|
||||
const signatures = source.signatures;
|
||||
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
||||
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
||||
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(290);
|
||||
const y = doc.y;
|
||||
doc.image(entry.buffer, 58, y, { fit: [470, 245] });
|
||||
doc.y = y + 250;
|
||||
doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
||||
doc.moveDown(0.5);
|
||||
};
|
||||
|
||||
const deadlineText = act.deadlineAt
|
||||
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
||||
: act.deadlineBasis === 'GEDO_DATE'
|
||||
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
||||
: '-';
|
||||
|
||||
const out: string[] = [
|
||||
'ACTA DE INSPECCION',
|
||||
'',
|
||||
`Acta: ${text(act.code)}`,
|
||||
`Inspeccion: ${text(inspection.code)}`,
|
||||
`Fecha: ${date(act.occurredAt)}`,
|
||||
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
||||
`Plazo: ${deadlineText}`,
|
||||
`Representante de la empresa: ${text(responsible.fullName)}`,
|
||||
`DNI: ${text(responsible.documentNumber)}`,
|
||||
`Cargo / funcion: ${text(responsible.position)}`,
|
||||
`Email: ${text(responsible.email)}`,
|
||||
'',
|
||||
'RESUMEN',
|
||||
...wrap(text(act.summary)),
|
||||
'',
|
||||
'OBSERVACIONES',
|
||||
...wrap(text(act.observations)),
|
||||
'',
|
||||
'INVENTARIO INSPECCIONADO',
|
||||
];
|
||||
|
||||
if (!inventories.length) out.push('-');
|
||||
for (const item of inventories) {
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
||||
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); }
|
||||
if (inventories.length) {
|
||||
heading('Instalaciones inspeccionadas');
|
||||
for (const item of inventories) body(`${text(item.name)} (${text(item.code)}) · ${text(item.typeName ?? item.typeCode)}`);
|
||||
}
|
||||
|
||||
out.push('', 'HALLAZGOS');
|
||||
if (!findings.length) out.push('Sin hallazgos registrados.');
|
||||
for (const item of findings) {
|
||||
const recurrence = item.isRecurrence
|
||||
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
||||
: '';
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
||||
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
||||
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
||||
heading('Hallazgos y fotografías');
|
||||
if (!findings.length) body('No se registraron hallazgos.');
|
||||
const shownAssetPhotos = new Set<string>();
|
||||
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)}` : ''}`);
|
||||
for (const photo of images.filter((item) => item.assetId === text(finding.assetId))) {
|
||||
if (shownAssetPhotos.has(photo.id)) continue;
|
||||
shownAssetPhotos.add(photo.id);
|
||||
image(photo, `Fotografía de inventario ${text(photo.title)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
||||
}
|
||||
doc.moveDown(0.4);
|
||||
}
|
||||
|
||||
out.push('', 'FIRMAS Y CONSTANCIAS');
|
||||
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
||||
for (const signature of inspectorSignatures) {
|
||||
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
||||
if (!findings.length) for (const photo of images.filter((item) => item.assetId)) image(photo, `Fotografía de inventario ${text(photo.title)}`);
|
||||
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; }
|
||||
}
|
||||
if (!companySignature) {
|
||||
out.push('Manifestacion de empresa: pendiente.');
|
||||
} else if (text(companySignature.status, '') === 'SIGNED') {
|
||||
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
||||
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad');
|
||||
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||
} else {
|
||||
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
||||
}
|
||||
|
||||
out.push(
|
||||
'',
|
||||
'INTEGRIDAD',
|
||||
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
||||
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
||||
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
||||
return Buffer.concat([
|
||||
Buffer.from(`${id} 0 obj\n`, 'ascii'),
|
||||
data,
|
||||
Buffer.from('\nendobj\n', 'ascii'),
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
||||
const all = lines(snapshot);
|
||||
const chunks: Array<string[]> = [];
|
||||
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
||||
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||
|
||||
const pageCount = chunks.length;
|
||||
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
||||
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
||||
const objects: Buffer[] = [];
|
||||
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
||||
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
||||
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
const content = chunk
|
||||
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
|
||||
.join('\n');
|
||||
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
|
||||
objects.push(objectBuffer(
|
||||
pageIds[index]!,
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
|
||||
));
|
||||
objects.push(objectBuffer(
|
||||
contentIds[index]!,
|
||||
Buffer.concat([
|
||||
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
|
||||
stream,
|
||||
Buffer.from('\nendstream', 'ascii'),
|
||||
]),
|
||||
));
|
||||
});
|
||||
|
||||
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
||||
const offsets: number[] = [0];
|
||||
let position = header.length;
|
||||
for (const object of objects) {
|
||||
offsets.push(position);
|
||||
position += object.length;
|
||||
}
|
||||
const xrefOffset = position;
|
||||
const xref = [
|
||||
`xref\n0 ${objects.length + 1}\n`,
|
||||
'0000000000 65535 f \n',
|
||||
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
|
||||
].join('');
|
||||
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
|
||||
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') };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user