import { BadRequestException, ConflictException, 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, InspectionFindingVersionEvent, InspectionFindingVerificationEventType, InspectionVerificationOutcome, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; import type { ListVerificationPlanningQueryDto } from './dto/list-verification-planning-query.dto'; import type { PlanVerificationVisitDto } from './dto/plan-verification-visit.dto'; import type { RecordVerificationResultDto } from './dto/record-verification-result.dto'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { nextInspectionVisitCode } from '../inspection-visits/inspection-visit-code'; import { appendVerificationEvent } from './verification-event-ledger'; interface VerificationContextView { id: string; code: string; name: string; } interface VerificationVisitView { id: string; code: string; status: InspectionVisitStatus; plannedStartAt: Date | null; } export interface VerificationPlanningItem { id: string; code: string; title: string; status: 'OPEN'; nextControlOn: string; asset: VerificationContextView & { typeName: string }; company: VerificationContextView | null; area: VerificationContextView | null; act: { id: string; code: string }; sourceVisit: { id: string; code: string }; verificationVisit: VerificationVisitView | null; } export interface VerificationPlanningCounters { eligible: number; unplanned: number; overdueUnplanned: number; dueNext30Days: number; planned: number; } export interface VerificationPlanningPage { data: VerificationPlanningItem[]; meta: { page: number; pageSize: number; total: number; totalPages: number }; counters: VerificationPlanningCounters; } interface LockedFindingRow { id: string; code: string; title: string; assetId: string; assetCode: string; assetName: string; companyId: string | null; companyCode: string | null; companyName: string | null; areaId: string | null; areaCode: string | null; areaName: string | null; nextControlOn: string | null; status: string; } @Injectable() export class InspectionVerificationsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async listQueue(query: ListVerificationPlanningQueryDto): Promise { const page = query.page ?? 1; const pageSize = query.pageSize ?? 50; const values: unknown[] = []; const baseFilters = [ `finding.status = 'OPEN'`, `finding.next_control_on IS NOT NULL`, `COALESCE(( 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 ), '') <> 'RESOLVED'`, ]; const add = (value: unknown): string => { values.push(value); return `$${values.length}`; }; if (query.search) { const parameter = add(`%${query.search}%`); baseFilters.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) baseFilters.push(`company.id = ${add(query.companyId)}`); if (query.areaId) baseFilters.push(`area.id = ${add(query.areaId)}`); if (query.dueFrom) baseFilters.push(`finding.next_control_on >= ${add(query.dueFrom)}::date`); if (query.dueTo) baseFilters.push(`finding.next_control_on <= ${add(query.dueTo)}::date`); const baseWhere = `WHERE ${baseFilters.join(' AND ')}`; const activeVisitJoin = ` LEFT JOIN LATERAL ( SELECT visit.id, visit.code, visit.status, visit.planned_start_at AS "plannedStartAt" FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.finding_id = finding.id AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') ORDER BY verification_link.created_at DESC, verification_link.id DESC LIMIT 1 ) verification_visit ON true `; const joins = ` INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits source_visit ON source_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 ${activeVisitJoin} `; const planningFilters: string[] = []; if (query.planningStatus === 'UNPLANNED') planningFilters.push('verification_visit.id IS NULL'); if (query.planningStatus === 'PLANNED') planningFilters.push('verification_visit.id IS NOT NULL'); const listWhere = planningFilters.length ? `${baseWhere} AND ${planningFilters.join(' AND ')}` : baseWhere; const countRows = await this.dataSource.query(` SELECT COUNT(*)::integer AS total FROM inspection_findings finding ${joins} ${listWhere} `, values) as Array<{ total: number }>; const total = Number(countRows[0]?.total ?? 0); const listValues = [...values]; listValues.push(pageSize); const limitParameter = `$${listValues.length}`; listValues.push((page - 1) * pageSize); const offsetParameter = `$${listValues.length}`; const data = await this.dataSource.query(` SELECT finding.id, finding.code, finding.title, finding.status, finding.next_control_on AS "nextControlOn", jsonb_build_object( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'typeName', asset_type.name ) AS asset, CASE WHEN company.id IS NULL THEN NULL ELSE jsonb_build_object( 'id', company.id, 'code', company.code, 'name', company.name ) END AS company, CASE WHEN area.id IS NULL THEN NULL ELSE jsonb_build_object( 'id', area.id, 'code', area.code, 'name', area.name ) END AS area, jsonb_build_object('id', act.id, 'code', act.code) AS act, jsonb_build_object( 'id', source_visit.id, 'code', source_visit.code ) AS "sourceVisit", 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."plannedStartAt" ) END AS "verificationVisit" FROM inspection_findings finding ${joins} ${listWhere} ORDER BY CASE WHEN finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date THEN 0 ELSE 1 END, finding.next_control_on, company.name NULLS LAST, area.name NULLS LAST, finding.code LIMIT ${limitParameter} OFFSET ${offsetParameter} `, listValues) as VerificationPlanningItem[]; const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`; const counterRows = await this.dataSource.query(` SELECT COUNT(*)::integer AS eligible, COUNT(*) FILTER (WHERE verification_visit.id IS NULL)::integer AS unplanned, COUNT(*) FILTER ( WHERE verification_visit.id IS NULL AND finding.next_control_on < ${today} )::integer AS "overdueUnplanned", COUNT(*) FILTER ( WHERE verification_visit.id IS NULL AND finding.next_control_on BETWEEN ${today} AND ${today} + 30 )::integer AS "dueNext30Days", COUNT(*) FILTER (WHERE verification_visit.id IS NOT NULL)::integer AS planned FROM inspection_findings finding ${joins} ${baseWhere} `, values) as VerificationPlanningCounters[]; const counters = counterRows[0]; return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize), }, counters: { eligible: Number(counters?.eligible ?? 0), unplanned: Number(counters?.unplanned ?? 0), overdueUnplanned: Number(counters?.overdueUnplanned ?? 0), dueNext30Days: Number(counters?.dueNext30Days ?? 0), planned: Number(counters?.planned ?? 0), }, }; } async recordResult( visitId: string, findingId: string, dto: RecordVerificationResultDto, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); const notes = dto.notes.trim(); const verifiedAt = dto.verifiedAt ? new Date(dto.verifiedAt) : new Date(); if (!Number.isFinite(verifiedAt.getTime())) { throw new BadRequestException({ code: 'VERIFICATION_RESULT_DATE_INVALID', message: 'La fecha de verificación no es válida', }); } if (dto.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE && !dto.nextControlOn) { throw new BadRequestException({ code: 'VERIFICATION_NEW_DATE_REQUIRED', message: 'Indicá la nueva fecha de verificación', }); } if (dto.outcome !== InspectionVerificationOutcome.REQUIRES_NEW_DATE && dto.nextControlOn) { throw new BadRequestException({ code: 'VERIFICATION_NEW_DATE_NOT_ALLOWED', message: 'La nueva fecha sólo corresponde cuando el hallazgo requiere un nuevo control', }); } return this.dataSource.transaction(async (manager) => { const [context] = await manager.query(` SELECT verification_link.id AS "linkId", verification_link.target_control_on AS "targetControlOn", verification_link.outcome, verification_link.result_notes AS "resultNotes", verification_link.verified_at AS "verifiedAt", verification_link.result_recorded_at AS "resultRecordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn", finding.id AS "findingId", finding.code AS "findingCode", finding.status AS "findingStatus", finding.next_control_on AS "findingNextControlOn", finding.act_id AS "sourceActId", visit.id AS "visitId", visit.code AS "visitCode", visit.status AS "visitStatus" FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.visit_id = $1 AND verification_link.finding_id = $2 FOR UPDATE OF verification_link, finding, visit `, [visitId, findingId]) as Array<{ linkId: string; targetControlOn: string | null; outcome: InspectionVerificationOutcome | null; resultNotes: string | null; verifiedAt: Date | null; resultRecordedAt: Date | null; rescheduledControlOn: string | null; findingId: string; findingCode: string; findingStatus: string; findingNextControlOn: string | null; sourceActId: string; visitId: string; visitCode: string; visitStatus: InspectionVisitStatus; }>; if (!context) { throw new NotFoundException({ code: 'VERIFICATION_LINK_NOT_FOUND', message: 'El hallazgo no pertenece a esta visita de verificación', }); } if (context.outcome !== null || context.resultRecordedAt !== null) { throw new ConflictException({ code: 'VERIFICATION_RESULT_IMMUTABLE', message: 'El resultado de esta verificación ya fue registrado y no puede reemplazarse', }); } if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'VERIFICATION_VISIT_NOT_IN_PROGRESS', message: 'El resultado sólo puede registrarse durante una visita de verificación en curso', }); } if (context.findingStatus !== 'OPEN') { throw new ConflictException({ code: 'VERIFICATION_FINDING_CLOSED', message: `${context.findingCode} ya no está abierto`, }); } await this.assertActorAssigned(manager, visitId, principal); let nextControlOn: string | null = context.findingNextControlOn; let rescheduledControlOn: string | null = null; if (dto.outcome === InspectionVerificationOutcome.RESOLVED) { nextControlOn = null; } if (dto.outcome === InspectionVerificationOutcome.NOT_RESOLVED) { nextControlOn = null; } if (dto.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE) { rescheduledControlOn = dto.nextControlOn ?? null; nextControlOn = rescheduledControlOn; const verifiedDate = verifiedAt.toISOString().slice(0, 10); if (!rescheduledControlOn || rescheduledControlOn <= verifiedDate) { throw new BadRequestException({ code: 'VERIFICATION_NEW_DATE_NOT_FUTURE', message: 'La nueva fecha debe ser posterior a la verificación realizada', }); } } const before = { outcome: context.outcome, resultNotes: context.resultNotes, verifiedAt: context.verifiedAt, resultRecordedAt: context.resultRecordedAt, rescheduledControlOn: context.rescheduledControlOn, findingNextControlOn: context.findingNextControlOn, }; const recordedAt = new Date(); await manager.query(` UPDATE inspection_finding_verification_visits SET outcome = $3, result_notes = $4, verified_at = $5, result_recorded_at = $6, result_recorded_by = $7, rescheduled_control_on = $8, updated_at = CURRENT_TIMESTAMP WHERE visit_id = $1 AND finding_id = $2 `, [ visitId, findingId, dto.outcome, notes, verifiedAt, recordedAt, principal.userId, rescheduledControlOn, ]); await manager.query(` UPDATE inspection_findings SET next_control_on = $2, updated_by = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [findingId, nextControlOn, principal.userId]); await appendVerificationEvent(manager, { findingId, verificationVisitId: visitId, eventType: InspectionFindingVerificationEventType.RESULT_RECORDED, occurredAt: verifiedAt, targetControlOn: context.targetControlOn, previousControlOn: context.findingNextControlOn, nextControlOn, outcome: dto.outcome, notes, }, principal); await appendVerificationEvent(manager, { findingId, verificationVisitId: visitId, eventType: rescheduledControlOn ? InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED : InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED, occurredAt: verifiedAt, targetControlOn: rescheduledControlOn, previousControlOn: context.findingNextControlOn, nextControlOn, notes: rescheduledControlOn ? 'Nueva fecha definida como resultado de la verificación' : 'La verificación consumió la fecha prevista sin fijar un nuevo control', }, principal); const versionNumber = await this.captureFindingVerificationVersion( manager, findingId, visitId, dto.outcome, notes, verifiedAt, rescheduledControlOn, principal, ); const [result] = await manager.query(` SELECT verification_link.finding_id AS "findingId", verification_link.visit_id AS "visitId", verification_link.target_control_on AS "targetControlOn", verification_link.outcome, verification_link.result_notes AS "notes", verification_link.verified_at AS "verifiedAt", verification_link.result_recorded_at AS "recordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn", finding.next_control_on AS "nextControlOn", finding.status AS "findingStatus", visit.status AS "visitStatus" FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.visit_id = $1 AND verification_link.finding_id = $2 `, [visitId, findingId]) as Array>; await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VERIFICATION_RESULT_RECORDED, entityType: 'inspection_finding_verification_visit', entityId: context.linkId, beforeData: before, afterData: result ?? null, metadata: { findingId, sourceActId: context.sourceActId, visitId, visitCode: context.visitCode, versionNumber, }, }, manager); return result; }); } async planVisit( dto: PlanVerificationVisitDto, principal: AuthPrincipal, request: RequestWithContext, ) { const findingIds = [...new Set(dto.findingIds)]; const plannedStartAt = new Date(dto.plannedStartAt); if (!Number.isFinite(plannedStartAt.getTime())) { throw new BadRequestException({ code: 'VERIFICATION_START_INVALID', message: 'La fecha prevista de inicio no es válida' }); } return this.dataSource.transaction(async (manager) => { const rows = await manager.query(` SELECT finding.id, finding.code, finding.title, finding.status, finding.asset_id AS "assetId", finding.next_control_on AS "nextControlOn", asset.code AS "assetCode", asset.name AS "assetName", company.id AS "companyId", company.code AS "companyCode", company.name AS "companyName", area.id AS "areaId", area.code AS "areaCode", area.name AS "areaName" FROM inspection_findings finding 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 finding.id = ANY($1::uuid[]) FOR UPDATE OF finding `, [findingIds]) as LockedFindingRow[]; if (rows.length !== findingIds.length) { throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' }); } const invalid = rows.find((row) => row.status !== 'OPEN' || !row.nextControlOn); if (invalid) { throw new ConflictException({ code: 'VERIFICATION_FINDING_NOT_READY', message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`, }); } const missingContext = rows.find((row) => !row.companyId || !row.areaId); if (missingContext) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`, }); } const companyId = rows[0]?.companyId; const areaId = rows[0]?.areaId; if (!companyId || !areaId) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' }); } if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_MIXED', message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación', }); } const activeLinks = await manager.query(` SELECT verification_link.finding_id AS "findingId", visit.code FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE verification_link.finding_id = ANY($1::uuid[]) AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS') `, [findingIds]) as Array<{ findingId: string; code: string }>; if (activeLinks.length > 0) { throw new ConflictException({ code: 'VERIFICATION_ALREADY_PLANNED', message: `Al menos un hallazgo ya está asociado a una visita activa (${activeLinks[0]?.code ?? 'existente'})`, }); } const companyName = rows[0]?.companyName ?? 'Empresa'; const areaName = rows[0]?.areaName ?? 'Área'; const code = await nextInspectionVisitCode(manager, plannedStartAt); const findingCodes = rows.map((row) => row.code).join(', '); const notes = dto.notes?.trim() || null; const instructions = [ `Verificar ${rows.length} hallazgo${rows.length === 1 ? '' : 's'}: ${findingCodes}.`, notes, ].filter(Boolean).join('\n\n'); const visit = manager.getRepository(InspectionVisit).create({ code, // Compatibilidad temporal de persistencia hasta retirar la columna legacy. title: code, objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.', status: InspectionVisitStatus.DRAFT, scopeAssetId: areaId, operationalAreaId: areaId, operatorCompanyId: companyId, leadInspectorUserId: null, plannedStartAt, actualStartedAt: null, actualClosedAt: null, instructions, cancellationReason: null, checklistGeneration: 0, checklistGeneratedAt: null, createdBy: principal.userId, updatedBy: principal.userId, }); await manager.getRepository(InspectionVisit).save(visit); const assetIds = [...new Set(rows.map((row) => row.assetId))]; for (const assetId of assetIds) { await manager.query(` INSERT INTO inspection_visit_assets ( visit_id, asset_id, included, planning_source, exclusion_reason, excluded_by, excluded_at, added_by ) VALUES ($1, $2, true, 'VERIFICATION', NULL, NULL, NULL, $3) ON CONFLICT (visit_id, asset_id) DO UPDATE SET included = true, planning_source = 'VERIFICATION', exclusion_reason = NULL, excluded_by = NULL, excluded_at = NULL, added_by = EXCLUDED.added_by, updated_at = CURRENT_TIMESTAMP `, [visit.id, assetId, principal.userId]); await manager.query(` INSERT INTO inspection_visit_asset_events ( visit_id, asset_id, event_type, actor_user_id, metadata ) VALUES ($1, $2, 'VERIFICATION_INCLUDED', $3, $4::jsonb) `, [visit.id, assetId, principal.userId, JSON.stringify({ findingIds })]); } for (const row of rows) { await manager.query(` INSERT INTO inspection_finding_verification_visits ( finding_id, visit_id, linked_by, target_control_on ) VALUES ($1, $2, $3, $4) ON CONFLICT (finding_id, visit_id) DO NOTHING `, [row.id, visit.id, principal.userId, row.nextControlOn]); await appendVerificationEvent(manager, { findingId: row.id, verificationVisitId: visit.id, eventType: InspectionFindingVerificationEventType.VISIT_PLANNED, occurredAt: new Date(), targetControlOn: row.nextControlOn, previousControlOn: row.nextControlOn, nextControlOn: row.nextControlOn, notes: `Visita ${visit.code} planificada para verificar ${row.code}`, }, principal); } await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VERIFICATION_PLANNED, entityType: 'inspection_visit', entityId: visit.id, afterData: { code: visit.code, status: visit.status, plannedStartAt: visit.plannedStartAt, companyId, areaId, findingIds, assetIds, }, }, manager); return { visit: { id: visit.id, code: visit.code, status: visit.status, plannedStartAt: visit.plannedStartAt, }, company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, findingCount: findingIds.length, assetCount: assetIds.length, }; }); } private async assertActorAssigned( manager: EntityManager, visitId: string, principal: AuthPrincipal, ): Promise { 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 ConflictException({ code: 'VERIFICATION_INSPECTOR_NOT_ASSIGNED', message: 'La visita de verificación no está asignada al inspector actual', }); } } private async captureFindingVerificationVersion( manager: EntityManager, findingId: string, verificationVisitId: string, outcome: InspectionVerificationOutcome, notes: string, verifiedAt: Date, rescheduledControlOn: string | null, principal: AuthPrincipal, ): Promise { const [updated] = await manager.query(` UPDATE inspection_findings SET current_version = current_version + 1 WHERE id = $1 RETURNING current_version AS "versionNumber" `, [findingId]) as Array<{ versionNumber: number }>; const versionNumber = Number(updated?.versionNumber ?? 0); const [snapshot] = 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, 'correctionDueOn', finding.correction_due_on, 'nextControlOn', finding.next_control_on, 'currentVersion', finding.current_version, 'verification', JSONB_BUILD_OBJECT( 'visitId', verification_visit.id, 'visitCode', verification_visit.code, 'outcome', $3::text, 'notes', $4::text, 'verifiedAt', $5::timestamptz, 'rescheduledControlOn', $6::date ), 'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status), 'sourceVisit', JSONB_BUILD_OBJECT('id', source_visit.id, 'code', source_visit.code, 'status', source_visit.status), 'asset', JSONB_BUILD_OBJECT('id', asset.id, 'code', asset.code, 'name', asset.name) ) AS snapshot FROM inspection_findings finding INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN inspection_visits verification_visit ON verification_visit.id = $2 WHERE finding.id = $1 `, [findingId, verificationVisitId, outcome, notes, verifiedAt, rescheduledControlOn]) as Array<{ snapshot: Record; }>; 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) `, [ findingId, versionNumber, InspectionFindingVersionEvent.VERIFICATION_RECORDED, snapshot?.snapshot ?? {}, principal.userId, principal.username, ]); return versionNumber; } }