chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user