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

This commit is contained in:
DH V2
2026-09-15 18:48:12 -03:00
parent 079728aa6d
commit 5cf99442a8
39 changed files with 1604 additions and 566 deletions
@@ -1,4 +1,6 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
interface ZipEntry {
name: string;
@@ -13,6 +15,10 @@ interface ReportWordInput {
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 = (() => {
@@ -68,7 +74,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st
}
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>`;
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 {
@@ -102,75 +108,80 @@ function deadline(act: Record<string, unknown>): string {
return '—';
}
function documentXml(input: ReportWordInput): string {
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 = 2743200, 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 findingRows = snapshot.findings.map((item) => [
text(item.code),
text(item.title),
text(item.description),
item.isRecurrence ? `${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
item.severity == null ? '—' : `${text(item.severity)}/10`,
]);
const inventoryRows = snapshot.inventories.map((item) => [
text(item.code),
text(item.name),
text(item.typeName ?? item.typeCode),
]);
const signatureRows = snapshot.signatures.map((item) => [
text(item.signerType),
text(item.signerName),
text(item.status),
text(item.companyManifestation, ''),
isoDate(item.signedAt ?? item.createdAt),
]);
const executive = input.executiveSummary?.trim()
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
const description = input.reportDescription?.trim()
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
const body = [
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
labelValue('Informe', input.code),
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
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 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('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
labelValue('Urgencia', urgency(snapshot.act.urgency)),
labelValue('Plazo', deadline(snapshot.act)),
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
paragraph('Resumen ejecutivo', 'Heading1'),
paragraph(executive),
paragraph('Descripción / análisis técnico', 'Heading1'),
paragraph(description),
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
labelValue('Resumen del Acta', text(snapshot.act.summary)),
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
labelValue('DNI', text(snapshot.responsible.documentNumber)),
labelValue('Cargo / función', text(snapshot.responsible.position)),
labelValue('Email', text(snapshot.responsible.email)),
paragraph('Inventario inspeccionado', 'Heading1'),
inventoryRows.length
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
: paragraph('No se registraron elementos de Inventario en el Acta.'),
paragraph('Hallazgos', 'Heading1'),
findingRows.length
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
: paragraph('El Acta no contiene Hallazgos.'),
paragraph('Firmas y manifestaciones', 'Heading1'),
signatureRows.length
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
: paragraph('No se registraron firmas en la instantánea sellada.'),
paragraph('Integridad de la fuente', 'Heading1'),
labelValue('Hash de la fuente del INF', input.frozenSha256),
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
].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>`;
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 ${snapshot.findings.length} hallazgo(s) 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.'));
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;
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
}
}
entries.push(
paragraph('CONCLUSIONES', 'Heading1'),
paragraph(input.executiveSummary?.trim() || `La inspección registró ${snapshot.findings.length} hallazgo(s). 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 {
@@ -232,10 +243,13 @@ function buildZip(entries: ZipEntry[]): Buffer {
}
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"/><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'),
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',
@@ -243,13 +257,15 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf
},
{
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'),
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: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'),
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), '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'),