import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } 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 { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto'; import { InspectionFindingsService } from './inspection-findings.service'; import type { InspectionFindingView } from './inspection-findings.service'; interface FindingRecurrenceContext { id: string; assetId: string; catalogItemId: string | null; title: string; createdAt: Date; actId: string; actStatus: string; visitId: string; visitStatus: string; } export interface FindingRecurrenceState { isRecurrence: boolean; recurrenceOfFindingId: string | null; recurrenceOfFindingCode: string | null; } export type FindingWithRecurrenceView = InspectionFindingView & FindingRecurrenceState; function comparable(value: string): string { return value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' '); } @Injectable() export class FindingRecurrenceService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly findings: InspectionFindingsService, ) {} async state(id: string): Promise { const [row] = await this.dataSource.query(` SELECT finding.is_recurrence AS "isRecurrence", finding.recurrence_of_finding_id AS "recurrenceOfFindingId", antecedent.code AS "recurrenceOfFindingCode" FROM inspection_findings finding LEFT JOIN inspection_findings antecedent ON antecedent.id = finding.recurrence_of_finding_id WHERE finding.id = $1 `, [id]) as FindingRecurrenceState[]; if (!row) { throw new NotFoundException({ code: 'INSPECTION_FINDING_NOT_FOUND', message: 'Hallazgo no encontrado', }); } return row; } async candidates(id: string) { const current = await this.requireFinding(this.dataSource.manager, id, false); const rows = await this.dataSource.query(` SELECT finding.id,finding.code,finding.title,finding.description,finding.status, finding.catalog_item_id AS "catalogItemId",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",visit.status AS "visitStatus" 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 AND finding.id<>$2 AND finding.status<>'VOIDED' AND finding.created_at<$3 AND ( ($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid) OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5))) ) ORDER BY finding.created_at DESC,finding.id DESC LIMIT 20 `, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]); return { findingId: current.id, assetId: current.assetId, hasCandidates: rows.length > 0, data: rows, }; } async link( id: string, dto: SetFindingRecurrenceDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); await this.dataSource.transaction(async (manager) => { const current = await this.requireFinding(manager, id, true); this.assertEditable(current); await this.assertActorAssigned(manager, current.visitId, principal); if (id === dto.recurrenceOfFindingId) { throw new BadRequestException({ code: 'FINDING_CANNOT_RECUR_FROM_SELF', message: 'Un Hallazgo no puede ser reincidencia de sí mismo', }); } const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false); if (previous.assetId !== current.assetId) { throw new ConflictException({ code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY', message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario', }); } if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) { throw new ConflictException({ code: 'FINDING_RECURRENCE_NOT_PREVIOUS', message: 'El Hallazgo de referencia debe ser anterior al actual', }); } const sameCatalog = current.catalogItemId && previous.catalogItemId ? current.catalogItemId === previous.catalogItemId : current.catalogItemId === previous.catalogItemId; const sameTitle = comparable(current.title) === comparable(previous.title); if (!sameCatalog && !sameTitle) { throw new ConflictException({ code: 'FINDING_RECURRENCE_NOT_EQUIVALENT', message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo', }); } const [before] = await manager.query(` SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId" FROM inspection_findings WHERE id=$1 FOR UPDATE `, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>; await manager.query(` UPDATE inspection_findings SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [id, previous.id, principal.userId]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_UPDATED, entityType: 'inspection_finding', entityId: id, beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null }, afterData: { isRecurrence: true, recurrenceOfFindingId: previous.id, recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id), }, metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' }, }, manager); }); const [finding, recurrence] = await Promise.all([ this.findings.getById(id), this.state(id), ]); return { ...finding, ...recurrence }; } async clear( id: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); await this.dataSource.transaction(async (manager) => { const current = await this.requireFinding(manager, id, true); this.assertEditable(current); await this.assertActorAssigned(manager, current.visitId, principal); const [before] = await manager.query(` SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId" FROM inspection_findings WHERE id=$1 FOR UPDATE `, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>; if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return; await manager.query(` UPDATE inspection_findings SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1 `, [id, principal.userId]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_UPDATED, entityType: 'inspection_finding', entityId: id, beforeData: before, afterData: { isRecurrence: false, recurrenceOfFindingId: null }, metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' }, }, manager); }); const [finding, recurrence] = await Promise.all([ this.findings.getById(id), this.state(id), ]); return { ...finding, ...recurrence }; } private async requireFinding( manager: EntityManager, id: string, lock: boolean, ): Promise { const [row] = await manager.query(` SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId", finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId", act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus" 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.id=$1 ${lock ? 'FOR UPDATE OF finding,act,visit' : ''} `, [id]) as FindingRecurrenceContext[]; if (!row) { throw new NotFoundException({ code: 'INSPECTION_FINDING_NOT_FOUND', message: 'Hallazgo no encontrado', }); } return row; } private assertEditable(finding: FindingRecurrenceContext): void { if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') { throw new ConflictException({ code: 'FINDING_RECURRENCE_IMMUTABLE', message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso', }); } } private async assertActorAssigned( manager: EntityManager, visitId: string, principal: AuthPrincipal, ): Promise { if (principal.permissions.includes('inspections.manage')) return; const [row] = await manager.query(` SELECT 1 FROM inspection_visit_members WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 `, [visitId, principal.userId]) as unknown[]; if (!row) { throw new ForbiddenException({ code: 'INSPECTION_NOT_ASSIGNED', message: 'La Inspección no está asignada al usuario actual', }); } } private async codeForFinding(manager: EntityManager, id: string): Promise { const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>; return row?.code ?? null; } }