Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 1m25s
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m16s
265 lines
14 KiB
TypeScript
265 lines
14 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>;
|
|
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<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>${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 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 `<?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') };
|
|
}
|