From af6256ce0fcbdf89d492dcadd2b44834aba7923b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:20:56 -0300 Subject: [PATCH] F4: implement finding recurrence workflow --- .../finding-recurrence.service.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 api-v3/src/inspection-findings/finding-recurrence.service.ts diff --git a/api-v3/src/inspection-findings/finding-recurrence.service.ts b/api-v3/src/inspection-findings/finding-recurrence.service.ts new file mode 100644 index 0000000..a3e7c3e --- /dev/null +++ b/api-v3/src/inspection-findings/finding-recurrence.service.ts @@ -0,0 +1,232 @@ +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'; + +interface FindingRecurrenceContext { + id: string; + assetId: string; + catalogItemId: string | null; + title: string; + createdAt: Date; + actId: string; + actStatus: string; + visitId: string; + visitStatus: string; +} + +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 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, + ) { + 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_RECURRENCE_LINKED, + 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 }, + }, manager); + }); + return this.findings.getById(id); + } + + async clear( + id: string, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + 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_RECURRENCE_CLEARED, + entityType: 'inspection_finding', + entityId: id, + beforeData: before, + afterData: { isRecurrence: false, recurrenceOfFindingId: null }, + metadata: { actId: current.actId, visitId: current.visitId }, + }, manager); + }); + return this.findings.getById(id); + } + + 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; + } +}