import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext, isUniqueViolation, } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction, InspectionVisit, InspectionVisitAssetPlanningSource, InspectionVisitStatus, } from '../database/entities'; import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { nextInspectionVisitCode } from './inspection-visit-code'; export interface InspectionPerson { id: string; username: string; firstName: string; lastName: string; } export interface InspectionAssetSummary { id: string; code: string; name: string; typeName: string; } export interface InspectionPlanningContextAsset { id: string; code: string; name: string; } export interface InspectionPlannedAsset extends InspectionAssetSummary { included: boolean; planningSource: InspectionVisitAssetPlanningSource; exclusionReason: string | null; excludedAt: Date | null; excludedBy: InspectionPerson | null; } export type InspectionChecklistItemKind = | 'ANTECEDENT' | 'COMPANY_OVERDUE' | 'VERIFICATION_OVERDUE' | 'UPCOMING_CONTROL'; export interface InspectionVisitChecklistItem { id: string; findingId: string; findingCode: string; findingTitle: string; findingStatus: string; severity: number | null; itemKind: InspectionChecklistItemKind; referenceOn: string | null; asset: InspectionAssetSummary; assetIncluded: boolean; } export interface InspectionVisitChecklistSummary { generation: number; generatedAt: Date | null; stale: boolean; antecedents: number; companyOverdue: number; verificationOverdue: number; upcomingControls: number; actionableAssets: number; excludedAssets: number; items: InspectionVisitChecklistItem[]; } export interface InspectionVisitListItem { id: string; code: string; title: string; objective: string | null; status: InspectionVisitStatus; scopeAsset: InspectionAssetSummary | null; operationalArea: InspectionPlanningContextAsset | null; operatorCompany: InspectionPlanningContextAsset | null; leadInspector: InspectionPerson | null; plannedStartAt: Date | null; plannedEndAt: Date | null; actualStartedAt: Date | null; actualClosedAt: Date | null; instructions: string | null; cancellationReason: string | null; checklistGeneration: number; checklistGeneratedAt: Date | null; assetCount: number; memberCount: number; createdAt: Date; updatedAt: Date; } export interface InspectionVerificationFindingSummary { id: string; code: string; title: string; status: string; nextControlOn: string | null; assetId: string; assetCode: string; assetName: string; targetControlOn: string | null; outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null; resultNotes: string | null; verifiedAt: Date | null; resultRecordedAt: Date | null; rescheduledControlOn: string | null; verificationEvidenceCount: number; } export interface InspectionVisitView extends InspectionVisitListItem { assets: InspectionAssetSummary[]; planningAssets: InspectionPlannedAsset[]; team: InspectionPerson[]; verificationFindings: InspectionVerificationFindingSummary[]; checklist: InspectionVisitChecklistSummary; } function visitNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Visita de inspección no encontrada', }); } function dateValue(value: string | null | undefined): Date | null { return value ? new Date(value) : null; } @Injectable() export class InspectionVisitsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async list(query: ListInspectionVisitsQueryDto) { const conditions: string[] = []; const parameters: unknown[] = []; const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); conditions.push(`(visit.code ILIKE ${search} OR visit.title ILIKE ${search})`); } if (query.status) conditions.push(`visit.status = ${add(query.status)}`); if (query.companyId) { const company = add(query.companyId); conditions.push(`( visit.operator_company_id = ${company}::uuid OR visit.scope_asset_id = ${company}::uuid OR EXISTS ( SELECT 1 FROM inspection_visit_assets company_link INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id WHERE company_link.visit_id = visit.id AND company_link.included = true AND company_asset.operator_company_id = ${company}::uuid ) )`); } if (query.areaId) { const area = add(query.areaId); conditions.push(`( visit.operational_area_id = ${area}::uuid OR visit.scope_asset_id = ${area}::uuid OR EXISTS ( SELECT 1 FROM inspection_visit_assets area_link INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id WHERE area_link.visit_id = visit.id AND area_link.included = true AND area_asset.operational_area_id = ${area}::uuid ) )`); } if (query.inspectorId) { const inspector = add(query.inspectorId); conditions.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) { conditions.push(`COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at)::date >= ${add(query.dateFrom)}::date`); } if (query.dateTo) { conditions.push(`COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at)::date <= ${add(query.dateTo)}::date`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const [countRow] = (await this.dataSource.query( `SELECT COUNT(*)::integer AS total FROM inspection_visits visit ${where}`, parameters, )) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const limit = add(query.pageSize); const offset = add((query.page - 1) * query.pageSize); const data = (await this.dataSource.query( `${this.visitSelect(where)} ORDER BY CASE visit.status WHEN 'IN_PROGRESS' THEN 1 WHEN 'PLANNED' THEN 2 WHEN 'DRAFT' THEN 3 WHEN 'CLOSED' THEN 4 ELSE 5 END, visit.planned_start_at NULLS LAST, visit.updated_at DESC LIMIT ${limit} OFFSET ${offset}`, parameters, )) as InspectionVisitListItem[]; return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } async listAssignees(): Promise<{ data: InspectionPerson[] }> { const data = (await this.dataSource.query(` SELECT DISTINCT user_account.id, user_account.username, user_account.first_name AS "firstName", user_account.last_name AS "lastName" FROM users user_account INNER JOIN user_roles user_role ON user_role.user_id = user_account.id INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id INNER JOIN permissions permission ON permission.id = role_permission.permission_id WHERE user_account.status = 'ACTIVE' AND permission.code = 'inspections.execute' ORDER BY user_account.last_name, user_account.first_name, user_account.username `)) as InspectionPerson[]; return { data }; } async listPlanningAreas(): Promise<{ data: InspectionPlanningContextAsset[] }> { const data = (await this.dataSource.query(` SELECT DISTINCT area.id, area.code, area.name FROM assets area INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN area_company_relations relation ON relation.area_id = area.id WHERE area_type.operational_role = 'AREA' AND area.information_status <> 'INACTIVE' AND relation.relation_role = 'OPERATOR' AND relation.valid_from <= CURRENT_TIMESTAMP AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) ORDER BY area.name, area.code `)) as InspectionPlanningContextAsset[]; return { data }; } async listPlanningOperators(areaId: string): Promise<{ data: InspectionPlanningContextAsset[] }> { const data = (await this.dataSource.query(` SELECT DISTINCT company.id, company.code, company.name FROM area_company_relations relation INNER JOIN assets company ON company.id = relation.company_id INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id WHERE relation.area_id = $1 AND relation.relation_role = 'OPERATOR' AND relation.valid_from <= CURRENT_TIMESTAMP AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) AND company_type.operational_role = 'COMPANY' AND company.information_status <> 'INACTIVE' ORDER BY company.name, company.code `, [areaId])) as InspectionPlanningContextAsset[]; return { data }; } async getById(id: string): Promise { return this.dataSource.transaction((manager) => this.loadView(manager, id)); } async create( dto: CreateInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { try { return await this.dataSource.transaction(async (manager) => { const plannedStartAt = dateValue(dto.plannedStartAt); if (!plannedStartAt || !Number.isFinite(plannedStartAt.getTime())) { throw new BadRequestException({ code: 'INSPECTION_START_DATE_REQUIRED', message: 'Indicá una fecha y hora prevista de inicio válida', }); } const operationalAreaId = dto.operationalAreaId; const operatorCompanyId = dto.operatorCompanyId; await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId); await this.requireAsset(manager, operationalAreaId); await this.requireInspectors(manager, [dto.leadInspectorUserId]); const code = await nextInspectionVisitCode(manager, plannedStartAt); const visit = manager.getRepository(InspectionVisit).create({ code, title: code, objective: null, status: InspectionVisitStatus.DRAFT, scopeAssetId: operationalAreaId, operationalAreaId, operatorCompanyId, leadInspectorUserId: dto.leadInspectorUserId, plannedStartAt, plannedEndAt: null, actualStartedAt: null, actualClosedAt: null, instructions: null, cancellationReason: null, checklistGeneration: 0, checklistGeneratedAt: null, createdBy: principal.userId, updatedBy: principal.userId, }); await manager.getRepository(InspectionVisit).save(visit); await this.replaceMemberLinks( manager, visit.id, [dto.leadInspectorUserId], principal.userId, ); await this.generateChecklistInternal(manager, visit, principal.userId); const created = await this.loadView(manager, visit.id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_CREATED, entityType: 'inspection_visit', entityId: visit.id, afterData: this.auditView(created), }, manager); return created; }); } catch (error) { if (isUniqueViolation(error)) throw this.codeConflict(); throw error; } } async update( id: string, dto: UpdateInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } try { return await this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); this.assertPlanningEditable(visit); const before = await this.loadView(manager, id); const nextStart = dto.plannedStartAt === undefined ? visit.plannedStartAt : dateValue(dto.plannedStartAt); const nextAreaId = dto.operationalAreaId === undefined ? visit.operationalAreaId : dto.operationalAreaId; const nextCompanyId = dto.operatorCompanyId === undefined ? visit.operatorCompanyId : dto.operatorCompanyId; const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined; const nextScope = contextTouched ? nextAreaId : dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId; await this.validatePlanningContext(manager, nextAreaId, nextCompanyId); await this.requireAsset(manager, nextScope); if (contextTouched && nextAreaId && nextCompanyId) { await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId); } else if (dto.scopeAssetId !== undefined) { await this.assertCurrentAssetsInScope(manager, id, nextScope); } if (dto.objective !== undefined) visit.objective = dto.objective; if (contextTouched) { visit.operationalAreaId = nextAreaId; visit.operatorCompanyId = nextCompanyId; visit.scopeAssetId = nextAreaId; } else if (dto.scopeAssetId !== undefined) { visit.scopeAssetId = dto.scopeAssetId; } if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart; if (dto.instructions !== undefined) visit.instructions = dto.instructions; if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_UPDATED, entityType: 'inspection_visit', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), }, manager); return updated; }); } catch (error) { if (isUniqueViolation(error)) throw this.codeConflict(); throw error; } } async replaceAssets( id: string, dto: ReplaceInspectionVisitAssetsDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); this.assertPlanningEditable(visit); await this.requireAssets(manager, dto.assetIds); const before = await this.activeAssetIds(manager, id); const removed = before.filter((assetId) => !dto.assetIds.includes(assetId)); if (removed.length > 0) { throw new BadRequestException({ code: 'INSPECTION_EXCLUSION_REASON_REQUIRED', message: 'Para retirar un registro del checklist usá la exclusión con motivo auditable', }); } const additions = dto.assetIds.filter((assetId) => !before.includes(assetId)); if (additions.length > 0) { const excludedExisting = (await manager.query(` SELECT asset_id AS id FROM inspection_visit_assets WHERE visit_id = $1 AND asset_id = ANY($2::uuid[]) AND included = false `, [id, additions])) as Array<{ id: string }>; if (excludedExisting.length > 0) { throw new BadRequestException({ code: 'INSPECTION_REINCLUDE_REQUIRED', message: 'Un registro excluido debe reincorporarse con la acción específica para conservar su trazabilidad', }); } if (!visit.operationalAreaId || !visit.operatorCompanyId) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'Seleccioná Área y Operadora antes de agregar registros preventivos', }); } await this.assertAssetsMatchContext( manager, additions, visit.operationalAreaId, visit.operatorCompanyId, ); for (const assetId of additions) { 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, 'PREVENTIVE', NULL, NULL, NULL, $3) ON CONFLICT (visit_id, asset_id) DO UPDATE SET included = true, planning_source = CASE WHEN inspection_visit_assets.planning_source = 'VERIFICATION' THEN 'VERIFICATION' ELSE 'PREVENTIVE' END, exclusion_reason = NULL, excluded_by = NULL, excluded_at = NULL, added_by = EXCLUDED.added_by, updated_at = CURRENT_TIMESTAMP `, [id, assetId, principal.userId]); await this.appendAssetEvent( manager, id, assetId, 'PREVENTIVE_INCLUDED', null, principal.userId, { source: 'web_planning' }, ); } } visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_ASSETS_UPDATED, entityType: 'inspection_visit', entityId: id, beforeData: { assetIds: before }, afterData: { assetIds: await this.activeAssetIds(manager, id), preventiveAdded: additions }, }, manager); return this.loadView(manager, id); }); } async generateChecklist( id: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); this.assertPlanningEditable(visit); const beforeGeneration = visit.checklistGeneration; await this.generateChecklistInternal(manager, visit, principal.userId); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_CHECKLIST_GENERATED, entityType: 'inspection_visit', entityId: id, beforeData: { checklistGeneration: beforeGeneration }, afterData: { checklistGeneration: updated.checklist.generation, checklistGeneratedAt: updated.checklist.generatedAt, summary: { antecedents: updated.checklist.antecedents, companyOverdue: updated.checklist.companyOverdue, verificationOverdue: updated.checklist.verificationOverdue, upcomingControls: updated.checklist.upcomingControls, actionableAssets: updated.checklist.actionableAssets, }, }, }, manager); return updated; }); } async excludeAsset( id: string, assetId: string, reason: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); this.assertPlanningEditable(visit); const [link] = (await manager.query(` SELECT included, planning_source AS "planningSource" FROM inspection_visit_assets WHERE visit_id = $1 AND asset_id = $2 FOR UPDATE `, [id, assetId])) as Array<{ included: boolean; planningSource: InspectionVisitAssetPlanningSource }>; if (!link) { throw new NotFoundException({ code: 'INSPECTION_VISIT_ASSET_NOT_FOUND', message: 'El registro no forma parte del checklist de esta visita', }); } if (!link.included) return this.loadView(manager, id); await manager.query(` UPDATE inspection_visit_assets SET included = false, exclusion_reason = $3, excluded_by = $4, excluded_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE visit_id = $1 AND asset_id = $2 `, [id, assetId, reason.trim(), principal.userId]); await this.appendAssetEvent( manager, id, assetId, 'EXCLUDED', reason.trim(), principal.userId, { planningSource: link.planningSource }, ); visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_ASSET_EXCLUDED, entityType: 'inspection_visit', entityId: id, beforeData: { assetId, included: true }, afterData: { assetId, included: false, reason: reason.trim() }, }, manager); return this.loadView(manager, id); }); } async includeAsset( id: string, assetId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); this.assertPlanningEditable(visit); const [link] = (await manager.query(` SELECT included, planning_source AS "planningSource", exclusion_reason AS "exclusionReason" FROM inspection_visit_assets WHERE visit_id = $1 AND asset_id = $2 FOR UPDATE `, [id, assetId])) as Array<{ included: boolean; planningSource: InspectionVisitAssetPlanningSource; exclusionReason: string | null; }>; if (!link) { throw new NotFoundException({ code: 'INSPECTION_VISIT_ASSET_NOT_FOUND', message: 'El registro no forma parte del checklist de esta visita', }); } if (link.included) return this.loadView(manager, id); if (!visit.operationalAreaId || !visit.operatorCompanyId) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' }); } await this.assertAssetsMatchContext( manager, [assetId], visit.operationalAreaId, visit.operatorCompanyId, ); await manager.query(` UPDATE inspection_visit_assets SET included = true, exclusion_reason = NULL, excluded_by = NULL, excluded_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE visit_id = $1 AND asset_id = $2 `, [id, assetId]); await this.appendAssetEvent( manager, id, assetId, 'REINCLUDED', link.exclusionReason, principal.userId, { planningSource: link.planningSource }, ); visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_ASSET_REINCLUDED, entityType: 'inspection_visit', entityId: id, beforeData: { assetId, included: false, reason: link.exclusionReason }, afterData: { assetId, included: true }, }, manager); return this.loadView(manager, id); }); } async replaceTeam( id: string, dto: ReplaceInspectionVisitTeamDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); this.assertPlanningEditable(visit); const leadInspectorUserId = dto.leadInspectorUserId ?? null; await this.requireInspectors(manager, dto.memberUserIds); if (leadInspectorUserId && !dto.memberUserIds.includes(leadInspectorUserId)) { throw new BadRequestException({ code: 'INSPECTION_LEAD_NOT_IN_TEAM', message: 'El inspector responsable debe formar parte del equipo', }); } const before = { leadInspectorUserId: visit.leadInspectorUserId, memberUserIds: await this.activeMemberIds(manager, id), }; visit.leadInspectorUserId = leadInspectorUserId; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); await this.replaceMemberLinks(manager, id, dto.memberUserIds, principal.userId); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_TEAM_UPDATED, entityType: 'inspection_visit', entityId: id, beforeData: before, afterData: { leadInspectorUserId, memberUserIds: dto.memberUserIds, }, }, manager); return this.loadView(manager, id); }); } async changeStatus( id: string, dto: ChangeInspectionVisitStatusDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (visit.status === dto.status) return this.loadView(manager, id); const cancellation = dto.status === InspectionVisitStatus.CANCELLED; const reversiblePlanning = (visit.status === InspectionVisitStatus.DRAFT && dto.status === InspectionVisitStatus.PLANNED) || (visit.status === InspectionVisitStatus.PLANNED && dto.status === InspectionVisitStatus.DRAFT); const cancellable = cancellation && [ InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED, InspectionVisitStatus.IN_PROGRESS, ].includes(visit.status); if (!reversiblePlanning && !cancellable) { throw new ConflictException({ code: 'INSPECTION_VISIT_INVALID_TRANSITION', message: 'La transición solicitada no está permitida en esta fase', }); } if (dto.status === InspectionVisitStatus.PLANNED) { await this.validatePlan(manager, visit); } const reason = dto.reason?.trim() || null; if (cancellation && (!reason || reason.length < 10)) { throw new BadRequestException({ code: 'INSPECTION_CANCELLATION_REASON_REQUIRED', message: 'Indicá un motivo de cancelación de al menos 10 caracteres', }); } const beforeStatus = visit.status; visit.status = dto.status; visit.cancellationReason = cancellation ? reason : null; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, entityType: 'inspection_visit', entityId: id, beforeData: { status: beforeStatus }, afterData: { status: dto.status, cancellationReason: visit.cancellationReason }, }, manager); return updated; }); } async start( id: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (visit.status !== InspectionVisitStatus.PLANNED) { throw new ConflictException({ code: 'INSPECTION_VISIT_NOT_PLANNED', message: 'La visita debe estar planificada antes de iniciarse', }); } await this.validatePlan(manager, visit); if (!principal.permissions.includes('inspections.manage')) { const memberIds = await this.activeMemberIds(manager, id); if (!memberIds.includes(principal.userId)) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'La visita no está asignada al usuario actual', }); } } visit.status = InspectionVisitStatus.IN_PROGRESS; visit.actualStartedAt = new Date(); visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_STARTED, entityType: 'inspection_visit', entityId: id, beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null }, afterData: { status: InspectionVisitStatus.IN_PROGRESS, actualStartedAt: visit.actualStartedAt, }, }, manager); return updated; }); } async close( id: string, dto: CloseInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', message: 'La inspección debe estar en curso para cerrarse', }); } if (!principal.permissions.includes('inspections.manage')) { const memberIds = await this.activeMemberIds(manager, id); if (!memberIds.includes(principal.userId)) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'La inspección no está asignada al usuario actual', }); } } const [acts] = (await manager.query(` SELECT COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts, COUNT(*) FILTER ( WHERE act.status = 'READY' AND NOT EXISTS ( SELECT 1 FROM inspection_act_signatures signature WHERE signature.act_id = act.id AND signature.signer_type = 'INSPECTOR' AND signature.status = 'SIGNED' ) )::integer AS "readyWithoutInspector", COUNT(*) FILTER ( WHERE act.status = 'READY' AND NOT EXISTS ( SELECT 1 FROM inspection_act_signatures signature WHERE signature.act_id = act.id AND signature.signer_type = 'COMPANY_RESPONSIBLE' ) )::integer AS "pendingCompany" FROM inspection_acts act WHERE act.visit_id = $1 AND act.status <> 'CANCELLED' `, [id])) as Array<{ drafts: number; readyWithoutInspector: number; pendingCompany: number }>; if (Number(acts?.drafts ?? 0) > 0) { throw new ConflictException({ code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING', message: 'Prepará o cancelá las actas en borrador antes de cerrar la inspección', }); } if (Number(acts?.readyWithoutInspector ?? 0) > 0) { throw new ConflictException({ code: 'INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING', message: 'Toda acta preparada debe tener firma de inspector antes de cerrar la inspección', }); } const serverClosedAt = new Date(); const clientClosedAt = new Date(dto.clientClosedAt); if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) { throw new BadRequestException({ code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME', message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la inspección', }); } if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME', message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', }); } visit.status = InspectionVisitStatus.CLOSED; visit.actualClosedAt = serverClosedAt; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, entityType: 'inspection_visit', entityId: id, beforeData: { status: InspectionVisitStatus.IN_PROGRESS }, afterData: { status: InspectionVisitStatus.CLOSED, clientClosedAt: clientClosedAt.toISOString(), serverClosedAt: serverClosedAt.toISOString(), pendingCompanySignatures: Number(acts?.pendingCompany ?? 0), }, metadata: { closeSource: 'ANDROID', actsMayCompleteCompanySignatureLater: true, }, }, manager); return updated; }); } private visitSelect(where: string): string { return ` SELECT visit.id, visit.code, visit.title, visit.objective, visit.status, CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', scope.id, 'code', scope.code, 'name', scope.name, 'typeName', scope_type.name ) END AS "scopeAsset", CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name ) END AS "operationalArea", CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name ) END AS "operatorCompany", CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', lead.id, 'username', lead.username, 'firstName', lead.first_name, 'lastName', lead.last_name ) END AS "leadInspector", visit.planned_start_at AS "plannedStartAt", visit.planned_end_at AS "plannedEndAt", visit.actual_started_at AS "actualStartedAt", visit.actual_closed_at AS "actualClosedAt", visit.instructions, visit.cancellation_reason AS "cancellationReason", visit.checklist_generation AS "checklistGeneration", visit.checklist_generated_at AS "checklistGeneratedAt", COALESCE(link_counts.assets, 0)::integer AS "assetCount", COALESCE(link_counts.members, 0)::integer AS "memberCount", visit.created_at AS "createdAt", visit.updated_at AS "updatedAt" FROM inspection_visits visit LEFT JOIN assets scope ON scope.id = visit.scope_asset_id LEFT JOIN asset_types scope_type ON scope_type.id = scope.asset_type_id LEFT JOIN assets area ON area.id = visit.operational_area_id LEFT JOIN assets company ON company.id = visit.operator_company_id LEFT JOIN users lead ON lead.id = visit.lead_inspector_user_id LEFT JOIN LATERAL ( SELECT (SELECT COUNT(*) FROM inspection_visit_assets asset_link WHERE asset_link.visit_id = visit.id AND asset_link.included = true) AS assets, (SELECT COUNT(*) FROM inspection_visit_members member_link WHERE member_link.visit_id = visit.id AND member_link.included = true) AS members ) link_counts ON true ${where} `; } private async loadView(manager: EntityManager, id: string): Promise { const [visit] = (await manager.query( this.visitSelect('WHERE visit.id = $1'), [id], )) as InspectionVisitListItem[]; if (!visit) throw visitNotFound(); const assets = (await manager.query(` SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName" FROM inspection_visit_assets link INNER JOIN assets asset ON asset.id = link.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE link.visit_id = $1 AND link.included = true ORDER BY asset.code, asset.name `, [id])) as InspectionAssetSummary[]; const planningAssets = (await manager.query(` SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName", link.included, link.planning_source AS "planningSource", link.exclusion_reason AS "exclusionReason", link.excluded_at AS "excludedAt", CASE WHEN excluded_user.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', excluded_user.id, 'username', excluded_user.username, 'firstName', excluded_user.first_name, 'lastName', excluded_user.last_name ) END AS "excludedBy" FROM inspection_visit_assets link INNER JOIN assets asset ON asset.id = link.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN users excluded_user ON excluded_user.id = link.excluded_by WHERE link.visit_id = $1 ORDER BY link.included DESC, asset.code, asset.name `, [id])) as InspectionPlannedAsset[]; const team = (await manager.query(` SELECT user_account.id, user_account.username, user_account.first_name AS "firstName", user_account.last_name AS "lastName" FROM inspection_visit_members link INNER JOIN users user_account ON user_account.id = link.user_id WHERE link.visit_id = $1 AND link.included = true ORDER BY CASE WHEN user_account.id = $2 THEN 0 ELSE 1 END, user_account.last_name, user_account.first_name `, [id, visit.leadInspector?.id ?? null])) as InspectionPerson[]; const verificationFindings = (await manager.query(` SELECT finding.id, finding.code, finding.title, finding.status, finding.next_control_on AS "nextControlOn", asset.id AS "assetId", asset.code AS "assetCode", asset.name AS "assetName", 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", (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 "verificationEvidenceCount" FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id INNER JOIN assets asset ON asset.id = finding.asset_id WHERE verification_link.visit_id = $1 ORDER BY finding.next_control_on NULLS LAST, finding.code `, [id])) as InspectionVerificationFindingSummary[]; const checklistItems = visit.checklistGeneration > 0 ? (await manager.query(` SELECT item.id, item.finding_id AS "findingId", item.finding_code AS "findingCode", item.finding_title AS "findingTitle", item.finding_status AS "findingStatus", item.severity, item.item_kind AS "itemKind", item.reference_on AS "referenceOn", JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'typeName', asset_type.name ) AS asset, COALESCE(link.included, false) AS "assetIncluded" FROM inspection_visit_checklist_items item INNER JOIN assets asset ON asset.id = item.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN inspection_visit_assets link ON link.visit_id = item.visit_id AND link.asset_id = item.asset_id WHERE item.visit_id = $1 AND item.generation_number = $2 ORDER BY CASE item.item_kind WHEN 'VERIFICATION_OVERDUE' THEN 1 WHEN 'COMPANY_OVERDUE' THEN 2 WHEN 'UPCOMING_CONTROL' THEN 3 ELSE 4 END, item.reference_on NULLS LAST, item.finding_code `, [id, visit.checklistGeneration])) as InspectionVisitChecklistItem[] : []; const actionableAssets = new Set( checklistItems .filter((item) => item.itemKind !== 'ANTECEDENT') .map((item) => item.asset.id), ).size; const checklist: InspectionVisitChecklistSummary = { generation: visit.checklistGeneration, generatedAt: visit.checklistGeneratedAt, stale: visit.checklistGeneration > 0 && !visit.checklistGeneratedAt, antecedents: checklistItems.filter((item) => item.itemKind === 'ANTECEDENT').length, companyOverdue: checklistItems.filter((item) => item.itemKind === 'COMPANY_OVERDUE').length, verificationOverdue: checklistItems.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length, upcomingControls: checklistItems.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length, actionableAssets, excludedAssets: planningAssets.filter((asset) => !asset.included).length, items: checklistItems, }; return { ...visit, assets, planningAssets, team, verificationFindings, checklist }; } 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 visitNotFound(); return visit; } private assertPlanningEditable(visit: InspectionVisit): void { if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED].includes(visit.status)) { throw new ConflictException({ code: 'INSPECTION_VISIT_PLANNING_LOCKED', message: 'Una visita iniciada, cerrada o cancelada ya no puede replanificarse', }); } } private async validatePlanningContext( manager: EntityManager, operationalAreaId: string | null, operatorCompanyId: string | null, ): Promise { if (!operationalAreaId && !operatorCompanyId) return; if (!operationalAreaId || !operatorCompanyId) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_PAIR_REQUIRED', message: 'Área y Operadora deben seleccionarse juntas', }); } const [context] = (await manager.query(` SELECT area.id AS "areaId", company.id AS "companyId", EXISTS ( SELECT 1 FROM area_company_relations relation WHERE relation.area_id = area.id AND relation.company_id = company.id AND relation.relation_role = 'OPERATOR' AND relation.valid_from <= CURRENT_TIMESTAMP AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) ) AS "activeOperatorRelation" FROM assets area INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id CROSS JOIN assets company INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id WHERE area.id = $1 AND company.id = $2 AND area_type.operational_role = 'AREA' AND company_type.operational_role = 'COMPANY' AND area.information_status <> 'INACTIVE' AND company.information_status <> 'INACTIVE' `, [operationalAreaId, operatorCompanyId])) as Array<{ areaId: string; companyId: string; activeOperatorRelation: boolean; }>; if (!context) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_INVALID', message: 'El Área o la Organización operadora no son válidas para planificación', }); } if (!context.activeOperatorRelation) { throw new BadRequestException({ code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA', message: 'La Organización seleccionada no tiene relación OPERATOR vigente con el Área', }); } } private async assertAssetsMatchContext( manager: EntityManager, assetIds: string[], operationalAreaId: string, operatorCompanyId: string, ): Promise { if (assetIds.length === 0) return; const [row] = (await manager.query(` SELECT COUNT(*)::integer AS outside FROM assets asset WHERE asset.id = ANY($1::uuid[]) AND ( asset.operational_area_id IS DISTINCT FROM $2::uuid OR asset.operator_company_id IS DISTINCT FROM $3::uuid ) `, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>; if (Number(row?.outside ?? 0) > 0) { throw new BadRequestException({ code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT', message: 'Uno o más registros no pertenecen al Área y Operadora de la visita', }); } } private async assertCurrentAssetsMatchContext( manager: EntityManager, visitId: string, operationalAreaId: string, operatorCompanyId: string, ): Promise { await this.assertAssetsMatchContext( manager, await this.activeAssetIds(manager, visitId), operationalAreaId, operatorCompanyId, ); } private async appendAssetEvent( manager: EntityManager, visitId: string, assetId: string, eventType: 'LEGACY_INCLUDED' | 'AUTO_INCLUDED' | 'PREVENTIVE_INCLUDED' | 'VERIFICATION_INCLUDED' | 'EXCLUDED' | 'REINCLUDED', reason: string | null, actorUserId: string | null, metadata: Record | null, ): Promise { await manager.query(` INSERT INTO inspection_visit_asset_events ( visit_id, asset_id, event_type, reason, actor_user_id, metadata ) VALUES ($1, $2, $3, $4, $5, $6::jsonb) `, [visitId, assetId, eventType, reason, actorUserId, JSON.stringify(metadata ?? {})]); } private async generateChecklistInternal( manager: EntityManager, visit: InspectionVisit, actorUserId: string, ): Promise { if (!visit.operationalAreaId || !visit.operatorCompanyId) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'Seleccioná primero el Área y la Organización operadora', }); } if (!visit.plannedStartAt) { throw new BadRequestException({ code: 'INSPECTION_START_DATE_REQUIRED', message: 'Indicá la fecha prevista antes de generar el checklist', }); } await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); const generation = visit.checklistGeneration + 1; await manager.query(` INSERT INTO inspection_visit_checklist_items ( visit_id, generation_number, finding_id, asset_id, item_kind, reference_on, finding_status, finding_code, finding_title, severity ) SELECT $1, $2, finding.id, finding.asset_id, CASE WHEN finding.status = 'OPEN' AND finding.company_response_received_on IS NULL AND finding.correction_due_on IS NOT NULL AND finding.correction_due_on < $5::date THEN 'COMPANY_OVERDUE' WHEN 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 < $5::date AND COALESCE(( SELECT verification.outcome FROM inspection_finding_verification_visits verification WHERE verification.finding_id = finding.id AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC LIMIT 1 ), '') <> 'RESOLVED' THEN 'VERIFICATION_OVERDUE' WHEN 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 BETWEEN $5::date AND ($5::date + 30) AND COALESCE(( SELECT verification.outcome FROM inspection_finding_verification_visits verification WHERE verification.finding_id = finding.id AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC LIMIT 1 ), '') <> 'RESOLVED' THEN 'UPCOMING_CONTROL' ELSE 'ANTECEDENT' END, CASE WHEN finding.status = 'OPEN' AND finding.company_response_received_on IS NULL AND finding.correction_due_on IS NOT NULL AND finding.correction_due_on < $5::date THEN finding.correction_due_on WHEN finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END, finding.status, finding.code, finding.title, finding.severity FROM inspection_findings finding INNER JOIN assets asset ON asset.id = finding.asset_id WHERE asset.operational_area_id = $3 AND asset.operator_company_id = $4 AND finding.status <> 'VOIDED' ORDER BY finding.created_at, finding.id `, [ visit.id, generation, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt.toISOString().slice(0, 10), ]); const candidates = (await manager.query(` SELECT DISTINCT asset_id AS id FROM inspection_visit_checklist_items WHERE visit_id = $1 AND generation_number = $2 AND item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') ORDER BY asset_id `, [visit.id, generation])) as Array<{ id: string }>; const candidateIds = candidates.map((row) => row.id); const obsolete = (await manager.query(` SELECT asset_id AS id FROM inspection_visit_assets WHERE visit_id = $1 AND planning_source = 'AUTOMATIC' AND included = true AND NOT (asset_id = ANY($2::uuid[])) FOR UPDATE `, [visit.id, candidateIds])) as Array<{ id: string }>; const obsoleteReason = 'Ya no forma parte del checklist automático vigente'; for (const row of obsolete) { await manager.query(` UPDATE inspection_visit_assets SET included = false, exclusion_reason = $3, excluded_by = $4, excluded_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE visit_id = $1 AND asset_id = $2 `, [visit.id, row.id, obsoleteReason, actorUserId]); await this.appendAssetEvent( manager, visit.id, row.id, 'EXCLUDED', obsoleteReason, actorUserId, { automatic: true, generation }, ); } for (const assetId of candidateIds) { const [existing] = (await manager.query(` SELECT included, planning_source AS "planningSource", exclusion_reason AS "exclusionReason" FROM inspection_visit_assets WHERE visit_id = $1 AND asset_id = $2 FOR UPDATE `, [visit.id, assetId])) as Array<{ included: boolean; planningSource: InspectionVisitAssetPlanningSource; exclusionReason: string | null; }>; if (!existing) { await manager.query(` INSERT INTO inspection_visit_assets ( visit_id, asset_id, included, planning_source, added_by ) VALUES ($1, $2, true, 'AUTOMATIC', $3) `, [visit.id, assetId, actorUserId]); await this.appendAssetEvent( manager, visit.id, assetId, 'AUTO_INCLUDED', null, actorUserId, { generation }, ); } else if (existing.planningSource === InspectionVisitAssetPlanningSource.LEGACY && existing.included) { await manager.query(` UPDATE inspection_visit_assets SET planning_source = 'AUTOMATIC', updated_at = CURRENT_TIMESTAMP WHERE visit_id = $1 AND asset_id = $2 `, [visit.id, assetId]); } } visit.checklistGeneration = generation; visit.checklistGeneratedAt = new Date(); visit.updatedBy = actorUserId; await manager.getRepository(InspectionVisit).save(visit); } private async requireAsset(manager: EntityManager, id: string | null): Promise { if (!id) return; const rows = await manager.query('SELECT 1 FROM assets WHERE id = $1', [id]) as unknown[]; if (rows.length === 0) { throw new BadRequestException({ code: 'INSPECTION_SCOPE_ASSET_NOT_FOUND', message: 'El activo de ubicación o alcance no existe', }); } } private async requireAssets(manager: EntityManager, ids: string[]): Promise { if (ids.length === 0) return; const [row] = (await manager.query( `SELECT COUNT(*)::integer AS count FROM assets WHERE id = ANY($1::uuid[])`, [ids], )) as Array<{ count: number }>; if (Number(row?.count ?? 0) !== ids.length) { throw new BadRequestException({ code: 'INSPECTION_ASSET_NOT_FOUND', message: 'Uno o más activos seleccionados no existen', }); } } private async requireInspectors(manager: EntityManager, ids: string[]): Promise { if (ids.length === 0) return; const [row] = (await manager.query(` SELECT COUNT(*)::integer AS count FROM users user_account WHERE user_account.id = ANY($1::uuid[]) AND user_account.status = 'ACTIVE' AND EXISTS ( SELECT 1 FROM user_roles user_role INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id INNER JOIN permissions permission ON permission.id = role_permission.permission_id WHERE user_role.user_id = user_account.id AND permission.code = 'inspections.execute' ) `, [ids])) as Array<{ count: number }>; if (Number(row?.count ?? 0) !== ids.length) { throw new BadRequestException({ code: 'INSPECTION_MEMBER_INVALID', message: 'Todo integrante debe ser un usuario activo con permiso de ejecución', }); } } private async assertAssetsInScope( manager: EntityManager, assetIds: string[], scopeAssetId: string | null, ): Promise { if (!scopeAssetId || assetIds.length === 0) return; const [row] = (await manager.query(` SELECT COUNT(*)::integer AS outside FROM UNNEST($1::uuid[]) selected(asset_id) WHERE NOT EXISTS ( WITH RECURSIVE ancestors AS ( SELECT asset.id, asset.parent_id FROM assets asset WHERE asset.id = selected.asset_id UNION ALL SELECT parent.id, parent.parent_id FROM assets parent INNER JOIN ancestors child ON child.parent_id = parent.id ) SELECT 1 FROM ancestors WHERE id = $2 ) `, [assetIds, scopeAssetId])) as Array<{ outside: number }>; if (Number(row?.outside ?? 0) > 0) { throw new BadRequestException({ code: 'INSPECTION_ASSET_OUTSIDE_SCOPE', message: 'Uno o más activos no pertenecen al alcance jerárquico de la visita', }); } } private async assertCurrentAssetsInScope( manager: EntityManager, visitId: string, scopeAssetId: string | null, ): Promise { await this.assertAssetsInScope( manager, await this.activeAssetIds(manager, visitId), scopeAssetId, ); } private async validatePlan(manager: EntityManager, visit: InspectionVisit): Promise { if (!visit.operationalAreaId || !visit.operatorCompanyId) { throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación', }); } await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); if (!visit.scopeAssetId) { throw new BadRequestException({ code: 'INSPECTION_SCOPE_REQUIRED', message: 'La visita no tiene un alcance operativo válido', }); } if (!visit.plannedStartAt) { throw new BadRequestException({ code: 'INSPECTION_START_DATE_REQUIRED', message: 'Indicá la fecha y hora prevista de inicio', }); } if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) { throw new BadRequestException({ code: 'INSPECTION_CHECKLIST_REQUIRED', message: 'Generá el checklist automático antes de confirmar la planificación', }); } if (!visit.leadInspectorUserId) { throw new BadRequestException({ code: 'INSPECTION_LEAD_REQUIRED', message: 'Asigná un inspector responsable', }); } await this.requireInspectors(manager, [visit.leadInspectorUserId]); const memberIds = await this.activeMemberIds(manager, visit.id); await this.requireInspectors(manager, memberIds); const [counts] = (await manager.query(` SELECT (SELECT COUNT(*) FROM inspection_visit_assets WHERE visit_id = $1 AND included = true)::integer AS assets, (SELECT COUNT(*) FROM inspection_visit_members WHERE visit_id = $1 AND included = true)::integer AS members, EXISTS ( SELECT 1 FROM inspection_visit_members WHERE visit_id = $1 AND user_id = $2 AND included = true ) AS "leadIncluded" `, [visit.id, visit.leadInspectorUserId])) as Array<{ assets: number; members: number; leadIncluded: boolean; }>; if (Number(counts?.assets ?? 0) < 1) { throw new BadRequestException({ code: 'INSPECTION_ASSETS_REQUIRED', message: 'Agregá al menos un activo existente para inspeccionar', }); } if (memberIds.length < 1 || Number(counts?.members ?? 0) < 1 || !counts?.leadIncluded) { throw new BadRequestException({ code: 'INSPECTION_TEAM_REQUIRED', message: 'El equipo debe incluir al inspector responsable', }); } await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId); await this.assertCurrentAssetsMatchContext( manager, visit.id, visit.operationalAreaId, visit.operatorCompanyId, ); } private async activeAssetIds(manager: EntityManager, visitId: string): Promise { const rows = (await manager.query(` SELECT asset_id AS id FROM inspection_visit_assets WHERE visit_id = $1 AND included = true ORDER BY created_at, asset_id `, [visitId])) as Array<{ id: string }>; return rows.map((row) => row.id); } private async activeMemberIds(manager: EntityManager, visitId: string): Promise { const rows = (await manager.query(` SELECT user_id AS id FROM inspection_visit_members WHERE visit_id = $1 AND included = true ORDER BY created_at, user_id `, [visitId])) as Array<{ id: string }>; return rows.map((row) => row.id); } private async replaceMemberLinks( manager: EntityManager, visitId: string, memberIds: string[], userId: string, ): Promise { await manager.query(` UPDATE inspection_visit_members SET included = false, updated_at = CURRENT_TIMESTAMP WHERE visit_id = $1 AND included = true `, [visitId]); for (const memberId of memberIds) { await manager.query(` INSERT INTO inspection_visit_members (visit_id, user_id, included, assigned_by) VALUES ($1, $2, true, $3) ON CONFLICT (visit_id, user_id) DO UPDATE SET included = true, assigned_by = EXCLUDED.assigned_by, updated_at = CURRENT_TIMESTAMP `, [visitId, memberId, userId]); } } private codeConflict(): ConflictException { return new ConflictException({ code: 'INSPECTION_VISIT_CODE_EXISTS', message: 'Ya existe una visita con ese código', }); } private auditView(visit: InspectionVisitView): Record { return { code: visit.code, title: visit.title, objective: visit.objective, status: visit.status, scopeAssetId: visit.scopeAsset?.id ?? null, operationalAreaId: visit.operationalArea?.id ?? null, operatorCompanyId: visit.operatorCompany?.id ?? null, leadInspectorUserId: visit.leadInspector?.id ?? null, plannedStartAt: visit.plannedStartAt, plannedEndAt: visit.plannedEndAt, actualStartedAt: visit.actualStartedAt, actualClosedAt: visit.actualClosedAt, instructions: visit.instructions, cancellationReason: visit.cancellationReason, checklistGeneration: visit.checklist.generation, checklistGeneratedAt: visit.checklist.generatedAt, assetIds: visit.assets.map((asset) => asset.id), excludedAssetIds: visit.planningAssets.filter((asset) => !asset.included).map((asset) => asset.id), memberUserIds: visit.team.map((member) => member.id), }; } }