diff --git a/api-v3/src/asset-master/simple-inventory-structure.service.ts b/api-v3/src/asset-master/simple-inventory-structure.service.ts new file mode 100644 index 0000000..78604e1 --- /dev/null +++ b/api-v3/src/asset-master/simple-inventory-structure.service.ts @@ -0,0 +1,323 @@ +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]; + } +}