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. La ubicación territorial es Departamento → Área → Yacimiento. * Empresa y Tipo de concesión pertenecen directamente al Yacimiento; por eso * la Operadora elegida debe coincidir con el Yacimiento seleccionado. */ @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 yacimiento.id FROM assets yacimiento INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id INNER JOIN assets company ON company.id=yacimiento.operator_company_id INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id WHERE yacimiento.id=$1::uuid AND yacimiento.parent_id=$2::uuid AND yacimiento.operator_company_id=$3::uuid AND lower(yacimiento_type.code)='yacimiento' AND yacimiento_type.is_active=true AND yacimiento.information_status<>'INACTIVE' AND yacimiento.concession_type_id IS NOT NULL AND company_type.operational_role='COMPANY' AND company_type.is_active=true AND company.information_status<>'INACTIVE' LIMIT 1 `, [scopeAssetId, dto.operationalAreaId, dto.operatorCompanyId]) as Array<{ id: string }>; if (!context) { throw new BadRequestException({ code: 'INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO', message: 'La Empresa seleccionada no es la Empresa relacionada con el Yacimiento', }); } 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]); 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 = 'checklist:assets'; await manager.query(` INSERT INTO inspection_visit_assets ( visit_id, asset_id, included, planning_source, added_by ) SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid FROM inspection_visit_checklist_items item WHERE item.visit_id=$1::uuid AND item.generation_number=1 AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') ON CONFLICT (visit_id,asset_id) DO UPDATE SET included=true, planning_source=CASE WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION' ELSE 'AUTOMATIC' END, exclusion_reason=NULL, excluded_by=NULL, excluded_at=NULL, added_by=EXCLUDED.added_by, updated_at=CURRENT_TIMESTAMP `, [visit.id, principal.userId]); stage = 'checklist:asset-events'; await manager.query(` INSERT INTO inspection_visit_asset_events ( visit_id, asset_id, event_type, reason, actor_user_id, metadata ) SELECT DISTINCT $1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid, jsonb_build_object('generation',1,'source','AUTHORITATIVE_CREATE') FROM inspection_visit_checklist_items item WHERE item.visit_id=$1::uuid AND item.generation_number=1 AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL') AND NOT EXISTS ( SELECT 1 FROM inspection_visit_asset_events event WHERE event.visit_id=$1::uuid AND event.asset_id=item.asset_id AND event.event_type='AUTO_INCLUDED' ) `, [visit.id, principal.userId]); 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, operatorSource: 'YACIMIENTO', 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(`inspection create failed at ${stage}: ${technical}`); throw error; } } }