diff --git a/api-v3/src/asset-master/inventory-structure.service.ts b/api-v3/src/asset-master/inventory-structure.service.ts index dbf0121..196f427 100644 --- a/api-v3/src/asset-master/inventory-structure.service.ts +++ b/api-v3/src/asset-master/inventory-structure.service.ts @@ -36,18 +36,17 @@ type ParentRow = { code: string; name: string; typeCode: string; - operationalAreaId: string | null; - operatorCompanyId: string | null; inventoryFamilyId: string | null; }; -const TYPE_CODE_BY_KIND: Record = { +const TYPE_CODE_BY_KIND: Record, string> = { AREA: 'area', YACIMIENTO: 'yacimiento', INSTALACION: 'instalacion', SUBINSTALACION: 'subinstalacion', }; const PARENT_TYPE_BY_KIND: Record = { + EMPRESA: null, AREA: null, YACIMIENTO: 'area', INSTALACION: 'yacimiento', @@ -70,18 +69,29 @@ export class InventoryStructureService { const types = (await this.dataSource.query(` SELECT id,code,name FROM asset_types - WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion') - AND is_active=true - ORDER BY CASE lower(code) - WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2 - WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END + WHERE ( + lower(code) IN ('area','yacimiento','instalacion','subinstalacion') + OR operational_role='COMPANY' + ) AND is_active=true + ORDER BY CASE + WHEN operational_role='COMPANY' THEN 0 + WHEN lower(code)='area' THEN 1 + WHEN lower(code)='yacimiento' THEN 2 + WHEN lower(code)='instalacion' THEN 3 + WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END `)) as StructureTypeRow[]; - if (types.length !== 4) { + const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase())); + 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 || !area || !yacimiento || !instalacion || !subinstalacion) { throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', - message: 'La estructura del Inventario todavía no está completamente configurada', + 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", @@ -93,12 +103,16 @@ export class InventoryStructureService { WHERE family.is_active=true ORDER BY family.level,family.name,family.code `)) as FamilyRow[]; + return { + independentMasters: [ + { kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false }, + ], levels: [ - { kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false }, - { kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false }, - { kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true }, - { kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true }, + { kind: 'AREA', label: 'Área', type: area, parentKind: null, 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 }, ], installationFamilies: families.filter((item) => item.level==='INSTALLATION'), subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), @@ -107,7 +121,7 @@ export class InventoryStructureService { async parents(kindValue: string, search?: string) { const kind = kindValue.toUpperCase() as InventoryStructureKind; - if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') { + if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') { throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', message: 'El nivel indicado no requiere un registro padre', @@ -154,9 +168,8 @@ export class InventoryStructureService { 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 code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); - const operationalAreaId = parent?.operationalAreaId ?? null; - const operatorCompanyId = parent?.operatorCompanyId ?? null; + const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); + const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null; const inserted = (await manager.query(` INSERT INTO assets ( @@ -164,30 +177,37 @@ export class InventoryStructureService { 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::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status, - $12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid + $1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid, + $5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status, + $11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid ) RETURNING id `, [ type.id, parent?.id ?? null, operationalAreaId, - operatorCompanyId, family?.id ?? null, - code, + generatedCode, dto.name, dto.commonName ?? null, dto.description ?? null, AssetInformationStatus.DRAFT, AssetOperationalStatus.UNKNOWN, AssetDataOrigin.MANUAL, - 'Inventario estructural F3.1', - `inventory-structure:${dto.kind.toLowerCase()}`, + dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5', + dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, family ? `Familia técnica: ${family.code} · ${family.name}` : null, principal.userId, ])) as Array<{ id: string }>; const id = inserted[0]?.id; - if (!id) throw new Error('No se pudo crear el registro estructural'); + 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, @@ -200,13 +220,12 @@ export class InventoryStructureService { 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) + ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7) `, [ id, parent?.id ?? null, operationalAreaId, - operatorCompanyId, - 'Alta guiada de Inventario estructural F3.1', + dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5', versionNumber, request.requestId, principal.userId, @@ -223,6 +242,7 @@ export class InventoryStructureService { inventoryStructureKind: dto.kind, inventoryFamilyId: family?.id ?? null, inventoryFamilyCode: family?.code ?? null, + operatorOwnership: false, }, }, manager); return created; @@ -231,7 +251,7 @@ export class InventoryStructureService { if (isUniqueViolation(error)) { throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', - message: 'Ya existe un registro de Inventario con ese código', + message: 'Ya existe un registro con ese código', }); } throw error; @@ -239,10 +259,11 @@ export class InventoryStructureService { } private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise { - const rows = (await manager.query(` - SELECT id,code,name FROM asset_types - WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1 - `, [TYPE_CODE_BY_KIND[kind]])) as 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]]; + const rows = (await manager.query(sql,params)) as StructureTypeRow[]; if (!rows[0]) { throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', @@ -261,8 +282,10 @@ export class InventoryStructureService { if (!expectedType) { if (parentId) { throw new BadRequestException({ - code: 'INVENTORY_AREA_MUST_BE_ROOT', - message: 'Un Área se crea como registro raíz y no puede tener padre', + code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', + message: kind === 'EMPRESA' + ? 'Una Empresa es un maestro independiente y no puede tener padre' + : 'Un Área es un registro raíz y no puede tener padre', }); } return null; @@ -275,8 +298,6 @@ export class InventoryStructureService { } const rows = (await manager.query(` SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", - asset.operational_area_id AS "operationalAreaId", - asset.operator_company_id AS "operatorCompanyId", asset.inventory_family_id AS "inventoryFamilyId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id @@ -294,6 +315,30 @@ export class InventoryStructureService { return parent; } + private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise { + if (parent.typeCode.toLowerCase()==='area') return parent.id; + 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, @@ -304,7 +349,7 @@ export class InventoryStructureService { if (!expectedLevel) { if (familyId) throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', - message: 'Área y Yacimiento no llevan familia técnica', + message: 'Empresa, Área y Yacimiento no llevan familia técnica', }); return null; } @@ -339,7 +384,7 @@ export class InventoryStructureService { } private generatedCode(kind: InventoryStructureKind, name: string): string { - const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; + const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; const readable = name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') @@ -356,6 +401,7 @@ export class InventoryStructureService { 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 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 @@ -364,9 +410,12 @@ export class InventoryStructureService { 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 inventory_families family ON family.id=asset.inventory_family_id WHERE asset.id=$1::uuid `, [id]); return rows[0]; } } + +type IdRow = { id: string };