import { createHash } from 'node:crypto'; interface ZipEntry { name: string; data: Buffer; } interface ReportWordInput { code: string; title: string; generatedAt: Date; frozenSha256: string; frozenSnapshot: Record; executiveSummary?: string | null; reportDescription?: string | null; } 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 { 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 { 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 ? `` : ''; return `${styleXml}${xmlEscape(value)}`; } function labelValue(label: string, value: string): string { return `${xmlEscape(label)}: ${xmlEscape(value)}`; } function table(headers: string[], rows: string[][]): string { const cell = (value: string, bold = false) => `${bold ? '' : ''}${xmlEscape(value)}`; const header = `${headers.map((item) => cell(item, true)).join('')}`; const body = rows.map((row) => `${row.map((item) => cell(item)).join('')}`).join(''); return `${header}${body}`; } 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 { 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 documentXml(input: ReportWordInput): string { const snapshot = f4Snapshot(input); const findingRows = snapshot.findings.map((item) => [ text(item.code), text(item.title), text(item.description), item.isRecurrence ? `Sí${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)), 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 `${body}`; } 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('', 'utf8'), }, { name: '_rels/.rels', data: Buffer.from('', 'utf8'), }, { name: 'word/_rels/document.xml.rels', data: Buffer.from('', 'utf8'), }, { name: 'word/styles.xml', data: Buffer.from('', 'utf8'), }, { name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') }, { name: 'docProps/core.xml', data: Buffer.from(`${xmlEscape(input.title)}DH InspecciónDH Inspección${input.generatedAt.toISOString()}`, 'utf8'), }, { name: 'docProps/app.xml', data: Buffer.from('DH Inspección', 'utf8'), }, ]; const buffer = buildZip(entries); return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; }