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 ParentRow = { id: string; code: string; name: string; typeCode: string; inventoryFamilyId: string | null; operationalAreaId: string | null; operatorCompanyId: string | null; }; type FamilyRow = { id: string; code: string; name: string; level: 'INSTALLATION' | 'SUBINSTALLATION'; }; const TYPE_CODE_BY_KIND: Record, string> = { DEPARTAMENTO: 'departamento', AREA: 'area', YACIMIENTO: 'yacimiento', INSTALACION: 'instalacion', SUBINSTALACION: 'subinstalacion', }; const PARENT_TYPE_BY_KIND: Record = { EMPRESA: null, DEPARTAMENTO: null, AREA: 'departamento', YACIMIENTO: 'area', INSTALACION: 'yacimiento', SUBINSTALACION: 'instalacion', }; @Injectable() export class SimpleInventoryStructureService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly history: AssetHistoryService, ) {} 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); let operationalAreaId: string | null = null; let operatorCompanyId: string | null = null; if (dto.kind === 'YACIMIENTO') { if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' }); if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' }); await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId); operationalAreaId = parent.id; operatorCompanyId = dto.operatorCompanyId; } else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') { operationalAreaId = parent?.operationalAreaId ?? null; operatorCompanyId = parent?.operatorCompanyId ?? null; if (!operationalAreaId || !operatorCompanyId) { throw new ConflictException({ code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING', message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas', }); } } else if (dto.operatorCompanyId || dto.concessionTypeId) { throw new BadRequestException({ code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED', message: 'Empresa operadora sólo corresponde al Yacimiento', }); } const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const rows = (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,NULL,$5::uuid, $6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status, $10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid ) RETURNING id `, [ type.id, parent?.id ?? null, operationalAreaId, operatorCompanyId, family?.id ?? null, generatedCode, dto.name, AssetInformationStatus.DRAFT, AssetOperationalStatus.UNKNOWN, AssetDataOrigin.MANUAL, dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario', `inventory-simple:${dto.kind.toLowerCase()}`, family ? `Tipo: ${family.name}` : null, principal.userId, ])) as Array<{ id: string }>; const id = rows[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]); } 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 de Yacimiento con Empresa operadora vinculada al Área' : 'Alta simple 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 Record, metadata: { versionNumber, inventoryStructureKind: dto.kind, inventoryFamilyId: family?.id ?? null, inventoryFamilyCode: family?.code ?? null, operatorCompanyId, simpleInventoryFlow: true, }, }, 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 { 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]]; 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 { const expectedType = PARENT_TYPE_BY_KIND[kind]; if (!expectedType) { if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` }); return null; } if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` }); const rows = (await manager.query(` SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode", asset.inventory_family_id AS "inventoryFamilyId", CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END 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 OF asset `, [parentId])) as ParentRow[]; const parent = rows[0]; if (!parent || parent.typeCode !== expectedType) { throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` }); } return parent; } private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise { const rows = await manager.query(` SELECT 1 FROM area_company_relations relation JOIN assets company ON company.id=relation.company_id JOIN asset_types company_type ON company_type.id=company.asset_type_id WHERE relation.area_id=$1::uuid AND relation.company_id=$2::uuid AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL AND company.information_status<>'INACTIVE' AND company_type.operational_role='COMPANY' AND company_type.is_active=true LIMIT 1 FOR KEY SHARE OF relation `, [areaId, companyId]); if (!rows[0]) { throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA', message: 'La Empresa seleccionada no está vinculada como explotadora del Área', }); } } private async requireFamily( manager: EntityManager, kind: InventoryStructureKind, familyId: string | null, parent: ParentRow | null, ): Promise { if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') { if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' }); return null; } if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` }); const rows = (await manager.query(` SELECT id,code,name,level::text AS level FROM inventory_families WHERE id=$1::uuid AND is_active=true LIMIT 1 FOR KEY SHARE `, [familyId])) as FamilyRow[]; const family = rows[0]; if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' }); const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION'; if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' }); if (kind === 'SUBINSTALACION') { if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' }); const compatible = await manager.query(` SELECT 1 FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid LIMIT 1 `, [family.id, parent.inventoryFamilyId]); if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' }); } return family; } 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 family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',family.id,'code',family.code,'name',family.name,'level',family.level) 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 inventory_families family ON family.id=asset.inventory_family_id WHERE asset.id=$1::uuid `, [id]); return rows[0]; } }