import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; type DossierRecord = Record; @Injectable() export class ActivityDossierService { private readonly logger = new Logger(ActivityDossierService.name); constructor(private readonly dataSource: DataSource) {} async dossier(id: string): Promise> { const assetRows = (await this.dataSource.query( `SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt" FROM assets WHERE id = $1`, [id], )) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>; const asset = assetRows[0]; if (!asset) { throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Activo no encontrado', }); } const failedFacets: string[] = []; const safeQuery = async (facet: string, sql: string): Promise => { try { return (await this.dataSource.query(sql, [id])) as DossierRecord[]; } catch (error) { failedFacets.push(facet); const message = error instanceof Error ? error.message : String(error); this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`); return []; } }; const [ visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions, ] = await Promise.all([ safeQuery('visits', ` SELECT visit.id, visit.code, visit.status, visit.planned_start_at AS "plannedStartAt", visit.actual_started_at AS "actualStartedAt", visit.actual_closed_at AS "actualClosedAt", visit.created_at AS "createdAt" FROM inspection_visits visit WHERE visit.scope_asset_id = $1 OR EXISTS ( SELECT 1 FROM inspection_visit_assets visit_asset WHERE visit_asset.visit_id = visit.id AND visit_asset.asset_id = $1 AND visit_asset.included = true ) OR EXISTS ( SELECT 1 FROM inspection_acts act JOIN inspection_act_assets act_asset ON act_asset.act_id = act.id AND act_asset.included = true WHERE act.visit_id = visit.id AND act_asset.asset_id = $1 ) OR EXISTS ( SELECT 1 FROM inspection_acts act JOIN inspection_findings finding ON finding.act_id = act.id WHERE act.visit_id = visit.id AND finding.asset_id = $1 ) ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC LIMIT 200 `), safeQuery('acts', ` SELECT act.id, act.visit_id AS "visitId", act.code, act.status, act.occurred_at AS "occurredAt", act.title, act.summary, act.closed_at AS "closedAt", act.current_version AS "currentVersion", visit.code AS "visitCode" FROM inspection_acts act JOIN inspection_visits visit ON visit.id = act.visit_id WHERE EXISTS ( SELECT 1 FROM inspection_act_assets act_asset WHERE act_asset.act_id = act.id AND act_asset.asset_id = $1 AND act_asset.included = true ) OR EXISTS ( SELECT 1 FROM inspection_findings finding WHERE finding.act_id = act.id AND finding.asset_id = $1 ) ORDER BY act.occurred_at DESC LIMIT 200 `), safeQuery('findings', ` SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status, finding.title, finding.description, finding.correction_due_on AS "correctionDueOn", finding.company_response_received_on AS "companyResponseReceivedOn", finding.next_control_on AS "nextControlOn", finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt", act.code AS "actCode", act.occurred_at AS "actOccurredAt", visit.id AS "visitId", visit.code AS "visitCode" FROM inspection_findings finding JOIN inspection_acts act ON act.id = finding.act_id JOIN inspection_visits visit ON visit.id = act.visit_id WHERE finding.asset_id = $1 ORDER BY finding.created_at DESC LIMIT 500 `), safeQuery('evidence', ` SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId", evidence.kind, evidence.purpose, evidence.original_name AS "originalName", evidence.title, evidence.description, evidence.captured_at AS "capturedAt", evidence.created_at AS "createdAt", finding.code AS "findingCode", finding.title AS "findingTitle" FROM inspection_finding_evidence evidence JOIN inspection_findings finding ON finding.id = evidence.finding_id WHERE finding.asset_id = $1 ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC LIMIT 500 `), safeQuery('communications', ` SELECT communication.id, communication.finding_id AS "findingId", communication.direction, communication.channel, communication.type, communication.occurred_at AS "occurredAt", communication.subject, communication.details, communication.contact_name AS "contactName", communication.created_at AS "createdAt", finding.code AS "findingCode", finding.title AS "findingTitle" FROM inspection_finding_communications communication JOIN inspection_findings finding ON finding.id = communication.finding_id WHERE finding.asset_id = $1 ORDER BY communication.occurred_at DESC LIMIT 500 `), safeQuery('verificationResults', ` SELECT verification_link.id, verification_link.finding_id AS "findingId", verification_link.visit_id AS "visitId", verification_link.target_control_on AS "targetControlOn", verification_link.outcome, verification_link.result_notes AS "resultNotes", verification_link.verified_at AS "verifiedAt", verification_link.result_recorded_at AS "resultRecordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn", finding.code AS "findingCode", finding.title AS "findingTitle", visit.code AS "visitCode", visit.status AS "visitStatus", (SELECT COUNT(*)::integer FROM inspection_finding_evidence verification_evidence WHERE verification_evidence.finding_id = finding.id AND verification_evidence.verification_visit_id = visit.id AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount" FROM inspection_finding_verification_visits verification_link JOIN inspection_findings finding ON finding.id = verification_link.finding_id JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC LIMIT 500 `), safeQuery('documents', ` SELECT document.id, document.document_type AS "documentType", document.document_number AS "documentNumber", document.title, document.issuer, document.document_date AS "documentDate", document.external_reference AS "externalReference", link.relation_type AS "relationType", link.notes, link.created_at AS "linkedAt" FROM asset_source_documents link JOIN source_documents document ON document.id = link.document_id WHERE link.asset_id = $1 ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC LIMIT 300 `), safeQuery('inspectionReports', ` SELECT report.id, report.code, report.status, report.pdf_status AS "pdfStatus", report.title, report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256", act.id AS "actId", act.code AS "actCode", visit.id AS "visitId", visit.code AS "visitCode" FROM inspection_reports report JOIN inspection_acts act ON act.id = report.act_id JOIN inspection_visits visit ON visit.id = report.visit_id WHERE visit.scope_asset_id = $1 OR EXISTS ( SELECT 1 FROM inspection_act_assets act_asset WHERE act_asset.act_id = act.id AND act_asset.asset_id = $1 AND act_asset.included = true ) OR EXISTS ( SELECT 1 FROM inspection_findings finding WHERE finding.act_id = act.id AND finding.asset_id = $1 ) ORDER BY report.generated_at DESC LIMIT 200 `), safeQuery('media', ` SELECT media.id, media.kind, media.original_name AS "originalName", media.title, media.description, media.captured_at AS "capturedAt", media.created_at AS "createdAt", media.source FROM asset_media media WHERE media.asset_id = $1 AND media.deleted_at IS NULL ORDER BY COALESCE(media.captured_at, media.created_at) DESC LIMIT 500 `), safeQuery('versions', ` SELECT version.id, version.version_number AS "versionNumber", version.change_type AS "changeType", version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername", version.source FROM asset_versions version WHERE version.asset_id = $1 ORDER BY version.occurred_at DESC LIMIT 500 `), ]); const timeline: DossierRecord[] = []; const push = (event: DossierRecord) => timeline.push(event); versions.forEach((version) => push({ id: `version:${String(version.id)}`, kind: 'INVENTORY_CHANGE', occurredAt: version.occurredAt, title: version.changeType === 'CREATED' || version.changeType === 'BASELINE' ? 'Registro incorporado al inventario' : 'Inventario actualizado', description: Array.isArray(version.changedFields) && version.changedFields.length > 0 ? `Campos: ${(version.changedFields as string[]).join(', ')}` : null, meta: { versionNumber: version.versionNumber, changeType: version.changeType, actorUsername: version.actorUsername, source: version.source, }, })); visits.forEach((visit) => push({ id: `visit:${String(visit.id)}`, kind: 'INSPECTION', occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt, title: `Inspección ${String(visit.code)}`, description: null, href: `/inspecciones/${String(visit.id)}`, meta: { status: visit.status }, })); acts.forEach((act) => push({ id: `act:${String(act.id)}`, kind: 'ACT', occurredAt: act.occurredAt, title: `Acta ${String(act.code)}`, description: act.title, href: `/inspecciones/actas/${String(act.id)}`, meta: { status: act.status, visitCode: act.visitCode }, })); findings.forEach((finding) => { push({ id: `finding:${String(finding.id)}`, kind: 'FINDING', occurredAt: finding.createdAt, title: `Hallazgo ${String(finding.code)}`, description: finding.title, href: `/hallazgos/${String(finding.id)}`, meta: { status: finding.status, actCode: finding.actCode }, }); if (finding.closedAt) { push({ id: `finding-close:${String(finding.id)}`, kind: 'FINDING_CLOSED', occurredAt: finding.closedAt, title: `Hallazgo ${String(finding.code)} cerrado`, description: finding.closureNotes, href: `/hallazgos/${String(finding.id)}`, meta: { status: finding.status }, }); } }); verificationResults.forEach((verification) => push({ id: `verification:${String(verification.id)}`, kind: 'VERIFICATION', occurredAt: verification.verifiedAt ?? verification.resultRecordedAt, title: verification.outcome === 'RESOLVED' ? `Verificación conforme · ${String(verification.findingCode)}` : verification.outcome === 'NOT_RESOLVED' ? `Verificación no conforme · ${String(verification.findingCode)}` : `Verificación reprogramada · ${String(verification.findingCode)}`, description: verification.resultNotes, href: `/hallazgos/${String(verification.findingId)}`, meta: { outcome: verification.outcome, visitCode: verification.visitCode, targetControlOn: verification.targetControlOn, rescheduledControlOn: verification.rescheduledControlOn, evidenceCount: verification.evidenceCount, }, })); communications.forEach((communication) => push({ id: `communication:${String(communication.id)}`, kind: 'COMMUNICATION', occurredAt: communication.occurredAt, title: communication.type === 'COMPANY_RESPONSE' ? 'Respuesta de la empresa' : String(communication.subject), description: communication.details, href: `/hallazgos/${String(communication.findingId)}`, meta: { findingCode: communication.findingCode, direction: communication.direction, channel: communication.channel, }, })); evidence.forEach((item) => push({ id: `evidence:${String(item.id)}`, kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT', occurredAt: item.capturedAt ?? item.createdAt, title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado', description: item.title ?? item.originalName, href: `/hallazgos/${String(item.findingId)}`, meta: { findingCode: item.findingCode, purpose: item.purpose }, })); inspectionReports.forEach((report) => push({ id: `inspection-report:${String(report.id)}`, kind: 'REPORT', occurredAt: report.generatedAt, title: `Informe ${String(report.code)}`, description: report.title, href: `/informes/${String(report.id)}`, meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode }, })); documents.forEach((document) => push({ id: `source-document:${String(document.id)}`, kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT', occurredAt: document.documentDate ?? document.linkedAt, title: String(document.title), description: document.documentNumber ? `Documento ${String(document.documentNumber)}` : 'Documento vinculado al inventario', meta: { documentType: document.documentType, relationType: document.relationType }, })); media.forEach((item) => push({ id: `asset-media:${String(item.id)}`, kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT', occurredAt: item.capturedAt ?? item.createdAt, title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario', description: item.title ?? item.originalName, meta: { source: item.source }, })); timeline.sort( (a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(), ); const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length; const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); return { asset: { id: asset.id, code: asset.code, name: asset.name, commonName: asset.commonName, }, counters: { inspections: visits.length, acts: acts.length, findings: findings.length, verifications: verificationResults.length, openFindings, closedFindings, evidence: evidence.length, documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length, photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length, reports: reports.length + inspectionReports.length, }, visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, reports, media, versions, timeline: timeline.slice(0, 500), warnings: failedFacets, }; } }