370 lines
15 KiB
TypeScript
370 lines
15 KiB
TypeScript
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<AreaCompanyRelationView> {
|
|
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<AreaCompanyRelationView> {
|
|
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<OperationalAssetSummary[]> {
|
|
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<void> {
|
|
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<AreaCompanyRelationView> {
|
|
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<string, unknown> {
|
|
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,
|
|
};
|
|
}
|
|
}
|