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, InspectionAct, InspectionActStatus, InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus, InspectionFindingVerificationEventType, InspectionFindingVersionEvent, InspectionVerificationOutcome, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; import type { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto'; import type { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto'; import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto'; import type { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto'; import type { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { appendVerificationEvent } from '../inspection-verifications/verification-event-ledger'; interface FindingContextView { id: string; code: string; name: string; } interface FindingAssetView { id: string; code: string; name: string; commonName: string | null; typeName: string; operatorCompany: FindingContextView | null; operationalArea: FindingContextView | null; } type FindingCatalogView = { id: string; code: string; categoryId: string; categoryName: string; sourceNumber: number; revision: number; suggestedSeverity: number | null; } | null; interface FindingDocumentView { actId: string; actCode: string; actStatus: InspectionActStatus; visitId: string; visitCode: string; visitStatus: InspectionVisitStatus; } export interface InspectionFindingListItem { id: string; actId: string; assetId: string; catalogItemId: string | null; findingNumber: number; code: string; status: InspectionFindingStatus; title: string; description: string; legalBasis: string | null; glossary: string | null; catalogRevision: number | null; suggestedSeverity: number | null; severity: number | null; correctionDueOn: string | null; responseDueBasis: InspectionFindingResponseDueBasis | null; responseDueDays: number | null; responseDueBaseOn: string | null; reportNotifiedOn: string | null; companyResponse: string | null; companyResponseReceivedOn: string | null; companyCommittedCorrectionOn: string | null; nextControlOn: string | null; currentVersion: number; closedAt: Date | null; closureNotes: string | null; asset: FindingAssetView; catalog: FindingCatalogView; document: FindingDocumentView; verificationVisit: { id: string; code: string; status: InspectionVisitStatus; plannedStartAt: Date | null; } | null; latestVerification: { visitId: string; visitCode: string; visitStatus: InspectionVisitStatus; targetControlOn: string | null; outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null; resultNotes: string | null; verifiedAt: Date | null; resultRecordedAt: Date | null; rescheduledControlOn: string | null; evidenceCount: number; } | null; createdAt: Date; updatedAt: Date; } interface FindingVersionView { id: string; versionNumber: number; event: InspectionFindingVersionEvent; snapshot: Record; actorUserId: string | null; actorUsername: string | null; createdAt: Date; } export interface FindingVerificationHistoryEvent { id: string; eventType: InspectionFindingVerificationEventType; occurredAt: Date; targetControlOn: string | null; previousControlOn: string | null; nextControlOn: string | null; outcome: InspectionVerificationOutcome | null; notes: string | null; actorUserId: string | null; actorUsername: string | null; verificationVisit: { id: string; code: string; status: InspectionVisitStatus; plannedStartAt: Date | null; } | null; evidenceCount: number; } export type InspectionFindingOfficeStage = | 'WAITING_COMPANY' | 'COMPANY_OVERDUE' | 'DEFINE_VERIFICATION' | 'PLAN_VERIFICATION' | 'VERIFICATION_PLANNED' | 'RESCHEDULE_VERIFICATION' | 'READY_TO_CLOSE' | 'CLOSED'; export interface InspectionFindingOfficeWorkflow { stage: InspectionFindingOfficeStage; label: string; action: string; dueOn: string | null; overdue: boolean; } export interface InspectionFindingView extends InspectionFindingListItem { versions: FindingVersionView[]; verificationHistory: FindingVerificationHistoryEvent[]; officeWorkflow: InspectionFindingOfficeWorkflow; } export interface InspectionFindingWorkflowCounters { open: number; waitingCompany: number; companyOverdue: number; companyDueNext7Days: number; awaitingVerificationSchedule: number; toVerify: number; verificationOverdue: number; verificationNext30Days: number; readyToClose: number; closed: number; } export interface InspectionFindingGlobalPage { data: InspectionFindingListItem[]; meta: { page: number; pageSize: number; total: number; totalPages: number }; counters: InspectionFindingWorkflowCounters; } interface CatalogSnapshot { id: string; title: string; legalBasis: string | null; glossary: string | null; revision: number; suggestedSeverity: number | null; } function findingNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_FINDING_NOT_FOUND', message: 'Hallazgo de inspección no encontrado', }); } function actNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } function addCalendarDays(dateOnly: string, days: number): string { const [year, month, day] = dateOnly.split('-').map(Number); const date = new Date(Date.UTC(year, month - 1, day)); date.setUTCDate(date.getUTCDate() + days); return date.toISOString().slice(0, 10); } @Injectable() export class InspectionFindingsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async listForAct(actId: string): Promise<{ data: InspectionFindingListItem[] }> { await this.requireAct(this.dataSource.manager, actId); const data = await this.dataSource.query( `${this.findingSelect('WHERE finding.act_id = $1')} ORDER BY finding.finding_number, finding.id`, [actId], ) as InspectionFindingListItem[]; return { data }; } async listGlobal(query: ListInspectionFindingsQueryDto): Promise { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const workflow = query.workflow ?? 'OPEN'; const values: unknown[] = []; const filters: string[] = []; const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`; const latestVerificationOutcome = `(SELECT latest_verification.outcome FROM inspection_finding_verification_visits latest_verification WHERE latest_verification.finding_id = finding.id AND latest_verification.outcome IS NOT NULL ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC LIMIT 1)`; const latestVerificationVisitStatus = `(SELECT latest_visit.status FROM inspection_finding_verification_visits latest_verification INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id WHERE latest_verification.finding_id = finding.id AND latest_verification.outcome IS NOT NULL ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC LIMIT 1)`; if (query.search) { values.push(`%${query.search}%`); const parameter = `$${values.length}`; filters.push(`( finding.code ILIKE ${parameter} OR finding.title ILIKE ${parameter} OR asset.code ILIKE ${parameter} OR asset.name ILIKE ${parameter} OR company.name ILIKE ${parameter} OR area.name ILIKE ${parameter} OR act.code ILIKE ${parameter} )`); } if (query.companyId) { values.push(query.companyId); filters.push(`company.id = $${values.length}::uuid`); } if (query.areaId) { values.push(query.areaId); filters.push(`area.id = $${values.length}::uuid`); } if (query.inspectorId) { values.push(query.inspectorId); const inspector = `$${values.length}`; filters.push(`( visit.lead_inspector_user_id = ${inspector}::uuid OR EXISTS ( SELECT 1 FROM inspection_visit_members member_filter WHERE member_filter.visit_id = visit.id AND member_filter.included = true AND member_filter.user_id = ${inspector}::uuid ) )`); } if (query.dateFrom) { values.push(query.dateFrom); filters.push(`act.occurred_at::date >= $${values.length}::date`); } if (query.dateTo) { values.push(query.dateTo); filters.push(`act.occurred_at::date <= $${values.length}::date`); } if (workflow === 'OPEN') filters.push(`finding.status = 'OPEN'`); if (workflow === 'WAITING_COMPANY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL`); if (workflow === 'COMPANY_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL AND finding.correction_due_on IS NOT NULL AND finding.correction_due_on < ${today}`); if (workflow === 'TO_SCHEDULE_VERIFICATION') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`); if (workflow === 'TO_VERIFY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`); if (workflow === 'VERIFICATION_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`); if (workflow === 'READY_TO_CLOSE') filters.push(`finding.status = 'OPEN' AND ${latestVerificationOutcome} = 'RESOLVED' AND ${latestVerificationVisitStatus} = 'CLOSED'`); if (workflow === 'CLOSED') filters.push(`finding.status = 'CLOSED'`); const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const countRows = await this.dataSource.query(` SELECT COUNT(*)::integer AS total 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 INNER JOIN assets asset ON asset.id = finding.asset_id LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id ${where} `, values) as Array<{ total: number }>; const total = Number(countRows[0]?.total ?? 0); values.push(pageSize); const limitParameter = `$${values.length}`; values.push((page - 1) * pageSize); const offsetParameter = `$${values.length}`; const data = await this.dataSource.query( `${this.findingSelect(where)} ORDER BY CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END, COALESCE(finding.next_control_on, finding.correction_due_on, '9999-12-31'::date), finding.updated_at DESC, finding.code LIMIT ${limitParameter} OFFSET ${offsetParameter}`, values, ) as InspectionFindingListItem[]; const [counters] = await this.dataSource.query(` SELECT COUNT(*) FILTER (WHERE status = 'OPEN')::integer AS "open", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL)::integer AS "waitingCompany", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on IS NOT NULL AND correction_due_on < ${today})::integer AS "companyOverdue", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on BETWEEN ${today} AND ${today} + 7)::integer AS "companyDueNext7Days", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "awaitingVerificationSchedule", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "toVerify", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND next_control_on < ${today} AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationOverdue", COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on BETWEEN ${today} AND ${today} + 30 AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationNext30Days", COUNT(*) FILTER (WHERE status = 'OPEN' AND (SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'RESOLVED' AND (SELECT vv.status FROM inspection_finding_verification_visits v INNER JOIN inspection_visits vv ON vv.id = v.visit_id WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'CLOSED')::integer AS "readyToClose", COUNT(*) FILTER (WHERE status = 'CLOSED')::integer AS "closed" FROM inspection_findings `) as InspectionFindingWorkflowCounters[]; return { data, meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, counters: { open: Number(counters?.open ?? 0), waitingCompany: Number(counters?.waitingCompany ?? 0), companyOverdue: Number(counters?.companyOverdue ?? 0), companyDueNext7Days: Number(counters?.companyDueNext7Days ?? 0), awaitingVerificationSchedule: Number(counters?.awaitingVerificationSchedule ?? 0), toVerify: Number(counters?.toVerify ?? 0), verificationOverdue: Number(counters?.verificationOverdue ?? 0), verificationNext30Days: Number(counters?.verificationNext30Days ?? 0), readyToClose: Number(counters?.readyToClose ?? 0), closed: Number(counters?.closed ?? 0), }, }; } async getById(id: string): Promise { return this.dataSource.transaction((manager) => this.loadView(manager, id)); } async getVerificationHistory(id: string): Promise { const [exists] = await this.dataSource.query('SELECT id FROM inspection_findings WHERE id = $1', [id]) as Array<{ id: string }>; if (!exists) throw findingNotFound(); return this.loadVerificationHistory(this.dataSource.manager, id); } async create( actId: string, dto: CreateInspectionFindingDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const act = await this.lockAct(manager, actId); const visit = await this.lockVisit(manager, act.visitId); this.assertActEditable(act, visit); await this.assertActorAssigned(manager, visit.id, principal); await this.assertActAsset(manager, actId, dto.assetId); const catalog = dto.catalogItemId ? await this.requireCatalogItem(manager, dto.catalogItemId, dto.assetId) : null; if (!catalog && !dto.customTitle?.trim()) { throw new BadRequestException({ code: 'INSPECTION_FINDING_TITLE_REQUIRED', message: 'Un hallazgo personalizado debe indicar un título', }); } const findingNumber = await this.allocateFindingNumber(manager, actId); if (findingNumber > 999) { throw new ConflictException({ code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED', message: 'El acta alcanzó el máximo de 999 hallazgos', }); } const finding = manager.getRepository(InspectionFinding).create({ actId, assetId: dto.assetId, catalogItemId: catalog?.id ?? null, findingNumber, code: `${act.code}-H${String(findingNumber).padStart(3, '0')}`, status: InspectionFindingStatus.OPEN, title: catalog?.title ?? dto.customTitle!.trim(), description: dto.description, legalBasis: catalog?.legalBasis ?? dto.customLegalBasis ?? null, glossary: catalog?.glossary ?? null, catalogRevision: catalog?.revision ?? null, suggestedSeverity: catalog?.suggestedSeverity ?? null, severity: dto.severity ?? catalog?.suggestedSeverity ?? null, correctionDueOn: dto.correctionDueOn ?? null, companyResponse: null, companyResponseReceivedOn: null, companyCommittedCorrectionOn: null, nextControlOn: null, currentVersion: 0, closedAt: null, closedBy: null, closureNotes: null, createdBy: principal.userId, updatedBy: principal.userId, }); await manager.getRepository(InspectionFinding).save(finding); if (!catalog) { await manager.query(` INSERT INTO finding_catalog_proposals ( finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis, proposed_severity, description, status, created_by ) SELECT $1, asset.id, asset.asset_type_id, $2, $3, $4, $5, 'PENDING', $6 FROM assets asset WHERE asset.id = $7 ON CONFLICT (finding_id) DO NOTHING `, [ finding.id, finding.title, finding.legalBasis, finding.severity, finding.description, principal.userId, finding.assetId, ]); } const versionNumber = await this.captureVersion( manager, finding, InspectionFindingVersionEvent.CREATED, principal, ); const created = await this.loadView(manager, finding.id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_CREATED, entityType: 'inspection_finding', entityId: finding.id, afterData: this.auditView(created), metadata: { actId, visitId: visit.id, versionNumber }, }, manager); return created; }); } async update( id: string, dto: UpdateInspectionFindingDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } return this.dataSource.transaction(async (manager) => { const finding = await this.lockFinding(manager, id); const act = await this.lockAct(manager, finding.actId); const visit = await this.lockVisit(manager, act.visitId); this.assertActEditable(act, visit); this.assertFindingOpen(finding); await this.assertActorAssigned(manager, visit.id, principal); if (dto.assetId !== undefined) { await this.assertActAsset(manager, act.id, dto.assetId); if (finding.catalogItemId) { await this.requireCatalogItem(manager, finding.catalogItemId, dto.assetId); } } const before = await this.loadView(manager, id); if (dto.assetId !== undefined) { finding.assetId = dto.assetId; if (!finding.catalogItemId) { await manager.query(` UPDATE finding_catalog_proposals proposal SET asset_id = asset.id, asset_type_id = asset.asset_type_id, updated_at = CURRENT_TIMESTAMP FROM assets asset WHERE proposal.finding_id = $1 AND proposal.status = 'PENDING' AND asset.id = $2 `, [finding.id, dto.assetId]); } } if (dto.description !== undefined) finding.description = dto.description; if (dto.severity !== undefined) finding.severity = dto.severity; if (dto.correctionDueOn !== undefined) finding.correctionDueOn = dto.correctionDueOn; finding.updatedBy = principal.userId; await manager.getRepository(InspectionFinding).save(finding); const versionNumber = await this.captureVersion( manager, finding, InspectionFindingVersionEvent.UPDATED, principal, ); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_UPDATED, entityType: 'inspection_finding', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { actId: act.id, visitId: visit.id, versionNumber }, }, manager); return updated; }); } async close( id: string, dto: CloseInspectionFindingDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const finding = await this.lockFinding(manager, id); this.assertFindingOpen(finding); const [activeVerification] = await manager.query(` SELECT visit.code, visit.status FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.finding_id = $1 AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') ORDER BY verification_link.created_at DESC LIMIT 1 `, [id]) as Array<{ code: string; status: InspectionVisitStatus }>; if (activeVerification) { throw new ConflictException({ code: 'INSPECTION_FINDING_VERIFICATION_ACTIVE', message: `El hallazgo tiene una verificación activa (${activeVerification.code}) y no puede cerrarse todavía`, }); } const before = await this.loadView(manager, id); finding.status = InspectionFindingStatus.CLOSED; finding.closedAt = new Date(); finding.closedBy = principal.userId; finding.closureNotes = dto.closureNotes; finding.updatedBy = principal.userId; await manager.getRepository(InspectionFinding).save(finding); const versionNumber = await this.captureVersion( manager, finding, InspectionFindingVersionEvent.FOLLOW_UP_UPDATED, principal, ); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_CLOSED, entityType: 'inspection_finding', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { actId: updated.document.actId, visitId: updated.document.visitId, versionNumber, }, }, manager); return updated; }); } async updateFollowUp( id: string, dto: UpdateInspectionFindingFollowUpDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } return this.dataSource.transaction(async (manager) => { const finding = await this.lockFinding(manager, id); this.assertFindingOpen(finding); const before = await this.loadView(manager, id); if (finding.companyResponseReceivedOn && (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined)) { throw new ConflictException({ code: 'INSPECTION_FINDING_RESPONSE_IMMUTABLE', message: 'La primera respuesta registrada no se modifica. Las nuevas presentaciones deben agregarse a la trazabilidad.', }); } const deadlineTouched = dto.responseDueBasis !== undefined || dto.responseDueDays !== undefined || dto.reportNotifiedOn !== undefined; if (deadlineTouched && finding.companyResponseReceivedOn) { throw new ConflictException({ code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED', message: 'El plazo administrativo ya no puede modificarse después de registrar la respuesta de la empresa', }); } if (deadlineTouched) { const basis = dto.responseDueBasis !== undefined ? dto.responseDueBasis : finding.responseDueBasis; const days = dto.responseDueDays !== undefined ? dto.responseDueDays : finding.responseDueDays; const reportNotifiedOn = dto.reportNotifiedOn !== undefined ? dto.reportNotifiedOn : finding.reportNotifiedOn; if (!basis || days === null || days === undefined) { throw new BadRequestException({ code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_INCOMPLETE', message: 'Definí el origen del plazo y la cantidad de días para calcular el vencimiento de respuesta', }); } let baseOn: string; if (basis === InspectionFindingResponseDueBasis.FINDING_DATE) { const [row] = await manager.query(` SELECT act.occurred_at::date::text AS "findingDate" FROM inspection_acts act WHERE act.id = $1 `, [finding.actId]) as Array<{ findingDate: string }>; if (!row?.findingDate) throw actNotFound(); baseOn = row.findingDate; } else { if (!reportNotifiedOn) { throw new BadRequestException({ code: 'INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED', message: 'Indicá la fecha en que el informe fue notificado para iniciar el plazo de respuesta', }); } baseOn = reportNotifiedOn; } finding.responseDueBasis = basis; finding.responseDueDays = days; finding.reportNotifiedOn = reportNotifiedOn ?? null; finding.responseDueBaseOn = baseOn; finding.correctionDueOn = addCalendarDays(baseOn, days); } const response = dto.companyResponse !== undefined ? dto.companyResponse : finding.companyResponse; const receivedOn = dto.companyResponseReceivedOn !== undefined ? dto.companyResponseReceivedOn : finding.companyResponseReceivedOn; const committedOn = dto.companyCommittedCorrectionOn !== undefined ? dto.companyCommittedCorrectionOn : finding.companyCommittedCorrectionOn; if ((response === null) !== (receivedOn === null)) { throw new BadRequestException({ code: 'INSPECTION_FINDING_RESPONSE_INCOMPLETE', message: 'La respuesta de la empresa debe incluir texto y fecha de recepción', }); } if (committedOn && !receivedOn) { throw new BadRequestException({ code: 'INSPECTION_FINDING_COMMITMENT_WITHOUT_RESPONSE', message: 'La fecha prometida requiere registrar primero la respuesta de la empresa', }); } if (committedOn && receivedOn && committedOn < receivedOn) { throw new BadRequestException({ code: 'INSPECTION_FINDING_COMMITMENT_BEFORE_RESPONSE', message: 'La fecha prometida no puede ser anterior a la respuesta recibida', }); } if (dto.companyResponse !== undefined) finding.companyResponse = dto.companyResponse; if (dto.companyResponseReceivedOn !== undefined) { finding.companyResponseReceivedOn = dto.companyResponseReceivedOn; } if (dto.companyCommittedCorrectionOn !== undefined) { finding.companyCommittedCorrectionOn = dto.companyCommittedCorrectionOn; } if (dto.nextControlOn !== undefined) finding.nextControlOn = dto.nextControlOn; finding.updatedBy = principal.userId; await manager.getRepository(InspectionFinding).save(finding); if (dto.nextControlOn !== undefined && dto.nextControlOn !== before.nextControlOn) { const eventType = dto.nextControlOn === null ? InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED : before.nextControlOn === null ? InspectionFindingVerificationEventType.CONTROL_DATE_DEFINED : InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED; await appendVerificationEvent(manager, { findingId: id, eventType, occurredAt: new Date(), targetControlOn: dto.nextControlOn, previousControlOn: before.nextControlOn, nextControlOn: dto.nextControlOn, notes: dto.nextControlOn === null ? 'Fecha de verificación retirada desde seguimiento de oficina' : before.nextControlOn === null ? 'Fecha de verificación definida desde seguimiento de oficina' : 'Fecha de verificación reprogramada desde seguimiento de oficina', }, principal); } const versionNumber = await this.captureVersion( manager, finding, InspectionFindingVersionEvent.FOLLOW_UP_UPDATED, principal, ); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_FINDING_FOLLOW_UP_UPDATED, entityType: 'inspection_finding', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { actId: updated.document.actId, visitId: updated.document.visitId, versionNumber, remainsOpen: updated.status === InspectionFindingStatus.OPEN, }, }, manager); return updated; }); } private findingSelect(where: string): string { return ` SELECT finding.id, finding.act_id AS "actId", finding.asset_id AS "assetId", finding.catalog_item_id AS "catalogItemId", finding.finding_number AS "findingNumber", finding.code, finding.status, finding.title, finding.description, finding.legal_basis AS "legalBasis", finding.glossary, finding.catalog_revision AS "catalogRevision", finding.suggested_severity AS "suggestedSeverity", finding.severity, finding.correction_due_on AS "correctionDueOn", finding.response_due_basis AS "responseDueBasis", finding.response_due_days AS "responseDueDays", finding.response_due_base_on AS "responseDueBaseOn", finding.report_notified_on AS "reportNotifiedOn", finding.company_response AS "companyResponse", finding.company_response_received_on AS "companyResponseReceivedOn", finding.company_committed_correction_on AS "companyCommittedCorrectionOn", finding.next_control_on AS "nextControlOn", finding.current_version AS "currentVersion", finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'commonName', asset.common_name, 'typeName', asset_type.name, 'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name ) END, 'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name ) END ) AS asset, CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', catalog.id, 'code', catalog.code, 'categoryId', category.id, 'categoryName', category.name, 'sourceNumber', catalog.source_number, 'revision', finding.catalog_revision, 'suggestedSeverity', finding.suggested_severity ) END AS catalog, JSONB_BUILD_OBJECT( 'actId', act.id, 'actCode', act.code, 'actStatus', act.status, 'visitId', visit.id, 'visitCode', visit.code, 'visitStatus', visit.status ) AS document, CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', verification_visit.id, 'code', verification_visit.code, 'status', verification_visit.status, 'plannedStartAt', verification_visit.planned_start_at ) END AS "verificationVisit", CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'visitId', latest_verification.visit_id, 'visitCode', latest_verification.visit_code, 'visitStatus', latest_verification.visit_status, 'targetControlOn', latest_verification.target_control_on, 'outcome', latest_verification.outcome, 'resultNotes', latest_verification.result_notes, 'verifiedAt', latest_verification.verified_at, 'resultRecordedAt', latest_verification.result_recorded_at, 'rescheduledControlOn', latest_verification.rescheduled_control_on, 'evidenceCount', latest_verification.evidence_count ) END AS "latestVerification", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt" 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 INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_categories category ON category.id = catalog.category_id LEFT JOIN LATERAL ( SELECT verification_target.id, verification_target.code, verification_target.status, verification_target.planned_start_at FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id WHERE verification_link.finding_id = finding.id AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') ORDER BY verification_link.created_at DESC, verification_link.id DESC LIMIT 1 ) verification_visit ON true LEFT JOIN LATERAL ( SELECT verification_link.visit_id, verification_target.code AS visit_code, verification_target.status AS visit_status, verification_link.target_control_on, verification_link.outcome, verification_link.result_notes, verification_link.verified_at, verification_link.result_recorded_at, verification_link.rescheduled_control_on, (SELECT COUNT(*)::integer FROM inspection_finding_evidence evidence WHERE evidence.finding_id = finding.id AND evidence.verification_visit_id = verification_link.visit_id AND evidence.purpose = 'VERIFICATION') AS evidence_count FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id WHERE verification_link.finding_id = finding.id ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC, verification_link.id DESC LIMIT 1 ) latest_verification ON true ${where} `; } private async loadView(manager: EntityManager, id: string): Promise { const [finding] = await manager.query( this.findingSelect('WHERE finding.id = $1'), [id], ) as InspectionFindingListItem[]; if (!finding) throw findingNotFound(); const versions = await manager.query(` SELECT id, version_number AS "versionNumber", event, snapshot, actor_user_id AS "actorUserId", actor_username AS "actorUsername", created_at AS "createdAt" FROM inspection_finding_versions WHERE finding_id = $1 ORDER BY version_number DESC `, [id]) as FindingVersionView[]; const verificationHistory = await this.loadVerificationHistory(manager, id); return { ...finding, versions, verificationHistory, officeWorkflow: this.officeWorkflow(finding) }; } private async loadVerificationHistory( manager: EntityManager, findingId: string, ): Promise { return manager.query(` SELECT event.id, event.event_type AS "eventType", event.occurred_at AS "occurredAt", event.target_control_on AS "targetControlOn", event.previous_control_on AS "previousControlOn", event.next_control_on AS "nextControlOn", event.outcome, event.notes, event.actor_user_id AS "actorUserId", COALESCE(event.actor_username, actor.username) AS "actorUsername", CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, 'status', visit.status, 'plannedStartAt', visit.planned_start_at ) END AS "verificationVisit", CASE WHEN visit.id IS NULL THEN 0 ELSE ( SELECT COUNT(*)::integer FROM inspection_finding_evidence evidence WHERE evidence.finding_id = event.finding_id AND evidence.verification_visit_id = visit.id AND evidence.purpose = 'VERIFICATION' ) END AS "evidenceCount" FROM inspection_finding_verification_events event LEFT JOIN inspection_visits visit ON visit.id = event.verification_visit_id LEFT JOIN users actor ON actor.id = event.actor_user_id WHERE event.finding_id = $1 ORDER BY event.occurred_at DESC, event.created_at DESC, event.id DESC `, [findingId]) as Promise; } private officeWorkflow(finding: InspectionFindingListItem): InspectionFindingOfficeWorkflow { if (finding.status === InspectionFindingStatus.CLOSED) { return { stage: 'CLOSED', label: 'Hallazgo cerrado', action: 'El seguimiento está finalizado y permanece disponible en el expediente histórico.', dueOn: null, overdue: false, }; } const today = this.mendozaDate(); const latest = finding.latestVerification; if (latest?.outcome === 'RESOLVED' && latest.visitStatus === InspectionVisitStatus.CLOSED) { return { stage: 'READY_TO_CLOSE', label: 'Listo para cierre', action: 'Revisar la evidencia de verificación y registrar la conclusión administrativa.', dueOn: null, overdue: false, }; } if (latest?.outcome === 'NOT_RESOLVED' && !finding.nextControlOn) { return { stage: 'RESCHEDULE_VERIFICATION', label: 'Requiere nueva verificación', action: 'Definir una nueva fecha de verificación para devolver el hallazgo a planificación.', dueOn: null, overdue: false, }; } if (!finding.companyResponseReceivedOn) { const overdue = Boolean(finding.correctionDueOn && finding.correctionDueOn < today); return { stage: overdue ? 'COMPANY_OVERDUE' : 'WAITING_COMPANY', label: overdue ? 'Respuesta de empresa vencida' : 'Esperando respuesta de empresa', action: overdue ? 'Registrar la presentación recibida o gestionar el reclamo administrativo.' : 'Registrar la respuesta de la empresa cuando sea recibida.', dueOn: finding.correctionDueOn, overdue, }; } if (!finding.nextControlOn) { return { stage: 'DEFINE_VERIFICATION', label: 'Definir fecha de verificación', action: 'Establecer cuándo debe volver a controlarse el hallazgo.', dueOn: null, overdue: false, }; } if (finding.verificationVisit) { return { stage: 'VERIFICATION_PLANNED', label: 'Verificación planificada', action: 'La visita ya está vinculada. Consultar o completar la inspección de verificación.', dueOn: finding.nextControlOn, overdue: finding.nextControlOn < today, }; } return { stage: 'PLAN_VERIFICATION', label: finding.nextControlOn < today ? 'Verificación vencida sin visita' : 'Verificación pendiente de planificación', action: 'Crear una visita de verificación para la fecha operativa definida.', dueOn: finding.nextControlOn, overdue: finding.nextControlOn < today, }; } private mendozaDate(): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Argentina/Mendoza', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); } private async requireAct(manager: EntityManager, id: string): Promise { const rows = await manager.query('SELECT 1 FROM inspection_acts WHERE id = $1', [id]) as unknown[]; if (rows.length === 0) throw actNotFound(); } private async lockAct(manager: EntityManager, id: string): Promise { const act = await manager.getRepository(InspectionAct) .createQueryBuilder('act') .where('act.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!act) throw actNotFound(); return act; } private async lockVisit(manager: EntityManager, id: string): Promise { const visit = await manager.getRepository(InspectionVisit) .createQueryBuilder('visit') .where('visit.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!visit) { throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Visita de inspección no encontrada', }); } return visit; } private async lockFinding(manager: EntityManager, id: string): Promise { const finding = await manager.getRepository(InspectionFinding) .createQueryBuilder('finding') .where('finding.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!finding) throw findingNotFound(); return finding; } private assertActEditable(act: InspectionAct, visit: InspectionVisit): void { if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE', message: 'Los hallazgos sólo se editan mientras el acta está en borrador y la visita en curso', }); } } private assertFindingOpen(finding: InspectionFinding): void { if (finding.status !== InspectionFindingStatus.OPEN) { throw new ConflictException({ code: 'INSPECTION_FINDING_NOT_OPEN', message: 'El hallazgo ya no está abierto', }); } } private async assertActorAssigned( manager: EntityManager, visitId: string, principal: AuthPrincipal, ): Promise { if (principal.permissions.includes('inspections.manage')) return; const rows = await manager.query(` SELECT 1 FROM inspection_visit_members WHERE visit_id = $1 AND user_id = $2 AND included = true `, [visitId, principal.userId]) as unknown[]; if (rows.length === 0) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'La visita no está asignada al usuario actual', }); } } private async assertActAsset(manager: EntityManager, actId: string, assetId: string): Promise { const rows = await manager.query(` SELECT 1 FROM inspection_act_assets WHERE act_id = $1 AND asset_id = $2 AND included = true `, [actId, assetId]) as unknown[]; if (rows.length === 0) { throw new BadRequestException({ code: 'INSPECTION_FINDING_ASSET_INVALID', message: 'El activo del hallazgo debe estar incluido en el acta', }); } } private async requireCatalogItem( manager: EntityManager, id: string, assetId: string, ): Promise { const [item] = await manager.query(` SELECT item.id, item.title, item.legal_basis AS "legalBasis", item.glossary, item.revision, item.suggested_severity AS "suggestedSeverity" FROM finding_catalog_items item INNER JOIN finding_categories category ON category.id = item.category_id INNER JOIN assets asset ON asset.id = $2 LEFT JOIN finding_catalog_asset_type_profiles profile ON profile.asset_type_id = asset.asset_type_id LEFT JOIN finding_catalog_item_asset_types mapping ON mapping.catalog_item_id = item.id AND mapping.asset_type_id = asset.asset_type_id LEFT JOIN finding_catalog_asset_overrides override ON override.catalog_item_id = item.id AND override.asset_id = asset.id WHERE item.id = $1 AND item.is_active = true AND category.is_active = true AND COALESCE( override.is_enabled, CASE WHEN profile.asset_type_id IS NULL THEN true ELSE mapping.id IS NOT NULL END ) = true `, [id, assetId]) as CatalogSnapshot[]; if (!item) { throw new BadRequestException({ code: 'FINDING_CATALOG_ITEM_NOT_APPLICABLE', message: 'El hallazgo seleccionado no está habilitado para este elemento del Inventario', }); } return item; } private async allocateFindingNumber(manager: EntityManager, actId: string): Promise { const [row] = 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 }>; return Number(row.number); } private async captureVersion( manager: EntityManager, finding: InspectionFinding, event: InspectionFindingVersionEvent, principal: AuthPrincipal, ): Promise { const [row] = await manager.query(` UPDATE inspection_findings SET current_version = current_version + 1 WHERE id = $1 RETURNING current_version AS "versionNumber" `, [finding.id]) as Array<{ versionNumber: number }>; const versionNumber = Number(row.versionNumber); finding.currentVersion = versionNumber; const snapshot = await this.buildSnapshot(manager, finding.id); await manager.query(` INSERT INTO inspection_finding_versions ( finding_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [finding.id, versionNumber, event, snapshot, principal.userId, principal.username]); return versionNumber; } private async buildSnapshot( manager: EntityManager, findingId: string, ): Promise> { const [row] = await manager.query(` SELECT JSONB_BUILD_OBJECT( 'id', finding.id, 'code', finding.code, 'findingNumber', finding.finding_number, 'status', finding.status, 'title', finding.title, 'description', finding.description, 'legalBasis', finding.legal_basis, 'glossary', finding.glossary, 'catalogRevision', finding.catalog_revision, 'suggestedSeverity', finding.suggested_severity, 'severity', finding.severity, 'correctionDueOn', finding.correction_due_on, 'responseDueBasis', finding.response_due_basis, 'responseDueDays', finding.response_due_days, 'responseDueBaseOn', finding.response_due_base_on, 'reportNotifiedOn', finding.report_notified_on, 'companyResponse', finding.company_response, 'companyResponseReceivedOn', finding.company_response_received_on, 'companyCommittedCorrectionOn', finding.company_committed_correction_on, 'nextControlOn', finding.next_control_on, 'currentVersion', finding.current_version, 'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status), 'visit', JSONB_BUILD_OBJECT('id', visit.id, 'code', visit.code, 'status', visit.status), 'asset', JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'typeId', asset.asset_type_id, 'currentVersion', asset.current_version ), 'catalog', CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', catalog.id, 'code', catalog.code, 'sourceNumber', catalog.source_number, 'revision', finding.catalog_revision, 'suggestedSeverity', finding.suggested_severity, 'categoryCode', category.code, 'categoryName', category.name ) END ) AS snapshot 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 INNER JOIN assets asset ON asset.id = finding.asset_id LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id LEFT JOIN finding_categories category ON category.id = catalog.category_id WHERE finding.id = $1 `, [findingId]) as Array<{ snapshot: Record }>; if (!row) throw findingNotFound(); return row.snapshot; } private auditView(finding: InspectionFindingListItem): Record { return { actId: finding.actId, visitId: finding.document.visitId, assetId: finding.assetId, catalogItemId: finding.catalogItemId, code: finding.code, status: finding.status, title: finding.title, description: finding.description, suggestedSeverity: finding.suggestedSeverity, severity: finding.severity, correctionDueOn: finding.correctionDueOn, responseDueBasis: finding.responseDueBasis, responseDueDays: finding.responseDueDays, responseDueBaseOn: finding.responseDueBaseOn, reportNotifiedOn: finding.reportNotifiedOn, companyResponse: finding.companyResponse, companyResponseReceivedOn: finding.companyResponseReceivedOn, companyCommittedCorrectionOn: finding.companyCommittedCorrectionOn, nextControlOn: finding.nextControlOn, currentVersion: finding.currentVersion, closedAt: finding.closedAt, closureNotes: finding.closureNotes, }; } }