import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common'; import { DataSource } 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, InspectionVisitStatus } from '../database/entities'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { nextInspectionVisitCode } from './inspection-visit-code'; import { validateInspectionPlanningScope } from './inspection-planning-scope'; import { InspectionVisitsService } from './inspection-visits.service'; /** * Creación WEB F6.1. Separa explícitamente ubicación territorial y alcance: * Departamento → Área → Yacimiento. La Operadora no es un padre físico; se * valida contra la relación temporal vigente del Área para la fecha planificada. */ @Injectable() export class InspectionPlanningCreateService { private readonly logger = new Logger(InspectionPlanningCreateService.name); constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly visits: InspectionVisitsService, ) {} async create( dto: CreateInspectionVisitDto, principal: AuthPrincipal, request: RequestWithContext, ) { const scopeAssetId = dto.scopeAssetId; if (!scopeAssetId) { throw new BadRequestException({ code: 'INSPECTION_YACIMIENTO_REQUIRED', message: 'Seleccioná el Yacimiento de la Inspección', }); } const plannedStartAt = new Date(dto.plannedStartAt); if (!Number.isFinite(plannedStartAt.getTime())) { throw new BadRequestException({ code: 'INSPECTION_START_DATE_REQUIRED', message: 'Indicá una fecha y hora prevista de inicio válida', }); } let stage = 'transaction:start'; try { const visitId = await this.dataSource.transaction(async (manager) => { stage = 'scope:validate'; await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId); stage = 'operator:validate'; const [context] = await manager.query(` SELECT relation.id FROM area_company_relations relation INNER JOIN assets area ON area.id=relation.area_id INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id 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::uuid AND relation.company_id=$2::uuid AND relation.relation_role='OPERATOR' AND relation.valid_from <= $3::timestamptz AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz) AND area_type.operational_role='AREA' AND company_type.operational_role='COMPANY' AND area.information_status<>'INACTIVE' AND company.information_status<>'INACTIVE' LIMIT 1 `, [dto.operationalAreaId, dto.operatorCompanyId, plannedStartAt]) as Array<{ id: string }>; if (!context) { throw new BadRequestException({ code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA', message: 'La Operadora seleccionada no está vigente para el Área en la fecha de la Inspección', }); } stage = 'inspector:validate'; const [inspector] = await manager.query(` SELECT user_account.id FROM users user_account WHERE user_account.id=$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' ) LIMIT 1 `, [dto.leadInspectorUserId]) as Array<{ id: string }>; if (!inspector) { throw new BadRequestException({ code: 'INSPECTION_MEMBER_INVALID', message: 'El Inspector responsable no está activo o no tiene permiso de ejecución', }); } stage = 'code:next'; const code = await nextInspectionVisitCode(manager, plannedStartAt); const visit = manager.getRepository(InspectionVisit).create({ code, objective: null, status: InspectionVisitStatus.DRAFT, scopeAssetId, operationalAreaId: dto.operationalAreaId, operatorCompanyId: dto.operatorCompanyId, leadInspectorUserId: dto.leadInspectorUserId, plannedStartAt, actualStartedAt: null, actualClosedAt: null, instructions: null, cancellationReason: null, checklistGeneration: 1, checklistGeneratedAt: new Date(), createdBy: principal.userId, updatedBy: principal.userId, }); stage = 'visit:save'; await manager.getRepository(InspectionVisit).save(visit); stage = 'member:insert'; await manager.query(` INSERT INTO inspection_visit_members (visit_id,user_id,included,assigned_by) VALUES ($1::uuid,$2::uuid,true,$3::uuid) `, [visit.id, dto.leadInspectorUserId, principal.userId]); // El checklist sigue el alcance físico del Yacimiento. No usa // assets.operator_company_id, que en F6 es sólo snapshot histórico. stage = 'checklist:insert'; 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::uuid,1,finding.id,finding.asset_id, CASE WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE' WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL' ELSE 'ANTECEDENT' END, CASE WHEN 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 inventory ON inventory.id=finding.asset_id WHERE inventory.operational_area_id=$2::uuid AND finding.status<>'VOIDED' AND EXISTS ( WITH RECURSIVE ancestors AS ( SELECT current_asset.id,current_asset.parent_id FROM assets current_asset WHERE current_asset.id=finding.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=$3::uuid LIMIT 1 ) ORDER BY finding.created_at,finding.id `, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]); stage = 'audit:record'; await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_VISIT_CREATED, entityType: 'inspection_visit', entityId: visit.id, afterData: { id: visit.id, code, status: visit.status, departmentHierarchy: 'Departamento → Área → Yacimiento', operationalAreaId: dto.operationalAreaId, scopeAssetId, operatorCompanyId: dto.operatorCompanyId, plannedStartAt: plannedStartAt.toISOString(), leadInspectorUserId: dto.leadInspectorUserId, }, }, manager); return visit.id; }); stage = 'view:load'; return await this.visits.getById(visitId); } catch (error) { if (isUniqueViolation(error)) { throw new ConflictException({ code: 'INSPECTION_CODE_ALREADY_EXISTS', message: 'El identificador de Inspección ya existe; volvé a intentar', }); } const technical = error instanceof Error ? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}` : String(error); this.logger.error(`F6.1 inspection create failed at ${stage}: ${technical}`); throw error; } } }