From 83c9e2ca3e451ba188f8fa2139fc1fe3510adf33 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 12 Sep 2026 12:11:02 -0300 Subject: [PATCH] fix(api): aislar y reparar consultas de actividad de inventario --- .../asset-master/activity-dossier.service.ts | 407 ++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 api-v3/src/asset-master/activity-dossier.service.ts diff --git a/api-v3/src/asset-master/activity-dossier.service.ts b/api-v3/src/asset-master/activity-dossier.service.ts new file mode 100644 index 0000000..f53d509 --- /dev/null +++ b/api-v3/src/asset-master/activity-dossier.service.ts @@ -0,0 +1,407 @@ +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, + }; + } +}