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, InspectionVisit, InspectionVisitStatus } from '../database/entities'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import type { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { InspectionVisitsService } from './inspection-visits.service'; interface PendingActRow { id: string; code: string; status: string; } @Injectable() export class InspectionVisitLifecycleService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly visits: InspectionVisitsService, ) {} async plan( id: string, principal: AuthPrincipal, request: RequestWithContext, ) { await this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (visit.status !== InspectionVisitStatus.DRAFT) { throw new ConflictException({ code: 'INSPECTION_NOT_DRAFT', message: 'Sólo una Inspección en borrador puede planificarse', }); } await this.validatePlanningContext(manager, visit); const before = visit.status; visit.status = InspectionVisitStatus.PLANNED; visit.cancellationReason = null; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, entityType: 'inspection_visit', entityId: id, beforeData: { status: before }, afterData: { status: InspectionVisitStatus.PLANNED }, metadata: { transition: 'PLAN' }, }, manager); }); return this.visits.getById(id); } async start( id: string, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); await this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (visit.status !== InspectionVisitStatus.PLANNED) { throw new ConflictException({ code: 'INSPECTION_NOT_PLANNED', message: 'La Inspección debe estar planificada antes de iniciarse', }); } await this.validatePlanningContext(manager, visit); await this.assertActorAssigned(manager, visit, principal); const startedAt = new Date(); visit.status = InspectionVisitStatus.IN_PROGRESS; visit.actualStartedAt = startedAt; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); 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: startedAt }, metadata: { transition: 'START', source: 'ANDROID' }, }, manager); }); return this.visits.getById(id); } async close( id: string, dto: CloseInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); await this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_NOT_IN_PROGRESS', message: 'La Inspección debe estar en curso para cerrarse', }); } await this.assertActorAssigned(manager, visit, principal); const acts = await manager.query(` SELECT id,code,status FROM inspection_acts WHERE visit_id=$1 AND status<>'CANCELLED' ORDER BY occurred_at,created_at,id FOR UPDATE `, [id]) as PendingActRow[]; if (acts.length === 0) { throw new ConflictException({ code: 'INSPECTION_REQUIRES_ACT', message: 'La Inspección debe tener al menos un Acta antes de cerrarse', }); } const pending = acts.filter((act) => act.status !== 'SEALED'); if (pending.length > 0) { throw new ConflictException({ code: 'INSPECTION_ACTS_NOT_SEALED', message: 'Todas las Actas deben estar SELLADAS antes de cerrar la Inspección', pendingActs: pending.map((act) => ({ id: act.id, code: act.code, status: act.status })), }); } const serverClosedAt = new Date(); const clientClosedAt = new Date(dto.clientClosedAt); if (!Number.isFinite(clientClosedAt.getTime())) { throw new BadRequestException({ code: 'INSPECTION_CLOSE_TIME_INVALID', message: 'La fecha de cierre informada por el dispositivo no es válida', }); } if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) { throw new BadRequestException({ code: 'INSPECTION_CLOSE_BEFORE_START', message: 'La fecha de cierre no puede ser anterior al inicio de la Inspección', }); } if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INSPECTION_CLOSE_TIME_IN_FUTURE', message: 'La fecha del 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); 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(), sealedActs: acts.length, }, metadata: { transition: 'CLOSE', source: 'ANDROID', allActsSealed: true, }, }, manager); }); return this.visits.getById(id); } async cancel( id: string, dto: CancelInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ) { await this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, id); if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED, InspectionVisitStatus.IN_PROGRESS].includes(visit.status)) { throw new ConflictException({ code: 'INSPECTION_CANNOT_CANCEL', message: 'La Inspección ya no puede cancelarse en su estado actual', }); } const [sealedAct] = await manager.query(` SELECT 1 FROM inspection_acts WHERE visit_id=$1 AND status='SEALED' LIMIT 1 `, [id]) as unknown[]; if (sealedAct) { throw new ConflictException({ code: 'INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL', message: 'Una Inspección con Actas SELLADAS no puede cancelarse', }); } const before = visit.status; visit.status = InspectionVisitStatus.CANCELLED; visit.cancellationReason = dto.reason; visit.updatedBy = principal.userId; await manager.getRepository(InspectionVisit).save(visit); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, entityType: 'inspection_visit', entityId: id, beforeData: { status: before }, afterData: { status: InspectionVisitStatus.CANCELLED, cancellationReason: dto.reason }, metadata: { transition: 'CANCEL' }, }, manager); }); return this.visits.getById(id); } 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_NOT_FOUND', message: 'Inspección no encontrada' }); } return visit; } private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise { if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) { throw new ConflictException({ code: 'INSPECTION_PLANNING_INCOMPLETE', message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar', }); } const [relation] = await manager.query(` SELECT 1 FROM area_company_relations WHERE area_id=$1 AND company_id=$2 AND relation_role='OPERATOR' AND valid_from<=CURRENT_TIMESTAMP AND (valid_until IS NULL OR valid_until>CURRENT_TIMESTAMP) LIMIT 1 `, [visit.operationalAreaId, visit.operatorCompanyId]) as unknown[]; if (!relation) { throw new ConflictException({ code: 'INSPECTION_OPERATOR_RELATION_INVALID', message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento', }); } const [inspector] = await manager.query(` SELECT 1 FROM users user_account JOIN user_roles user_role ON user_role.user_id=user_account.id JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id JOIN permissions permission ON permission.id=role_permission.permission_id WHERE user_account.id=$1 AND user_account.status='ACTIVE' AND permission.code='inspections.execute' LIMIT 1 `, [visit.leadInspectorUserId]) as unknown[]; if (!inspector) { throw new ConflictException({ code: 'INSPECTION_LEAD_INVALID', message: 'El Inspector responsable no está activo o no puede ejecutar Inspecciones', }); } } private async assertActorAssigned( manager: EntityManager, visit: InspectionVisit, principal: AuthPrincipal, ): Promise { if (principal.permissions.includes('inspections.manage')) return; const [member] = await manager.query(` SELECT 1 FROM inspection_visit_members WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 `, [visit.id, principal.userId]) as unknown[]; if (!member && visit.leadInspectorUserId !== principal.userId) { throw new ForbiddenException({ code: 'INSPECTION_NOT_ASSIGNED', message: 'La Inspección no está asignada al usuario actual', }); } } }