import { ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction } from '../database/entities'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import type { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto'; @Injectable() export class InspectionFindingRecurrenceService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async candidates(actId: string, assetId: string) { await this.requireDraftActAsset(actId, assetId); const data = await this.dataSource.query(` SELECT finding.id, finding.code, finding.title, finding.description, finding.severity, finding.catalog_item_id AS "catalogItemId", finding.is_recurrence AS "isRecurrence", finding.antecedent_finding_id AS "antecedentFindingId", finding.created_at AS "createdAt", act.id AS "actId", act.code AS "actCode", act.occurred_at AS "actOccurredAt", visit.id AS "visitId", visit.code AS "visitCode" FROM inspection_findings finding INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE finding.asset_id = $1 AND finding.status = 'OPEN' AND finding.act_id <> $2 ORDER BY act.occurred_at DESC, finding.created_at DESC, finding.id DESC `, [assetId, actId]); return { data }; } async create( actId: string, dto: CreateRecurrentInspectionFindingDto, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const [act] = await manager.query(` SELECT act.id, act.code, act.status, act.occurred_at AS "occurredAt", act.visit_id AS "visitId", visit.status AS "visitStatus" FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE act.id = $1 FOR UPDATE OF act, visit `, [actId]) as Array<{ id: string; code: string; status: string; occurredAt: Date; visitId: string; visitStatus: string; }>; if (!act) throw this.actNotFound(); if (act.status !== 'DRAFT' || act.visitStatus !== 'IN_PROGRESS') { throw new ConflictException({ code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE', message: 'La reincidencia sólo puede registrarse mientras el Acta está en borrador y la inspección en curso', }); } if (!principal.permissions.includes('inspections.manage')) { const [member] = await manager.query(` SELECT 1 AS found FROM inspection_visit_members WHERE visit_id = $1 AND user_id = $2 AND included = true LIMIT 1 `, [act.visitId, principal.userId]) as Array<{ found: number }>; if (!member) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'La inspección no está asignada al usuario actual', }); } } const [antecedent] = await manager.query(` SELECT finding.id, finding.act_id AS "actId", finding.asset_id AS "assetId", finding.catalog_item_id AS "catalogItemId", finding.title, finding.legal_basis AS "legalBasis", finding.glossary, finding.catalog_revision AS "catalogRevision", finding.suggested_severity AS "suggestedSeverity", finding.severity, finding.status, previous_act.code AS "actCode", previous_act.occurred_at AS "occurredAt" FROM inspection_findings finding INNER JOIN inspection_acts previous_act ON previous_act.id = finding.act_id WHERE finding.id = $1 FOR SHARE OF finding, previous_act `, [dto.antecedentFindingId]) as Array<{ id: string; actId: string; assetId: string; catalogItemId: string | null; title: string; legalBasis: string | null; glossary: string | null; catalogRevision: number | null; suggestedSeverity: number | null; severity: number | null; status: string; actCode: string; occurredAt: Date; }>; if (!antecedent) { throw new NotFoundException({ code: 'INSPECTION_FINDING_ANTECEDENT_NOT_FOUND', message: 'No se encontró el Hallazgo antecedente seleccionado', }); } if (antecedent.status !== 'OPEN') { throw new ConflictException({ code: 'INSPECTION_FINDING_ANTECEDENT_RESOLVED', message: 'Sólo un Hallazgo anterior todavía abierto puede originar una reincidencia', }); } if (antecedent.actId === actId) { throw new ConflictException({ code: 'INSPECTION_FINDING_RECURRENCE_SAME_ACT', message: 'Una reincidencia debe referenciar un Hallazgo de un Acta anterior', }); } if (new Date(antecedent.occurredAt).getTime() > new Date(act.occurredAt).getTime()) { throw new ConflictException({ code: 'INSPECTION_FINDING_ANTECEDENT_AFTER_ACT', message: 'El antecedente no puede pertenecer a un Acta posterior a la actual', }); } const [assetLink] = await manager.query(` SELECT 1 AS found FROM inspection_act_assets WHERE act_id = $1 AND asset_id = $2 AND included = true LIMIT 1 `, [actId, antecedent.assetId]) as Array<{ found: number }>; if (!assetLink) { throw new ConflictException({ code: 'INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT', message: 'El Inventario del Hallazgo antecedente no forma parte del Acta actual', }); } const [sequence] = await manager.query(` SELECT COALESCE(MAX(finding_number), 0)::integer + 1 AS number FROM inspection_findings WHERE act_id = $1 `, [actId]) as Array<{ number: number }>; const findingNumber = Number(sequence?.number ?? 1); if (findingNumber > 999) { throw new ConflictException({ code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED', message: 'El Acta alcanzó el máximo de 999 Hallazgos', }); } const code = `${act.code}-H${String(findingNumber).padStart(3, '0')}`; const [created] = await manager.query(` INSERT INTO inspection_findings ( act_id, asset_id, catalog_item_id, finding_number, code, status, title, description, legal_basis, glossary, catalog_revision, suggested_severity, severity, is_recurrence, antecedent_finding_id, correction_due_on, current_version, created_by, updated_by ) VALUES ( $1,$2,$3,$4,$5,'OPEN',$6,$7,$8,$9,$10,$11,$12,true,$13,$14,1,$15,$15 ) RETURNING id, created_at AS "createdAt", updated_at AS "updatedAt" `, [ actId, antecedent.assetId, antecedent.catalogItemId, findingNumber, code, antecedent.title, dto.description, antecedent.legalBasis, antecedent.glossary, antecedent.catalogRevision, antecedent.suggestedSeverity, dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity, antecedent.id, dto.correctionDueOn ?? null, principal.userId, ]) as Array<{ id: string; createdAt: Date; updatedAt: Date }>; const snapshot = { id: created.id, actId, assetId: antecedent.assetId, catalogItemId: antecedent.catalogItemId, findingNumber, code, status: 'OPEN', title: antecedent.title, description: dto.description, legalBasis: antecedent.legalBasis, glossary: antecedent.glossary, catalogRevision: antecedent.catalogRevision, suggestedSeverity: antecedent.suggestedSeverity, severity: dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity, isRecurrence: true, antecedentFindingId: antecedent.id, antecedentCode: `${antecedent.actCode}`, correctionDueOn: dto.correctionDueOn ?? null, currentVersion: 1, createdAt: created.createdAt, updatedAt: created.updatedAt, }; await manager.query(` INSERT INTO inspection_finding_versions ( finding_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, 1, 'CREATED', $2::jsonb, $3, $4) `, [created.id, snapshot, principal.userId, principal.username]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_CREATED, entityType: 'inspection_finding', entityId: created.id, afterData: snapshot, metadata: { actId, visitId: act.visitId, recurrence: true, antecedentFindingId: antecedent.id, }, }, manager); return snapshot; }); } private async requireDraftActAsset(actId: string, assetId: string): Promise { const [row] = await this.dataSource.query(` SELECT 1 AS found FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN inspection_act_assets link ON link.act_id = act.id AND link.asset_id = $2 AND link.included = true WHERE act.id = $1 AND act.status = 'DRAFT' AND visit.status = 'IN_PROGRESS' LIMIT 1 `, [actId, assetId]) as Array<{ found: number }>; if (!row) { throw new ConflictException({ code: 'INSPECTION_FINDING_RECURRENCE_CONTEXT_INVALID', message: 'El Inventario debe estar incluido en un Acta en borrador de una inspección en curso', }); } } private actNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } }