215 lines
13 KiB
TypeScript
215 lines
13 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
|
|
interface ZipEntry {
|
|
name: string;
|
|
data: Buffer;
|
|
}
|
|
|
|
interface ReportWordInput {
|
|
code: string;
|
|
title: string;
|
|
generatedAt: Date;
|
|
frozenSha256: string;
|
|
frozenSnapshot: Record<string, unknown>;
|
|
}
|
|
|
|
const crcTable = (() => {
|
|
const table = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n += 1) {
|
|
let value = n;
|
|
for (let k = 0; k < 8; k += 1) {
|
|
value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
}
|
|
table[n] = value >>> 0;
|
|
}
|
|
return table;
|
|
})();
|
|
|
|
function crc32(data: Buffer): number {
|
|
let crc = 0xffffffff;
|
|
for (const byte of data) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function xmlEscape(value: unknown): string {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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 normalized = String(value ?? '').trim();
|
|
return normalized || fallback;
|
|
}
|
|
|
|
function isoDate(value: unknown): string {
|
|
const date = new Date(String(value ?? ''));
|
|
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—';
|
|
}
|
|
|
|
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
|
|
const styleXml = style ? `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>` : '';
|
|
return `<w:p>${styleXml}<w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
|
}
|
|
|
|
function labelValue(label: string, value: string): string {
|
|
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
|
}
|
|
|
|
function table(headers: string[], rows: string[][]): string {
|
|
const cell = (value: string, bold = false) => `<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr><w:p><w:r>${bold ? '<w:rPr><w:b/></w:rPr>' : ''}<w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p></w:tc>`;
|
|
const header = `<w:tr>${headers.map((item) => cell(item, true)).join('')}</w:tr>`;
|
|
const body = rows.map((row) => `<w:tr>${row.map((item) => cell(item)).join('')}</w:tr>`).join('');
|
|
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`;
|
|
}
|
|
|
|
function documentXml(input: ReportWordInput): string {
|
|
const report = asRecord(input.frozenSnapshot.report);
|
|
const actClosure = asRecord(input.frozenSnapshot.actClosure);
|
|
const prepared = asRecord(actClosure.preparedSnapshot);
|
|
const act = asRecord(prepared.act);
|
|
const visit = asRecord(act.visit);
|
|
const responsible = asRecord(prepared.responsible);
|
|
const team = asArray(prepared.team);
|
|
const assets = asArray(prepared.assets);
|
|
const findings = asArray(prepared.findings);
|
|
const signatures = asArray(actClosure.signatures);
|
|
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))];
|
|
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))];
|
|
const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean);
|
|
const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]);
|
|
const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]);
|
|
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]);
|
|
const body = [
|
|
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
|
paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'),
|
|
labelValue('Informe', input.code),
|
|
labelValue('Título', input.title),
|
|
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
|
labelValue('Acta', text(report.actCode ?? act.code)),
|
|
labelValue('Inspección', text(report.visitCode ?? visit.code)),
|
|
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'),
|
|
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'),
|
|
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'),
|
|
labelValue('Responsable de empresa', text(responsible.fullName)),
|
|
labelValue('Cargo', text(responsible.position)),
|
|
paragraph('Datos de la visita', 'Heading1'),
|
|
labelValue('Objetivo', text(visit.objective)),
|
|
labelValue('Fecha de inspección', isoDate(act.occurredAt)),
|
|
labelValue('Resumen', text(act.summary)),
|
|
labelValue('Observaciones', text(act.observations)),
|
|
paragraph('Elementos inspeccionados', 'Heading1'),
|
|
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'),
|
|
paragraph('Hallazgos', 'Heading1'),
|
|
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'),
|
|
paragraph('Firmas y constancias', 'Heading1'),
|
|
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'),
|
|
paragraph('Integridad documental', 'Heading1'),
|
|
labelValue('Hash del informe', input.frozenSha256),
|
|
labelValue('Hash de cierre del acta', text(report.actClosureSha256)),
|
|
paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'),
|
|
].join('');
|
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
|
}
|
|
|
|
function buildZip(entries: ZipEntry[]): Buffer {
|
|
const locals: Buffer[] = [];
|
|
const centrals: Buffer[] = [];
|
|
let offset = 0;
|
|
const dosDate = 33;
|
|
const dosTime = 0;
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.name, 'utf8');
|
|
const crc = crc32(entry.data);
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt16LE(0, 6);
|
|
local.writeUInt16LE(0, 8);
|
|
local.writeUInt16LE(dosTime, 10);
|
|
local.writeUInt16LE(dosDate, 12);
|
|
local.writeUInt32LE(crc, 14);
|
|
local.writeUInt32LE(entry.data.length, 18);
|
|
local.writeUInt32LE(entry.data.length, 22);
|
|
local.writeUInt16LE(name.length, 26);
|
|
local.writeUInt16LE(0, 28);
|
|
locals.push(local, name, entry.data);
|
|
const central = Buffer.alloc(46);
|
|
central.writeUInt32LE(0x02014b50, 0);
|
|
central.writeUInt16LE(20, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt16LE(0, 8);
|
|
central.writeUInt16LE(0, 10);
|
|
central.writeUInt16LE(dosTime, 12);
|
|
central.writeUInt16LE(dosDate, 14);
|
|
central.writeUInt32LE(crc, 16);
|
|
central.writeUInt32LE(entry.data.length, 20);
|
|
central.writeUInt32LE(entry.data.length, 24);
|
|
central.writeUInt16LE(name.length, 28);
|
|
central.writeUInt16LE(0, 30);
|
|
central.writeUInt16LE(0, 32);
|
|
central.writeUInt16LE(0, 34);
|
|
central.writeUInt16LE(0, 36);
|
|
central.writeUInt32LE(0, 38);
|
|
central.writeUInt32LE(offset, 42);
|
|
centrals.push(central, name);
|
|
offset += local.length + name.length + entry.data.length;
|
|
}
|
|
const centralData = Buffer.concat(centrals);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(0, 4);
|
|
end.writeUInt16LE(0, 6);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(centralData.length, 12);
|
|
end.writeUInt32LE(offset, 16);
|
|
end.writeUInt16LE(0, 20);
|
|
return Buffer.concat([...locals, centralData, end]);
|
|
}
|
|
|
|
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
|
|
const entries: ZipEntry[] = [
|
|
{
|
|
name: '[Content_Types].xml',
|
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
|
},
|
|
{
|
|
name: '_rels/.rels',
|
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/></Relationships>', 'utf8'),
|
|
},
|
|
{
|
|
name: 'word/_rels/document.xml.rels',
|
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'),
|
|
},
|
|
{
|
|
name: 'word/styles.xml',
|
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'),
|
|
},
|
|
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') },
|
|
{
|
|
name: 'docProps/core.xml',
|
|
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
|
|
},
|
|
{
|
|
name: 'docProps/app.xml',
|
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Application>DH Inspección</Application></Properties>', 'utf8'),
|
|
},
|
|
];
|
|
const buffer = buildZip(entries);
|
|
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
|
}
|