From 577e267f498c48ca45ff8b0e17d16f43d2aa22be Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 21:19:12 -0300 Subject: [PATCH] F4: implement explicit inspection lifecycle --- .../inspection-visit-lifecycle.service.ts | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts diff --git a/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts b/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts new file mode 100644 index 0000000..e420083 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visit-lifecycle.service.ts @@ -0,0 +1,297 @@ +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', + }); + } + } +}