diff --git a/api-v3/src/inspection-visits/inspection-planning-create.service.ts b/api-v3/src/inspection-visits/inspection-planning-create.service.ts new file mode 100644 index 0000000..51d7c8c --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-planning-create.service.ts @@ -0,0 +1,190 @@ +import { BadRequestException, ConflictException, Injectable } 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 { + 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', + }); + } + + try { + const visitId = await this.dataSource.transaction(async (manager) => { + await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId); + + 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', + }); + } + + 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', + }); + } + + 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, + }); + await manager.getRepository(InspectionVisit).save(visit); + + 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. + 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)]); + + 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; + }); + + return 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', + }); + } + throw error; + } + } +}