250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|