import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AreaOrganizationRole, AssetTypeOperationalRole, AuditAction } from '../database/entities'; import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit'; import type { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto'; import type { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto'; import type { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto'; export interface OperationalAssetSummary { id: string; code: string; name: string; commonName: string | null; typeName: string; } export interface AreaCompanyRelationView { id: string; area: OperationalAssetSummary; company: OperationalAssetSummary; relationRole: AreaOrganizationRole; participationPercent: number | null; legalInstrument: string | null; sourceDocumentId: string | null; validFrom: Date; validUntil: Date | null; startReason: string; endReason: string | null; createdBy: { id: string; username: string } | null; endedBy: { id: string; username: string } | null; createdAt: Date; updatedAt: Date; active: boolean; assignedAssetCount: number; } @Injectable() export class AssetOperationalRelationsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async listAreas(parentId?: string): Promise<{ data: OperationalAssetSummary[] }> { if (!parentId) { return { data: await this.listAssetsByRole(AssetTypeOperationalRole.AREA) }; } const data = (await this.dataSource.query(` WITH RECURSIVE ancestors AS ( SELECT id, parent_id FROM assets WHERE id = $1 UNION ALL SELECT parent.id, parent.parent_id FROM assets parent INNER JOIN ancestors current ON parent.id = current.parent_id ) SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName" FROM ancestors INNER JOIN assets asset ON asset.id = ancestors.id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset_type.operational_role = $2 AND asset_type.is_active = true AND asset.information_status <> 'INACTIVE' ORDER BY asset.name, asset.code `, [parentId, AssetTypeOperationalRole.AREA])) as OperationalAssetSummary[]; return { data }; } async listCompanies(): Promise<{ data: OperationalAssetSummary[] }> { return { data: await this.listAssetsByRole(AssetTypeOperationalRole.COMPANY) }; } async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); const data = (await this.dataSource.query(` SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" FROM ( SELECT relation.company_id FROM area_company_relations relation WHERE relation.area_id = $1 AND relation.relation_role = 'OPERATOR' AND relation.valid_until IS NULL UNION SELECT asset.operator_company_id AS company_id FROM assets asset WHERE asset.operational_area_id = $1 AND asset.operator_company_id IS NOT NULL AND asset.information_status <> 'INACTIVE' ) linked INNER JOIN assets company ON company.id = linked.company_id INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id WHERE company.information_status <> 'INACTIVE' ORDER BY company.name, company.code `, [areaId])) as OperationalAssetSummary[]; return { data }; } async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); const data = (await this.dataSource.query(` SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" FROM ( SELECT relation.area_id FROM area_company_relations relation WHERE relation.company_id = $1 AND relation.relation_role = 'OPERATOR' AND relation.valid_until IS NULL UNION SELECT asset.operational_area_id AS area_id FROM assets asset WHERE asset.operator_company_id = $1 AND asset.operational_area_id IS NOT NULL AND asset.information_status <> 'INACTIVE' ) linked INNER JOIN assets area ON area.id = linked.area_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id WHERE area.information_status <> 'INACTIVE' ORDER BY area.name, area.code `, [companyId])) as OperationalAssetSummary[]; return { data }; } async list(query: ListAreaCompanyRelationsQueryDto): Promise<{ data: AreaCompanyRelationView[] }> { const conditions: string[] = []; const parameters: unknown[] = []; const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.areaId) conditions.push(`relation.area_id = ${add(query.areaId)}`); if (query.companyId) conditions.push(`relation.company_id = ${add(query.companyId)}`); if (!query.includeHistory) conditions.push('relation.valid_until IS NULL'); const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const data = (await this.dataSource.query( `${this.relationSelect(where)} ORDER BY relation.valid_until NULLS FIRST, relation.valid_from DESC`, parameters, )) as AreaCompanyRelationView[]; return { data }; } async create( dto: CreateAreaCompanyRelationDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { try { return await this.dataSource.transaction(async (manager) => { await this.requireAssetRole(manager, dto.areaId, AssetTypeOperationalRole.AREA); await this.requireAssetRole(manager, dto.companyId, AssetTypeOperationalRole.COMPANY); if (dto.sourceDocumentId) { const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); } const [row] = (await manager.query(` INSERT INTO area_company_relations ( area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; const created = await this.loadRelation(manager, row.id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_AREA_COMPANY_RELATION_CREATED, entityType: 'area_company_relation', entityId: row.id, afterData: this.auditView(created), }, manager); return created; }); } catch (error) { if (isUniqueViolation(error)) { throw new ConflictException({ code: 'AREA_COMPANY_RELATION_EXISTS', message: 'La organización ya tiene ese rol activo en el área', }); } throw error; } } async end( id: string, dto: EndAreaCompanyRelationDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const before = await this.loadRelation(manager, id, true); if (!before.active) { throw new ConflictException({ code: 'AREA_COMPANY_RELATION_ALREADY_ENDED', message: 'La relación ya se encuentra finalizada', }); } if (before.assignedAssetCount > 0) { throw new ConflictException({ code: 'AREA_COMPANY_RELATION_IN_USE', message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`, }); } await manager.query(` UPDATE area_company_relations SET valid_until = CURRENT_TIMESTAMP, end_reason = $2, ended_by = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND valid_until IS NULL `, [id, dto.reason, principal.userId]); const updated = await this.loadRelation(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_AREA_COMPANY_RELATION_ENDED, entityType: 'area_company_relation', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), }, manager); return updated; }); } private async listAssetsByRole(role: AssetTypeOperationalRole): Promise { return (await this.dataSource.query(` SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName" FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset_type.operational_role = $1 AND asset_type.is_active = true AND asset.information_status <> 'INACTIVE' ORDER BY asset.name, asset.code `, [role])) as OperationalAssetSummary[]; } private async requireAssetRole( manager: EntityManager, assetId: string, role: AssetTypeOperationalRole, ): Promise { const [row] = (await manager.query(` SELECT asset.id, asset.information_status AS "informationStatus", asset_type.operational_role AS role, asset_type.is_active AS "typeActive" FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE asset.id = $1 `, [assetId])) as Array<{ id: string; informationStatus: string; role: AssetTypeOperationalRole; typeActive: boolean; }>; if (!row) { throw new BadRequestException({ code: 'OPERATIONAL_ASSET_NOT_FOUND', message: role === AssetTypeOperationalRole.AREA ? 'El área seleccionada no existe' : 'La empresa seleccionada no existe', }); } if (row.role !== role) { throw new BadRequestException({ code: 'OPERATIONAL_ASSET_ROLE_INVALID', message: role === AssetTypeOperationalRole.AREA ? 'El activo seleccionado no está configurado como Área' : 'El activo seleccionado no está configurado como Empresa', }); } if (!row.typeActive || row.informationStatus === 'INACTIVE') { throw new ConflictException({ code: 'OPERATIONAL_ASSET_INACTIVE', message: role === AssetTypeOperationalRole.AREA ? 'El área o su tipo se encuentra inactivo' : 'La empresa o su tipo se encuentra inactivo', }); } } private async loadRelation( manager: EntityManager, id: string, lock = false, ): Promise { if (lock) { const rows = await manager.query( 'SELECT id FROM area_company_relations WHERE id = $1 FOR UPDATE', [id], ) as unknown[]; if (rows.length === 0) throw this.relationNotFound(); } const [row] = (await manager.query( `${this.relationSelect('WHERE relation.id = $1')}`, [id], )) as AreaCompanyRelationView[]; if (!row) throw this.relationNotFound(); return row; } private relationSelect(where: string): string { return ` SELECT relation.id, JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name, 'typeName', area_type.name ) AS area, JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name, 'typeName', company_type.name ) AS company, relation.relation_role AS "relationRole", relation.participation_percent::double precision AS "participationPercent", relation.legal_instrument AS "legalInstrument", relation.source_document_id AS "sourceDocumentId", relation.valid_from AS "validFrom", relation.valid_until AS "validUntil", relation.start_reason AS "startReason", relation.end_reason AS "endReason", CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', creator.id, 'username', creator.username ) END AS "createdBy", CASE WHEN ender.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', ender.id, 'username', ender.username ) END AS "endedBy", relation.created_at AS "createdAt", relation.updated_at AS "updatedAt", (relation.valid_until IS NULL) AS active, CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset WHERE asset.operational_area_id = relation.area_id AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount" 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 LEFT JOIN users creator ON creator.id = relation.created_by LEFT JOIN users ender ON ender.id = relation.ended_by ${where} `; } private relationNotFound(): NotFoundException { return new NotFoundException({ code: 'AREA_COMPANY_RELATION_NOT_FOUND', message: 'Relación entre área y empresa no encontrada', }); } private auditView(relation: AreaCompanyRelationView): Record { return { id: relation.id, areaId: relation.area.id, companyId: relation.company.id, relationRole: relation.relationRole, participationPercent: relation.participationPercent, legalInstrument: relation.legalInstrument, sourceDocumentId: relation.sourceDocumentId, validFrom: relation.validFrom, validUntil: relation.validUntil, startReason: relation.startReason, endReason: relation.endReason, active: relation.active, assignedAssetCount: relation.assignedAssetCount, }; } }