Files
dh-inspeccion-v2/api-v3/src/inspection-reports/inspection-report-word-builder.ts
T
DH V2 60ba37c287
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m8s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
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 59s
fix(f6.9): include every field photo and preserve document revisions
2026-09-15 19:18:31 -03:00

294 lines
18 KiB
TypeScript

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
interface ZipEntry {
name: string;
data: Buffer;
}
interface ReportWordInput {
code: string;
title: string;
generatedAt: Date;
frozenSha256: string;
frozenSnapshot: Record<string, unknown>;
executiveSummary?: string | null;
reportDescription?: string | null;
companyName?: string | null;
areaName?: string | null;
scopeName?: string | null;
photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>;
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
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 parsed = new Date(String(value ?? ''));
return Number.isFinite(parsed.getTime()) ? parsed.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 xml:space="preserve">${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 f4Snapshot(input: ReportWordInput) {
const source = asRecord(input.frozenSnapshot.source);
const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure);
const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot);
const act = asRecord(locked.act);
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 = asArray(sealedAct.signatures);
return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures };
}
function urgency(value: unknown): string {
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
}
function deadline(act: Record<string, unknown>): string {
const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles';
if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`;
if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`;
return '—';
}
function imageDimensions(buffer: Buffer): { width: number; height: number } {
if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) break;
const marker = buffer[offset + 1]!;
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
const size = buffer.readUInt16BE(offset + 2);
if (size < 2) break;
offset += size + 2;
}
return { width: 800, height: 500 };
}
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2057400, right = false): string {
const dimensions = imageDimensions(image);
const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height);
const cx = Math.round(dimensions.width * scale);
const cy = Math.round(dimensions.height * scale);
const alt = xmlEscape(name);
return `<w:p>${right ? '<w:pPr><w:jc w:val="right"/></w:pPr>' : ''}<w:r><w:drawing><wp:inline><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relationship}" name="${alt}" descr="${alt}"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${alt}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId${relationship}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
}
function documentXml(input: ReportWordInput, logo: Buffer): string {
const snapshot = f4Snapshot(input);
const photos = input.photos ?? [];
const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`;
const entries: string[] = [
paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
paragraph('DIRECCIÓN DE HIDROCARBUROS'),
drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
paragraph('Sr. Director de Hidrocarburos'),
labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
labelValue('Inspector/a', authors || 'No consignado'),
labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
labelValue('Empresa inspeccionada', text(input.companyName)),
paragraph('OBJETIVOS', 'Heading1'),
labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
paragraph('MARCO LEGAL', 'Heading1'),
...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${quantity} durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
];
if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
const shownPhotos = new Set<number>();
for (const finding of snapshot.findings) {
entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
entries.push(paragraph(text(finding.description)));
if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
for (let index = 0; index < photos.length; index++) {
const photo = photos[index]!;
if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
shownPhotos.add(index);
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
}
}
const otherPhotos = photos.map((photo, index) => ({ photo, index }))
.filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index));
if (otherPhotos.length) {
entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1'));
for (const { photo, index } of otherPhotos) {
entries.push(labelValue('Instalación', text(photo.title)));
entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación'));
entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`));
}
}
entries.push(
paragraph('CONCLUSIONES', 'Heading1'),
paragraph(input.executiveSummary?.trim() || `La inspección registró ${quantity}. Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
labelValue('SHA-256 de la fuente del Informe', input.frozenSha256),
);
const body = entries.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><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 logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
const photos = input.photos ?? [];
const mediaRelationships = photos.map((photo, index) => `<Relationship Id="rId${index + 3}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}"/>`).join('');
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"/><Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="png" ContentType="image/png"/><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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</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:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></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:pPr><w:spacing w:before="160" w:after="180"/></w:pPr></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="26"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="260" w:after="130"/></w:pPr></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:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr></w:style></w:styles>', 'utf8'),
},
{ name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') },
{ name: 'word/media/logo-mendoza.png', data: logo },
...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })),
{
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') };
}