From f5ab6ce885358235aebb21f0644f196b066095ae Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:30:43 -0300 Subject: [PATCH] F4: rebuild Act PDF from locked and sealed snapshot --- .../inspection-act-pdf-builder.ts | 260 +++++++++++++----- 1 file changed, 192 insertions(+), 68 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index 7039abd..e058911 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -1,88 +1,212 @@ import { createHash } from 'node:crypto'; -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 out=String(value ?? '').trim(); return out || fallback; } -function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; } -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 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 out = String(value ?? '').trim(); + return out || fallback; +} + +function date(value: unknown): string { + const parsed = new Date(String(value ?? '')); + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-'; +} + +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): { + locked: Record; + signatures: Array>; + finalSha256: unknown; +} { + 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[] { - const prepared=asRecord(asRecord(snapshot).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(snapshot.signatures); - const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE'); - const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))]; - const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))]; - const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean); - const out:string[]=[ + const source = lockedSnapshot(snapshot); + const locked = source.locked; + const act = asRecord(locked.act); + const inspection = asRecord(act.inspection ?? asRecord(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 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', - 'Documento automatico - diseno institucional pendiente de definicion', '', `Acta: ${text(act.code)}`, - `Inspeccion: ${text(visit.code)}`, + `Inspeccion: ${text(inspection.code)}`, `Fecha: ${date(act.occurredAt)}`, - `Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`, - `Area: ${areas.join(' / ') || 'Segun alcance del acta'}`, - `Inspector/es: ${inspectors.join(' / ') || '-'}`, + `Urgencia: ${urgencyLabel(act.urgency)}`, + `Plazo: ${deadlineText}`, `Responsable empresa: ${text(responsible.fullName)}`, `Cargo: ${text(responsible.position)}`, '', - 'OBJETIVO', ...wrap(text(visit.objective)), '', - 'RESUMEN', ...wrap(text(act.summary)), '', - 'OBSERVACIONES', ...wrap(text(act.observations)), '', - 'ELEMENTOS INSPECCIONADOS', + 'RESUMEN', + ...wrap(text(act.summary)), + '', + 'OBSERVACIONES', + ...wrap(text(act.observations)), + '', + 'INVENTARIO INSPECCIONADO', ]; - if(!assets.length) out.push('-'); - for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`)); - out.push('', 'HALLAZGOS'); - if(!findings.length) out.push('Sin hallazgos registrados.'); - for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); } - out.push('', 'CONSTANCIA DE LA EMPRESA'); - if(!companySignature) out.push('Firma o constancia pendiente.'); - else if(text(companySignature.status,'')==='SIGNED'){ - const manifestation=text(companySignature.companyManifestation,'CONFORMITY'); - out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad'); - if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement))); - } else { - out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`)); + + if (!inventories.length) out.push('-'); + for (const item of inventories) { + out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`)); } - out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`); + + 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)}`)); + } + + 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 (!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 disidencia' : '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')]); } +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): { buffer: Buffer; sha256: string } { - const all=lines(snapshot); - const chunks:Array=[]; - for(let i=0;i4+i*2); - const contentIds=Array.from({length:pageCount},(_,i)=>5+i*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,i)=>`${i===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 all = lines(snapshot); + const chunks: Array = []; + 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 pos=header.length; - for(const obj of objects){ offsets.push(pos); pos+=obj.length; } - const xrefOffset=pos; - const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+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')]); - return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') }; + + 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')]); + return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; }