From 69aeb5204080a04cd6d1612aad2848986bdd4995 Mon Sep 17 00:00:00 2001 From: KoreX Labs Date: Tue, 15 Sep 2026 23:09:16 -0300 Subject: [PATCH] feat(actas): render complete self-contained PDF --- api-v3/package-lock.json | 4 +- api-v3/package.json | 2 +- .../inspection-closing.service.ts | 102 ++++--- .../inspection-act-pdf-builder.ts | 257 +++++++++++++++--- .../inspection-act-pdf.service.ts | 39 ++- api-v3/src/version.ts | 4 +- .../unit/act-findings-only-regression.test.ts | 24 +- api-v3/test/unit/f4-health-metadata.test.ts | 8 +- docs/PHASE_F6_10_ACTA_PDF_COMPLETE.md | 24 ++ 9 files changed, 369 insertions(+), 95 deletions(-) create mode 100644 docs/PHASE_F6_10_ACTA_PDF_COMPLETE.md diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 0438477..5d84a85 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-10", + "version": "0.29.0-11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-10", + "version": "0.29.0-11", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 359297f..d96228b 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-10", + "version": "0.29.0-11", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index c214314..b98e332 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -45,7 +45,7 @@ import { type UploadedInspectionSignatureFile, } from './inspection-signature-file'; -const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4'; +const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5'; const CONSENT_VERSION = 'F4-1'; const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.'; const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.'; @@ -896,6 +896,8 @@ export class InspectionClosingService { 'id',act.id, 'code',act.code, 'status',act.status, + 'actYear',act.act_year, + 'actNumber',act.act_number, 'occurredAt',act.occurred_at, 'title',act.title, 'summary',act.summary, @@ -910,6 +912,7 @@ export class InspectionClosingService { 'id',visit.id, 'code',visit.code, 'status',visit.status, + 'scopeAssetId',visit.scope_asset_id, 'operationalAreaId',visit.operational_area_id, 'operatorCompanyId',visit.operator_company_id, 'leadInspectorUserId',visit.lead_inspector_user_id, @@ -922,49 +925,74 @@ export class InspectionClosingService { `, [actId]) as Array<{ act: Record }>; if (!act) throw actNotFound(); const responsible = await this.requireResponsible(manager, actId); + const [contextRow] = await manager.query(` + SELECT + CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS company, + CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',department.id,'code',department.code,'name',department.name) END AS department, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS area, + CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',scope.id,'code',scope.code,'name',scope.name,'typeCode',scope_type.code,'typeName',scope_type.name) END AS scope, + CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',lead.id,'username',lead.username,'firstName',lead.first_name,'lastName',lead.last_name,'email',lead.email) END AS "leadInspector" + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id=act.visit_id + LEFT JOIN assets company ON company.id=visit.operator_company_id + LEFT JOIN assets area ON area.id=visit.operational_area_id + LEFT JOIN assets department ON department.id=area.parent_id + LEFT JOIN assets scope ON scope.id=visit.scope_asset_id + LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id + LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id + WHERE act.id=$1 + `, [actId]) as Array>; + const inspectors = await manager.query(` + SELECT member.id,member.username,member.first_name AS "firstName",member.last_name AS "lastName",member.email + FROM inspection_acts act + JOIN inspection_visits visit ON visit.id=act.visit_id + JOIN inspection_visit_members link ON link.visit_id=act.visit_id AND link.included=true + JOIN users member ON member.id=link.user_id + WHERE act.id=$1 + ORDER BY CASE WHEN member.id=visit.lead_inspector_user_id THEN 0 ELSE 1 END, + member.last_name,member.first_name,member.username + `, [actId]) as Array>; + const context = { ...(contextRow ?? {}), inspectors }; const inventories = await manager.query(` - SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion", - type.code AS "typeCode",type.name AS "typeName" - FROM inspection_act_assets link - JOIN assets asset ON asset.id=link.asset_id - JOIN asset_types type ON type.id=asset.asset_type_id - WHERE link.act_id=$1 AND link.included=true - AND EXISTS ( - SELECT 1 FROM inspection_findings finding - WHERE finding.act_id=link.act_id - AND finding.asset_id=asset.id - AND finding.status<>'VOIDED' - ) - ORDER BY asset.code,asset.id + WITH RECURSIVE selected AS ( + SELECT asset.id,asset.code,asset.name,asset.common_name,asset.parent_id,asset.current_version,type.code AS type_code,type.name AS type_name, + family.code AS family_code,family.name AS family_name + FROM inspection_act_assets link + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_types type ON type.id=asset.asset_type_id + LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id + WHERE link.act_id=$1 AND link.included=true + AND EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.act_id=link.act_id AND finding.asset_id=asset.id AND finding.status<>'VOIDED') + ), lineage AS ( + SELECT selected.id AS root_id,selected.id,selected.code,selected.name,selected.type_code,selected.type_name,selected.parent_id,0 AS depth FROM selected + UNION ALL + SELECT lineage.root_id,parent.id,parent.code,parent.name,parent_type.code,parent_type.name,parent.parent_id,lineage.depth+1 + FROM lineage JOIN assets parent ON parent.id=lineage.parent_id JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id + WHERE lineage.depth<8 + ) + SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion", + selected.type_code AS "typeCode",selected.type_name AS "typeName", + selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName", + COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path + FROM selected ORDER BY selected.code,selected.id `, [actId]) as Array>; const findings = await manager.query(` - SELECT - finding.id, - finding.finding_number AS "findingNumber", - finding.code, - finding.status, - finding.asset_id AS "assetId", - finding.catalog_item_id AS "catalogItemId", - finding.title, - finding.description, - finding.legal_basis AS "legalBasis", - finding.severity, - finding.is_recurrence AS "isRecurrence", - finding.recurrence_of_finding_id AS "recurrenceOfFindingId", - finding.correction_due_on AS "correctionDueOn", - finding.current_version AS "currentVersion" + SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status, + finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description, + finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision", + finding.suggested_severity AS "suggestedSeverity",finding.severity, + finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId", + finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion", + CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',catalog.id,'code',catalog.code,'sourceNumber',catalog.source_number,'title',catalog.title,'categoryName',category.name,'revision',finding.catalog_revision) END AS catalog, + CASE WHEN antecedent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',antecedent.id,'code',antecedent.code,'title',antecedent.title) END AS recurrence FROM inspection_findings finding + LEFT JOIN finding_catalog_items catalog ON catalog.id=finding.catalog_item_id + LEFT JOIN finding_categories category ON category.id=catalog.category_id + LEFT JOIN inspection_findings antecedent ON antecedent.id=finding.recurrence_of_finding_id WHERE finding.act_id=$1 AND finding.status<>'VOIDED' ORDER BY finding.finding_number,finding.id `, [actId]) as Array>; - return { - schemaVersion: CLOSURE_SCHEMA_VERSION, - lockedAt: lockedAt.toISOString(), - act: act.act, - responsible, - inventories, - findings, - }; + return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings }; } private async deadlinePolicy(manager: EntityManager): Promise { 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 87d934a..ad89fe1 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -14,6 +14,19 @@ export interface ActPdfImage { buffer: Buffer; } +export interface ActPdfContext { + companyName?: string | null; + departmentName?: string | null; + areaName?: string | null; + scopeName?: string | null; + scopeCode?: string | null; + scopeTypeName?: string | null; + scopeTypeCode?: string | null; + yacimientoName?: string | null; + yacimientoCode?: string | null; + leadInspectorName?: string | null; +} + function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -25,23 +38,60 @@ function text(value: unknown, fallback = ''): string { } function date(value: unknown): string { const parsed = new Date(String(value ?? '')); - return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString('es-AR', { timeZone: 'America/Argentina/Mendoza', dateStyle: 'short', timeStyle: 'short' }) : '-'; + return Number.isFinite(parsed.getTime()) + ? new Intl.DateTimeFormat('es-AR', { + timeZone: 'America/Argentina/Mendoza', day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', hour12: false, + }).format(parsed) + : '-'; +} +function fullName(value: Record): string { + return `${text(value.firstName)} ${text(value.lastName)}`.trim() || text(value.username); } function isPlaceholder(value: unknown): boolean { return text(value).startsWith('Acta de inspección en curso. Los Hallazgos'); } +function urgency(value: unknown): string { + return text(value) === 'URGENT' ? 'Urgente' : text(value) === 'NON_URGENT' ? 'No urgente' : '-'; +} +function dayType(value: unknown): string { + return text(value) === 'BUSINESS' ? 'días hábiles' : text(value) === 'CALENDAR' ? 'días corridos' : ''; +} +function deadlineBasis(value: unknown): string { + return text(value) === 'ACT_DATE' ? 'Desde la fecha del Acta' : text(value) === 'GEDO_DATE' ? 'Desde la notificación formal' : ''; +} +function manifestation(value: unknown): string { + return text(value) === 'CONFORMITY' ? 'Firma sin disconformidad' : text(value) === 'DISSENT' ? 'Firma en disconformidad' : ''; +} +function signatureStatus(value: unknown): string { + return text(value) === 'SIGNED' ? 'Firmó' : text(value) === 'REFUSED' ? 'Se negó a firmar' : text(value) === 'ABSENT' ? 'Ausente' : 'No firmó'; +} // %PDF-1.4 is the document-version contract for consolidated Actas. -export async function buildInspectionActPdf(snapshot: Record, images: ActPdfImage[] = [], context: { companyName?: string | null; areaName?: string | null; scopeName?: string | null } = {}): Promise<{ buffer: Buffer; sha256: string }> { +export async function buildInspectionActPdf( + snapshot: Record, + images: ActPdfImage[] = [], + fallbackContext: ActPdfContext = {}, +): Promise<{ buffer: Buffer; sha256: string }> { const sealed = asRecord(snapshot); const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot); const act = asRecord(locked.act); const inspection = asRecord(act.inspection ?? act.visit); + const context = asRecord(locked.context); + const company = asRecord(context.company); + const department = asRecord(context.department); + const area = asRecord(context.area); + const scope = asRecord(context.scope); + const leadInspector = asRecord(context.leadInspector); + const inspectors = asArray(context.inspectors); const responsible = asRecord(locked.responsible); + const inventories = asArray(locked.inventories); const findings = asArray(locked.findings); const signatures = asArray(sealed.signatures); + const seal = asRecord(sealed.seal); const hash = text(sealed.finalSha256 ?? sealed.lockedSha256); const logo = resolve(process.cwd(), 'assets/logo-mendoza.png'); + const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true }); doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf')); doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf')); @@ -49,6 +99,9 @@ export async function buildInspectionActPdf(snapshot: Record, i doc.on('data', (part: Buffer) => chunks.push(part)); const done = new Promise((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); }); const blue = '#162D69'; + const ink = '#202939'; + const muted = '#5A667C'; + const header = () => { doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42); doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58); @@ -58,63 +111,189 @@ export async function buildInspectionActPdf(snapshot: Record, i }; doc.on('pageAdded', header); header(); + const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); }; - const heading = (label: string) => { need(48); doc.moveDown(1); doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); doc.moveDown(0.35); }; - const body = (value: unknown) => { need(22); doc.font('body').fillColor('#202939').fontSize(10.5).text(text(value, '-'), { lineGap: 3 }); doc.moveDown(0.4); }; - const label = (name: string, value: unknown) => { if (!text(value)) return; need(22); doc.font('body-bold').fillColor('#202939').fontSize(10).text(`${name}: `, { continued: true }); doc.font('body').text(text(value)); doc.moveDown(0.35); }; + const section = (label: string) => { + need(46); + doc.moveDown(0.9); + doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase()); + doc.moveTo(54, doc.y + 3).lineTo(540, doc.y + 3).strokeColor('#D5DCE8').stroke(); + doc.moveDown(0.55); + }; + const body = (value: unknown) => { + need(24); + doc.font('body').fillColor(ink).fontSize(10.2).text(text(value, '-'), { lineGap: 3 }); + doc.moveDown(0.4); + }; + const label = (name: string, value: unknown, allowEmpty = false) => { + const rendered = text(value); + if (!rendered && !allowEmpty) return; + need(24); + doc.font('body-bold').fillColor(ink).fontSize(9.8).text(`${name}: `, { continued: true }); + doc.font('body').text(rendered || '-'); + doc.moveDown(0.28); + }; + const subheading = (value: string) => { + need(32); + doc.font('body-bold').fillColor(blue).fontSize(10.8).text(value); + doc.moveDown(0.25); + }; const image = (entry: ActPdfImage, caption: string) => { need(235); const y = doc.y; doc.image(entry.buffer, 58, y, { fit: [470, 190] }); doc.y = y + 195; - doc.font('body').fontSize(8).fillColor('#47536A').text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 }); + doc.font('body').fontSize(8).fillColor(muted).text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 }); doc.moveDown(0.5); }; - doc.font('body-bold').fillColor('#202939').fontSize(19).text(`ACTA DE INSPECCIÓN ${text(act.code)}`); - doc.moveDown(0.5); + doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`); + doc.moveDown(0.15); + doc.font('body').fillColor(muted).fontSize(9).text('Documento consolidado de actuación inspectiva'); + + section('1. Identificación del Acta'); + label('Código del Acta', act.code); + if (act.actNumber || act.actYear) label('Número / Año', `${text(act.actNumber, '-')} / ${text(act.actYear, '-')}`); label('Inspección', inspection.code); - label('Empresa inspeccionada', context.companyName); - label('Área', context.areaName); - label('Yacimiento o instalación', context.scopeName); - label('Fecha y hora', date(act.occurredAt)); - label('Urgencia del Acta', text(act.urgency) === 'URGENT' ? 'Urgente' : text(act.urgency) === 'NON_URGENT' ? 'No urgente' : ''); + label('Fecha y hora de actuación', date(act.occurredAt)); + label('Estado documental', 'Sellada'); + label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion); + + section('2. Contexto territorial y operativo'); + label('Empresa / Operadora', company.name ?? fallbackContext.companyName); + label('Departamento', department.name ?? fallbackContext.departmentName); + label('Área', area.name ?? fallbackContext.areaName); + const pathYacimientos = inventories.flatMap((inventory) => asArray(inventory.path)) + .filter((node) => text(node.typeCode).toLowerCase() === 'yacimiento'); + const scopeTypeCode = text(scope.typeCode ?? fallbackContext.scopeTypeCode).toLowerCase(); + const yacimientoNames = Array.from(new Set([ + ...(scopeTypeCode === 'yacimiento' ? [text(scope.name ?? fallbackContext.scopeName)] : []), + ...pathYacimientos.map((node) => text(node.name)), + text(fallbackContext.yacimientoName), + ].filter(Boolean))); + const yacimientoCodes = Array.from(new Set([ + ...(scopeTypeCode === 'yacimiento' ? [text(scope.code ?? fallbackContext.scopeCode)] : []), + ...pathYacimientos.map((node) => text(node.code)), + text(fallbackContext.yacimientoCode), + ].filter(Boolean))); + if (yacimientoNames.length) label(yacimientoNames.length > 1 ? 'Yacimientos' : 'Yacimiento', yacimientoNames.join(', ')); + if (yacimientoCodes.length) label(yacimientoCodes.length > 1 ? 'Códigos de yacimiento' : 'Código de yacimiento', yacimientoCodes.join(', ')); + if (scopeTypeCode && scopeTypeCode !== 'yacimiento') { + const scopeLabel = `${text(scope.typeName ?? fallbackContext.scopeTypeName)} - ${text(scope.name ?? fallbackContext.scopeName)}${text(scope.code ?? fallbackContext.scopeCode) ? ` [${text(scope.code ?? fallbackContext.scopeCode)}]` : ''}`; + label('Alcance de la inspección', scopeLabel); + } + label('Inicio efectivo de la inspección', date(inspection.actualStartedAt)); + + section('3. Intervinientes'); + const leadName = fullName(leadInspector) || fallbackContext.leadInspectorName || ''; + label('Inspector/a responsable', leadName); + const otherInspectors = inspectors.map(fullName).filter((name) => name && name !== leadName); + if (otherInspectors.length) label('Otros inspectores actuantes', otherInspectors.join(', ')); + label('Situación del representante', text(responsible.attendanceStatus) === 'ABSENT' ? 'Ausente' : 'Presente'); label('Representante de la empresa', responsible.fullName); - label('DNI', responsible.documentNumber); + const document = [text(responsible.documentType), text(responsible.documentNumber)].filter(Boolean).join(' '); + label('Documento', document); label('Cargo o función', responsible.position); - heading('Lo actuado'); + label('Correo electrónico', responsible.email); + label('Teléfono', responsible.phone); + if (text(responsible.attendanceStatus) === 'ABSENT') label('Motivo de ausencia', responsible.absenceReason); + + section('4. Datos del Acta'); + label('Objeto / Denominación', act.title); + label('Urgencia', urgency(act.urgency)); + if (act.deadlineDays != null) { + const duration = `${text(act.deadlineDays)} ${dayType(act.deadlineDayType)}`.trim(); + label('Plazo', duration); + label('Cómputo del plazo', deadlineBasis(act.deadlineBasis)); + if (act.deadlineBaseAt) label('Fecha base', date(act.deadlineBaseAt)); + if (act.deadlineAt) label('Vencimiento', date(act.deadlineAt)); + } + subheading('Descripción de lo actuado'); if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary); else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`); - if (act.observations) { label('Observaciones', act.observations); } - heading('Hallazgos y fotografías'); - if (!findings.length) body('No se registraron hallazgos.'); - for (const finding of findings) { - need(75); - doc.font('body-bold').fillColor(blue).fontSize(11).text(`${text(finding.code)} · ${text(finding.title)}`); - label('Descripción', finding.description); - if (finding.legalBasis) label('Normativa consignada', finding.legalBasis); - if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`); - for (const photo of images.filter((item) => item.findingId === text(finding.id))) { - image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); - } - doc.moveDown(0.4); + if (text(act.observations)) { + subheading('Observaciones generales'); + body(act.observations); } - heading('Intervinientes y firmas'); + + section('5. Hallazgos'); + if (!findings.length) body('No se registraron hallazgos en esta Acta.'); + for (const finding of findings) { + const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {}; + const catalog = asRecord(finding.catalog); + const recurrence = asRecord(finding.recurrence); + const path = asArray(inventory.path); + const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > '); + const number = text(finding.findingNumber, '?'); + need(100); + doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`); + doc.moveDown(0.2); + label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`); + if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName); + if (route) label('Ubicación / Ruta jerárquica', route); + label('Denominación del hallazgo', finding.title); + subheading('Qué se constató'); + body(finding.description); + if (text(catalog.id)) { + const source = [text(catalog.categoryName), text(catalog.title), text(catalog.code), catalog.sourceNumber ? `Ítem ${text(catalog.sourceNumber)}` : ''].filter(Boolean).join(' · '); + label('Referencia de catálogo', source); + } else { + label('Referencia de catálogo', 'OTROS / hallazgo cargado en campo'); + } + if (text(finding.glossary)) label('Criterio / Referencia técnica', finding.glossary); + if (text(finding.legalBasis)) label('Normativa / Base legal', finding.legalBasis); + if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`); + if (finding.isRecurrence) { + label('Reincidencia', 'Sí'); + if (text(recurrence.code) || text(recurrence.title)) label('Antecedente relacionado', `${text(recurrence.code)}${text(recurrence.title) ? ` - ${text(recurrence.title)}` : ''}`); + } else { + label('Reincidencia', 'No'); + } + const photos = images.filter((item) => item.findingId === text(finding.id)); + if (photos.length) { + subheading(`Evidencia fotográfica (${photos.length})`); + for (const photo of photos) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`); + } else { + label('Evidencia fotográfica', 'Sin fotografías asociadas'); + } + doc.moveDown(0.7); + } + + section('6. Firmas y manifestaciones'); for (const signature of signatures) { const name = text(signature.signerName); - const role = text(signature.signerType) === 'INSPECTOR' ? 'Inspector/a' : 'Representante de la empresa'; - const status = text(signature.status); - label(role, `${name} · ${status === 'SIGNED' ? 'Firmó' : status === 'REFUSED' ? 'Se negó a firmar' : 'No firmó'} · ${date(signature.signedAt ?? signature.createdAt)}`); - if (signature.companyManifestation === 'DISSENT') label('Disconformidad', signature.companyStatement); - if (status === 'REFUSED') label('Motivo de negativa', signature.reason); + const inspector = text(signature.signerType) === 'INSPECTOR'; + const role = inspector ? 'Inspector/a' : 'Representante de la empresa'; + subheading(`${role}: ${name}`); + const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' '); + if (signedDocument) label('Documento', signedDocument); + label('Cargo o función', signature.position); + label('Resultado', signatureStatus(signature.status)); + if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation)); + if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement); + if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.reason); + label('Fecha y hora de la constancia', date(signature.signedAt ?? signature.createdAt)); + if (text(signature.source)) label('Origen de la constancia', text(signature.source) === 'ANDROID' ? 'Aplicación móvil' : 'Dashboard web'); const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256)); - if (signatureImage) { need(100); const y = doc.y; doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] }); doc.y = y + 66; } + if (signatureImage) { + need(100); + const y = doc.y; + doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] }); + doc.y = y + 66; + } + doc.moveDown(0.5); } - heading('Integridad del Acta'); + + section('7. Integridad y cierre'); + label('Contenido bloqueado', date(locked.lockedAt)); + label('Sellado en servidor', date(seal.serverSealedAt)); + label('Fecha informada por dispositivo', date(seal.deviceSealedAt)); + label('Modo de cierre', seal.uploadMode); body(`SHA-256 del cierre: ${hash}`); - body(`SHA-256 del contenido cerrado: ${text(sealed.lockedSha256)}`); - need(25); - doc.fontSize(8).fillColor('#637088').text(`Acta ${text(act.code)} · documento consolidado · ${date(asRecord(sealed.seal).serverSealedAt)}`, 54, doc.y); + body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`); + need(28); + doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y); + doc.end(); const buffer = await done; return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') }; diff --git a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts index ddbd159..55196ea 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf.service.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf.service.ts @@ -4,7 +4,7 @@ import { isAbsolute, parse, resolve } from 'node:path'; import { ConfigService } from '@nestjs/config'; import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { buildInspectionActPdf, type ActPdfImage } from './inspection-act-pdf-builder'; +import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } from './inspection-act-pdf-builder'; import { renderableInspectionImage } from './inspection-document-images'; @Injectable() @@ -96,7 +96,7 @@ export class InspectionActPdfService { SELECT stored_name AS "storedName",original_name AS "originalName", size_bytes AS "sizeBytes",sha256 FROM inspection_act_consolidated_pdf_revisions - WHERE act_id=$1 AND template_version=2 + WHERE act_id=$1 AND template_version=3 `, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (existing) { const buffer = await this.verifiedImage(this.root, existing); @@ -126,12 +126,12 @@ export class InspectionActPdfService { }); await this.dataSource.query(` INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256) - VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING + VALUES($1,3,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING `, [actId, storedName, originalName, built.buffer.length, built.sha256]); const [saved] = await this.dataSource.query(` SELECT stored_name AS "storedName",original_name AS "originalName", size_bytes AS "sizeBytes",sha256 - FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=2 + FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3 `, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>; if (!saved) throw this.storageError(); return { @@ -141,7 +141,7 @@ export class InspectionActPdfService { } async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> { - if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente'); + if (![1, 2, 3].includes(version)) throw new NotFoundException('Versión documental inexistente'); const [row] = await this.dataSource.query(` SELECT stored_name AS "storedName",original_name AS "originalName", size_bytes AS "sizeBytes",sha256 @@ -152,17 +152,38 @@ export class InspectionActPdfService { return { buffer: await this.verifiedImage(this.root, row), originalName: row.originalName, mimeType: 'application/pdf' }; } - private async actContext(actId: string): Promise<{ companyName: string | null; areaName: string | null; scopeName: string | null }> { + private async actContext(actId: string): Promise { const [row] = await this.dataSource.query(` - SELECT company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName" + SELECT company.name AS "companyName",department.name AS "departmentName",area.name AS "areaName", + scope.name AS "scopeName",scope.code AS "scopeCode",scope_type.name AS "scopeTypeName",scope_type.code AS "scopeTypeCode", + COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.name END,yacimiento_from_findings.name) AS "yacimientoName", + COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.code END,yacimiento_from_findings.code) AS "yacimientoCode", + btrim(concat_ws(' ',lead.first_name,lead.last_name)) AS "leadInspectorName" FROM inspection_acts act JOIN inspection_visits visit ON visit.id=act.visit_id LEFT JOIN assets company ON company.id=visit.operator_company_id LEFT JOIN assets area ON area.id=visit.operational_area_id + LEFT JOIN assets department ON department.id=area.parent_id LEFT JOIN assets scope ON scope.id=visit.scope_asset_id + LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id + LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id + LEFT JOIN LATERAL ( + WITH RECURSIVE ancestors AS ( + SELECT asset.id,asset.parent_id,asset.code,asset.name,asset.asset_type_id + FROM inspection_findings finding JOIN assets asset ON asset.id=finding.asset_id + WHERE finding.act_id=act.id AND finding.status<>'VOIDED' + UNION + SELECT parent.id,parent.parent_id,parent.code,parent.name,parent.asset_type_id + FROM ancestors JOIN assets parent ON parent.id=ancestors.parent_id + ) + SELECT string_agg(DISTINCT ancestors.name, ', ' ORDER BY ancestors.name) AS name, + string_agg(DISTINCT ancestors.code, ', ' ORDER BY ancestors.code) AS code + FROM ancestors JOIN asset_types ancestor_type ON ancestor_type.id=ancestors.asset_type_id + WHERE lower(ancestor_type.code)='yacimiento' + ) yacimiento_from_findings ON true WHERE act.id=$1 - `, [actId]) as Array<{ companyName: string | null; areaName: string | null; scopeName: string | null }>; - return row ?? { companyName: null, areaName: null, scopeName: null }; + `, [actId]) as ActPdfContext[]; + return row ?? {}; } private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise { diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 663c53c..ea21eb0 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-10'; -export const API_PHASE = 'F6.9'; +export const API_VERSION = '0.29.0-11'; +export const API_PHASE = 'F6.10'; diff --git a/api-v3/test/unit/act-findings-only-regression.test.ts b/api-v3/test/unit/act-findings-only-regression.test.ts index 737a519..3abac9b 100644 --- a/api-v3/test/unit/act-findings-only-regression.test.ts +++ b/api-v3/test/unit/act-findings-only-regression.test.ts @@ -8,7 +8,7 @@ const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2' test('Acta PDF renders Hallazgos and finding evidence, never standalone Inventory or field photos', () => { const source = api('inspection-reports/inspection-act-pdf-builder.ts'); - assert.match(source, /heading\('Hallazgos y fotografías'\)/); + assert.match(source, /section\('5\. Hallazgos'\)/); assert.match(source, /item\.findingId === text\(finding\.id\)/); assert.doesNotMatch(source, /Instalaciones inspeccionadas/); assert.doesNotMatch(source, /Otras instalaciones inspeccionadas/); @@ -46,3 +46,25 @@ test('Acta sealed snapshot excludes Inventory that has no Hallazgo and PDF skips assert.match(pdfService, /fieldImages\(actId, false\)/); assert.match(pdfService, /includeAssetPhotos \? await this\.dataSource\.query/); }); + +test('Acta PDF explains each Hallazgo with frozen territorial, technical and recurrence context', () => { + const pdf = api('inspection-reports/inspection-act-pdf-builder.ts'); + const closing = api('inspection-closing/inspection-closing.service.ts'); + assert.match(pdf, /Contexto territorial y operativo/); + assert.match(pdf, /Inspector\/a responsable/); + assert.match(pdf, /Tipo de instalación/); + assert.match(pdf, /Qué se constató/); + assert.match(pdf, /Referencia de catálogo/); + assert.match(pdf, /Normativa \/ Base legal/); + assert.match(pdf, /Reincidencia/); + assert.match(pdf, /Alcance de la inspección/); + assert.match(pdf, /scopeTypeCode/); + assert.match(pdf, /Antecedente relacionado/); + assert.match(closing, /DH-ACT-LIFECYCLE-V5/); + assert.match(closing, /AS department/); + assert.match(closing, /AS "leadInspector"/); + assert.match(closing, /AS "installationTypeName"/); + assert.match(closing, /AS recurrence/); + const service = api('inspection-reports/inspection-act-pdf.service.ts'); + assert.match(service, /yacimiento_from_findings/); +}); diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index d3d1e22..a62e5e2 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.9 release', () => { - assert.equal(API_PHASE, 'F6.9'); +test('health metadata reports the current F6.10 release', () => { + assert.equal(API_PHASE, 'F6.10'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-10'); -}); \ No newline at end of file + assert.equal(API_VERSION, '0.29.0-11'); +}); diff --git a/docs/PHASE_F6_10_ACTA_PDF_COMPLETE.md b/docs/PHASE_F6_10_ACTA_PDF_COMPLETE.md new file mode 100644 index 0000000..c590b84 --- /dev/null +++ b/docs/PHASE_F6_10_ACTA_PDF_COMPLETE.md @@ -0,0 +1,24 @@ +# F6.10 · Acta PDF completa y autoexplicativa + +## Objetivo + +Ordenar el Acta consolidada para que pueda comprenderse sin consultar el sistema y reforzar la explicación de cada Hallazgo. + +## Contenido documental + +El PDF se organiza en siete bloques: identificación, contexto territorial y operativo, intervinientes, datos del Acta, Hallazgos, firmas/manifestaciones e integridad/cierre. + +Cada Hallazgo congela y presenta el elemento afectado, tipo de instalación, ruta Departamento → Área → Yacimiento → Instalación/Subinstalación, denominación, constatación, referencia de catálogo u OTROS, criterio técnico disponible, base legal, gravedad, reincidencia/antecedente y fotografías vinculadas directamente al Hallazgo. + +No se incorporan al Acta altas de campo, instalaciones ni fotografías sin Hallazgo. Los plazos y la urgencia continúan perteneciendo al Acta, no a los Hallazgos. + +## Inmutabilidad + +El snapshot de cierre sube a `DH-ACT-LIFECYCLE-V5` para congelar contexto territorial, operadora, inspectores, ruta técnica y metadatos de catálogo/reincidencia. El PDF consolidado pasa a plantilla documental v3, conservando disponibles las revisiones v1/v2 ya emitidas. + +## Validación + +- API typecheck: OK +- API tests: 470/470 +- API build: OK +- PDF de muestra: 3 páginas, lectura completa y fechas Mendoza en formato administrativo de 24 horas.