578 lines
26 KiB
TypeScript
578 lines
26 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { DataSource, EntityManager } 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 {
|
|
AssetDataOrigin,
|
|
AssetInformationStatus,
|
|
AssetOperationalStatus,
|
|
AssetVersionChangeType,
|
|
AuditAction,
|
|
} from '../database/entities';
|
|
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
|
|
import { AssetHistoryService } from './asset-history.service';
|
|
|
|
type StructureTypeRow = { id: string; code: string; name: string };
|
|
type SimpleOptionRow = { id: string; code: string; name: string };
|
|
type FamilyParent = { id: string; code: string; name: string };
|
|
type FamilyRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
|
legacyTypeCode: string | null;
|
|
informationLabels: string[];
|
|
parentFamilyIds: string[];
|
|
parentFamilies: FamilyParent[];
|
|
};
|
|
type ParentRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
typeCode: string;
|
|
inventoryFamilyId: string | null;
|
|
operationalAreaId: string | null;
|
|
operatorCompanyId: string | null;
|
|
};
|
|
type IdRow = { id: string };
|
|
|
|
type YacimientoContext = {
|
|
companyId: string;
|
|
concessionTypeId: string;
|
|
concessionName: string;
|
|
};
|
|
|
|
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
|
|
DEPARTAMENTO: 'departamento',
|
|
AREA: 'area',
|
|
YACIMIENTO: 'yacimiento',
|
|
INSTALACION: 'instalacion',
|
|
SUBINSTALACION: 'subinstalacion',
|
|
};
|
|
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
|
EMPRESA: null,
|
|
DEPARTAMENTO: null,
|
|
AREA: 'departamento',
|
|
YACIMIENTO: 'area',
|
|
INSTALACION: 'yacimiento',
|
|
SUBINSTALACION: 'instalacion',
|
|
};
|
|
const FAMILY_LEVEL_BY_KIND: Partial<Record<InventoryStructureKind, FamilyRow['level']>> = {
|
|
INSTALACION: 'INSTALLATION',
|
|
SUBINSTALACION: 'SUBINSTALLATION',
|
|
};
|
|
|
|
@Injectable()
|
|
export class InventoryStructureService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
private readonly history: AssetHistoryService,
|
|
) {}
|
|
|
|
async options() {
|
|
const types = (await this.dataSource.query(`
|
|
SELECT id,code,name
|
|
FROM asset_types
|
|
WHERE (
|
|
lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
|
|
OR operational_role='COMPANY'
|
|
) AND is_active=true
|
|
ORDER BY CASE
|
|
WHEN operational_role='COMPANY' THEN 0
|
|
WHEN lower(code)='departamento' THEN 1
|
|
WHEN lower(code)='area' THEN 2
|
|
WHEN lower(code)='yacimiento' THEN 3
|
|
WHEN lower(code)='instalacion' THEN 4
|
|
WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END
|
|
`)) as StructureTypeRow[];
|
|
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
|
|
const departamento = types.find((item) => item.code.toLowerCase()==='departamento');
|
|
const area = types.find((item) => item.code.toLowerCase()==='area');
|
|
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
|
|
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
|
|
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
|
|
if (!company || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) {
|
|
throw new ConflictException({
|
|
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
|
|
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
|
|
});
|
|
}
|
|
|
|
const families = (await this.dataSource.query(`
|
|
SELECT family.id,family.code,family.name,family.level,
|
|
family.legacy_type_code AS "legacyTypeCode",
|
|
family.information_labels AS "informationLabels",
|
|
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
|
|
FROM inventory_family_parent_rules rule
|
|
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
|
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
|
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
|
FROM inventory_family_parent_rules rule
|
|
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
|
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
|
|
FROM inventory_families family
|
|
WHERE family.is_active=true
|
|
ORDER BY family.level,family.name,family.code
|
|
`)) as FamilyRow[];
|
|
|
|
const companies = (await this.dataSource.query(`
|
|
SELECT asset.id,asset.code,COALESCE(profile.legal_name,asset.name) AS name
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id AND type.operational_role='COMPANY' AND type.is_active=true
|
|
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
|
|
WHERE asset.information_status<>'INACTIVE'
|
|
ORDER BY COALESCE(profile.legal_name,asset.name),asset.code
|
|
`)) as SimpleOptionRow[];
|
|
const concessionTypes = (await this.dataSource.query(`
|
|
SELECT id,code,name
|
|
FROM concession_types
|
|
WHERE is_active=true
|
|
ORDER BY CASE lower(name) WHEN 'explotación' THEN 1 WHEN 'exploración' THEN 2 ELSE 9 END,name,code
|
|
`)) as SimpleOptionRow[];
|
|
|
|
return {
|
|
independentMasters: [
|
|
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
|
|
],
|
|
levels: [
|
|
{ kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false },
|
|
{ kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false },
|
|
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
|
|
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
|
|
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
|
|
],
|
|
companies,
|
|
concessionTypes,
|
|
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
|
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
|
};
|
|
}
|
|
|
|
async parents(kindValue: string, search?: string) {
|
|
const kind = kindValue.toUpperCase() as InventoryStructureKind;
|
|
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
|
|
throw new BadRequestException({
|
|
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
|
|
message: 'El nivel indicado no requiere un registro padre',
|
|
});
|
|
}
|
|
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
|
const parameters: unknown[] = [expectedType];
|
|
let searchSql = '';
|
|
if (search?.trim()) {
|
|
parameters.push(`%${search.trim()}%`);
|
|
searchSql = `AND (asset.code ILIKE $2 OR asset.name ILIKE $2 OR COALESCE(asset.common_name,'') ILIKE $2)`;
|
|
}
|
|
const rows = await this.dataSource.query(`
|
|
SELECT
|
|
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
|
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
|
'informationLabels',family.information_labels
|
|
) END AS "inventoryFamily",
|
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id',parent.id,'code',parent.code,'name',parent.name
|
|
) END AS parent
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
|
WHERE lower(type.code)=lower($1::text)
|
|
AND asset.information_status<>'INACTIVE'
|
|
${searchSql}
|
|
ORDER BY asset.name,asset.code
|
|
LIMIT 100
|
|
`, parameters);
|
|
return { data: rows };
|
|
}
|
|
|
|
async create(
|
|
dto: CreateInventoryStructureDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
) {
|
|
try {
|
|
return await this.dataSource.transaction(async (manager) => {
|
|
const type = await this.requireStructureType(manager, dto.kind);
|
|
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
|
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
|
const yacimientoContext = await this.requireYacimientoContext(manager, dto);
|
|
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
|
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
|
|
? await this.resolveAreaId(manager, parent)
|
|
: null;
|
|
const operatorCompanyId = dto.kind === 'YACIMIENTO'
|
|
? yacimientoContext?.companyId ?? null
|
|
: dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION'
|
|
? parent?.operatorCompanyId ?? null
|
|
: null;
|
|
const concessionTypeId = dto.kind === 'YACIMIENTO'
|
|
? yacimientoContext?.concessionTypeId ?? null
|
|
: null;
|
|
|
|
if ((dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') && !operatorCompanyId) {
|
|
throw new ConflictException({
|
|
code: 'INVENTORY_YACIMIENTO_COMPANY_MISSING',
|
|
message: 'El Yacimiento de origen no tiene una Empresa relacionada válida',
|
|
});
|
|
}
|
|
|
|
const inserted = (await manager.query(`
|
|
INSERT INTO assets (
|
|
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
|
|
code,name,common_name,description,information_status,operational_status,
|
|
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
|
) VALUES (
|
|
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,
|
|
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
|
|
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
|
|
) RETURNING id
|
|
`, [
|
|
type.id,
|
|
parent?.id ?? null,
|
|
operationalAreaId,
|
|
operatorCompanyId,
|
|
concessionTypeId,
|
|
family?.id ?? null,
|
|
generatedCode,
|
|
dto.name,
|
|
dto.commonName ?? null,
|
|
dto.description ?? null,
|
|
AssetInformationStatus.DRAFT,
|
|
AssetOperationalStatus.UNKNOWN,
|
|
AssetDataOrigin.MANUAL,
|
|
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
|
|
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
|
family
|
|
? `Clasificación técnica: ${family.code} · ${family.name}`
|
|
: yacimientoContext
|
|
? `Tipo de concesión: ${yacimientoContext.concessionName}`
|
|
: null,
|
|
principal.userId,
|
|
])) as Array<{ id: string }>;
|
|
const id = inserted[0]?.id;
|
|
if (!id) throw new Error('No se pudo crear el registro');
|
|
|
|
if (dto.kind === 'EMPRESA') {
|
|
await manager.query(`
|
|
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
|
|
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
|
|
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
|
|
`,[id,dto.name,principal.userId]);
|
|
}
|
|
if (dto.kind === 'YACIMIENTO' && operationalAreaId && operatorCompanyId && concessionTypeId) {
|
|
await this.ensureCompatibilityProjection(
|
|
manager,
|
|
operationalAreaId,
|
|
operatorCompanyId,
|
|
concessionTypeId,
|
|
yacimientoContext?.concessionName ?? 'Concesión',
|
|
);
|
|
}
|
|
|
|
const versionNumber = await this.history.capture(
|
|
manager,id,AssetVersionChangeType.CREATED,principal,request,
|
|
);
|
|
await manager.query(`
|
|
INSERT INTO asset_context_history (
|
|
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
|
change_reason,asset_version_number,source,request_id,created_by
|
|
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
|
`, [
|
|
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
|
|
dto.kind === 'YACIMIENTO'
|
|
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
|
|
: dto.kind === 'EMPRESA'
|
|
? 'Alta manual de Empresa independiente'
|
|
: 'Alta manual de estructura de Inventario',
|
|
versionNumber,request.requestId,principal.userId,
|
|
]);
|
|
const created = await this.loadView(manager, id);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_CREATED,
|
|
entityType: 'asset',entityId: id,
|
|
afterData: created as unknown as Record<string, unknown>,
|
|
metadata: {
|
|
versionNumber,inventoryStructureKind: dto.kind,
|
|
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
|
|
operatorCompanyId,
|
|
concessionTypeId,
|
|
},
|
|
}, manager);
|
|
return created;
|
|
});
|
|
} catch (error) {
|
|
if (isUniqueViolation(error)) throw new ConflictException({
|
|
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
|
const sql = kind === 'EMPRESA'
|
|
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
|
|
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
|
|
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
|
|
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
|
|
if (!rows[0]) throw new ConflictException({
|
|
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
|
|
});
|
|
return rows[0];
|
|
}
|
|
|
|
private async requireParent(
|
|
manager: EntityManager,
|
|
kind: InventoryStructureKind,
|
|
parentId: string | null,
|
|
): Promise<ParentRow | null> {
|
|
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
|
if (!expectedType) {
|
|
if (parentId) throw new BadRequestException({
|
|
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
|
|
message: kind === 'EMPRESA'
|
|
? 'Una Empresa es un maestro independiente y no puede tener padre'
|
|
: 'Un Departamento es un registro raíz y no puede tener padre',
|
|
});
|
|
return null;
|
|
}
|
|
if (!parentId) throw new BadRequestException({
|
|
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
|
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
|
});
|
|
const rows = (await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
|
asset.inventory_family_id AS "inventoryFamilyId",
|
|
asset.operational_area_id AS "operationalAreaId",
|
|
asset.operator_company_id AS "operatorCompanyId"
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
|
FOR KEY SHARE
|
|
`, [parentId])) as ParentRow[];
|
|
const parent = rows[0];
|
|
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
|
|
if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
|
|
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
|
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
|
|
});
|
|
return parent;
|
|
}
|
|
|
|
private async requireYacimientoContext(
|
|
manager: EntityManager,
|
|
dto: CreateInventoryStructureDto,
|
|
): Promise<YacimientoContext | null> {
|
|
if (dto.kind !== 'YACIMIENTO') {
|
|
if (dto.operatorCompanyId || dto.concessionTypeId) {
|
|
throw new BadRequestException({
|
|
code: 'INVENTORY_YACIMIENTO_CONTEXT_NOT_ALLOWED',
|
|
message: 'Empresa relacionada y Tipo de concesión sólo corresponden al Yacimiento',
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
if (!dto.operatorCompanyId) {
|
|
throw new BadRequestException({
|
|
code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
|
|
message: 'Seleccioná la Empresa relacionada del Yacimiento',
|
|
});
|
|
}
|
|
if (!dto.concessionTypeId) {
|
|
throw new BadRequestException({
|
|
code: 'INVENTORY_YACIMIENTO_CONCESSION_REQUIRED',
|
|
message: 'Seleccioná el Tipo de concesión del Yacimiento',
|
|
});
|
|
}
|
|
const companyRows = (await manager.query(`
|
|
SELECT asset.id
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE asset.id=$1::uuid
|
|
AND type.operational_role='COMPANY'
|
|
AND type.is_active=true
|
|
AND asset.information_status<>'INACTIVE'
|
|
FOR KEY SHARE
|
|
`,[dto.operatorCompanyId])) as IdRow[];
|
|
if (!companyRows[0]) throw new BadRequestException({
|
|
code: 'INVENTORY_YACIMIENTO_COMPANY_INVALID',
|
|
message: 'La Empresa relacionada seleccionada no es válida',
|
|
});
|
|
const concessionRows = (await manager.query(`
|
|
SELECT id,name
|
|
FROM concession_types
|
|
WHERE id=$1::uuid AND is_active=true
|
|
FOR KEY SHARE
|
|
`,[dto.concessionTypeId])) as Array<{id:string;name:string}>;
|
|
if (!concessionRows[0]) throw new BadRequestException({
|
|
code: 'INVENTORY_YACIMIENTO_CONCESSION_INVALID',
|
|
message: 'El Tipo de concesión seleccionado no es válido',
|
|
});
|
|
return {
|
|
companyId: dto.operatorCompanyId,
|
|
concessionTypeId: dto.concessionTypeId,
|
|
concessionName: concessionRows[0].name,
|
|
};
|
|
}
|
|
|
|
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
|
|
if (parent.typeCode.toLowerCase()==='area') return parent.id;
|
|
if (parent.operationalAreaId) return parent.operationalAreaId;
|
|
const rows = (await manager.query(`
|
|
WITH RECURSIVE lineage AS (
|
|
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
|
|
UNION ALL
|
|
SELECT parent.id,parent.parent_id,parent.asset_type_id
|
|
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
|
|
)
|
|
SELECT lineage.id
|
|
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
|
|
WHERE type.operational_role='AREA'
|
|
LIMIT 1
|
|
`,[parent.id])) as IdRow[];
|
|
const areaId=rows[0]?.id;
|
|
if (!areaId) throw new ConflictException({
|
|
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
|
|
message:'La ubicación seleccionada no pertenece a un Área válida',
|
|
});
|
|
return areaId;
|
|
}
|
|
|
|
private async requireFamily(
|
|
manager: EntityManager,
|
|
kind: InventoryStructureKind,
|
|
familyId: string | null,
|
|
parent: ParentRow | null,
|
|
): Promise<FamilyRow | null> {
|
|
const expectedLevel = FAMILY_LEVEL_BY_KIND[kind];
|
|
if (!expectedLevel) {
|
|
if (familyId) throw new BadRequestException({
|
|
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
|
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
|
|
});
|
|
return null;
|
|
}
|
|
if (!familyId) throw new BadRequestException({
|
|
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
|
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
|
|
});
|
|
const rows = (await manager.query(`
|
|
SELECT family.id,family.code,family.name,family.level,
|
|
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
|
|
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
|
|
FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
|
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
|
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
|
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
|
|
FROM inventory_families family
|
|
WHERE family.id=$1::uuid AND family.is_active=true
|
|
LIMIT 1
|
|
`, [familyId])) as FamilyRow[];
|
|
const family = rows[0];
|
|
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
|
|
if (family.level !== expectedLevel) throw new BadRequestException({
|
|
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
|
message: 'La clasificación técnica no corresponde al nivel seleccionado',
|
|
});
|
|
if (kind === 'SUBINSTALACION') {
|
|
if (!parent?.inventoryFamilyId) throw new BadRequestException({
|
|
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
|
|
message:'La Instalación padre debe tener una clasificación técnica válida',
|
|
});
|
|
const [compatible]=(await manager.query(`
|
|
SELECT 1 AS ok FROM inventory_family_parent_rules
|
|
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
|
|
LIMIT 1
|
|
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
|
|
if (!compatible) throw new BadRequestException({
|
|
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
|
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
|
|
});
|
|
}
|
|
return family;
|
|
}
|
|
|
|
private async ensureCompatibilityProjection(
|
|
manager: EntityManager,
|
|
areaId: string,
|
|
companyId: string,
|
|
concessionTypeId: string,
|
|
concessionName: string,
|
|
): Promise<void> {
|
|
const companyProjection = await manager.query(`
|
|
SELECT id FROM area_company_relations
|
|
WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_to IS NULL
|
|
LIMIT 1
|
|
`,[areaId,companyId]);
|
|
if (!companyProjection[0]) {
|
|
await manager.query(`
|
|
INSERT INTO area_company_relations(area_id,company_id,relation_role,valid_from,start_reason)
|
|
VALUES($1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,'Proyección derivada de Yacimiento')
|
|
`,[areaId,companyId]);
|
|
}
|
|
const rightType = concessionName.toLocaleLowerCase('es-AR').includes('explor')
|
|
? 'EXPLORATION_PERMIT'
|
|
: 'EXPLOITATION_CONCESSION';
|
|
const rightProjection = await manager.query(`
|
|
SELECT id FROM area_legal_rights
|
|
WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND status='ACTIVE'
|
|
LIMIT 1
|
|
`,[areaId,rightType]);
|
|
if (!rightProjection[0]) {
|
|
await manager.query(`
|
|
INSERT INTO area_legal_rights(area_id,right_type,name,status,notes)
|
|
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4)
|
|
`,[areaId,rightType,`${concessionName} · proyección de Yacimiento`,`Tipo de concesión canónico: ${concessionTypeId}`]);
|
|
}
|
|
}
|
|
|
|
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
|
const prefix = kind === 'EMPRESA' ? 'EMP'
|
|
: kind === 'DEPARTAMENTO' ? 'DEP'
|
|
: kind === 'AREA' ? 'AREA'
|
|
: kind === 'YACIMIENTO' ? 'YAC'
|
|
: kind === 'INSTALACION' ? 'INST'
|
|
: 'SUB';
|
|
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
|
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
|
|
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
|
}
|
|
|
|
private async loadView(manager: EntityManager, id: string) {
|
|
const rows = await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
|
|
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
|
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
|
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
|
|
CASE WHEN concession.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',concession.id,'code',concession.code,'name',concession.name) END AS "concessionType",
|
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
|
'informationLabels',family.information_labels
|
|
) END AS "inventoryFamily",
|
|
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
|
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
|
LEFT JOIN assets company ON company.id=asset.operator_company_id
|
|
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
|
LEFT JOIN concession_types concession ON concession.id=asset.concession_type_id
|
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
|
WHERE asset.id=$1::uuid
|
|
`, [id]);
|
|
return rows[0];
|
|
}
|
|
}
|