diff --git a/api-v3/src/inspection-findings/inspection-finding-recurrence.service.ts b/api-v3/src/inspection-findings/inspection-finding-recurrence.service.ts new file mode 100644 index 0000000..1d72dc5 --- /dev/null +++ b/api-v3/src/inspection-findings/inspection-finding-recurrence.service.ts @@ -0,0 +1,301 @@ +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', + }); + } +}