diff --git a/api-v3/src/asset-master/inventory-structure.service.ts b/api-v3/src/asset-master/inventory-structure.service.ts new file mode 100644 index 0000000..e5eae78 --- /dev/null +++ b/api-v3/src/asset-master/inventory-structure.service.ts @@ -0,0 +1,333 @@ +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 FamilyRow = { + id: string; + code: string; + name: string; + level: 'INSTALLATION' | 'SUBINSTALLATION'; + legacyTypeCode: string | null; + informationLabels: string[]; + parentFamilyId: string | null; + parentFamilyCode: string | null; + parentFamilyName: string | null; +}; +type ParentRow = { + id: string; + code: string; + name: string; + typeCode: string; + operationalAreaId: string | null; + operatorCompanyId: string | null; + inventoryFamilyId: string | null; +}; + +const TYPE_CODE_BY_KIND: Record = { + AREA: 'area', + YACIMIENTO: 'yacimiento', + INSTALACION: 'instalacion', + SUBINSTALACION: 'subinstalacion', +}; +const PARENT_TYPE_BY_KIND: Record = { + AREA: null, + YACIMIENTO: 'area', + INSTALACION: 'yacimiento', + SUBINSTALACION: 'instalacion', +}; +const FAMILY_LEVEL_BY_KIND: Partial> = { + 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 ('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 + `)) as StructureTypeRow[]; + if (types.length !== 4) { + throw new ConflictException({ + code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', + message: 'La estructura del Inventario todavía no está completamente configurada', + }); + } + 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", + parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" + FROM inventory_families family + LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id + LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id + WHERE family.is_active=true + ORDER BY family.level,family.name,family.code + `)) as FamilyRow[]; + return { + 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 }, + ], + installationFamilies: families.filter((item) => item.level==='INSTALLATION'), + subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), + }; + } + + 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 code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); + const operationalAreaId = parent?.operationalAreaId ?? null; + const operatorCompanyId = parent?.operatorCompanyId ?? null; + + const inserted = (await manager.query(` + INSERT INTO assets ( + asset_type_id,parent_id,operational_area_id,operator_company_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::varchar,$7::varchar,$8::varchar,$9::text,$10::varchar,$11::varchar, + $12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid + ) RETURNING id + `, [ + type.id, + parent?.id ?? null, + operationalAreaId, + operatorCompanyId, + family?.id ?? null, + code, + dto.name, + dto.commonName ?? null, + dto.description ?? null, + AssetInformationStatus.DRAFT, + AssetOperationalStatus.IN_SERVICE, + AssetDataOrigin.MANUAL, + 'Inventario estructural F3.1', + `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'); + + 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, + 'Alta guiada de Inventario estructural F3.1', + 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, + metadata: { + versionNumber, + inventoryStructureKind: dto.kind, + inventoryFamilyId: family?.id ?? null, + inventoryFamilyCode: family?.code ?? null, + }, + }, manager); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException({ + code: 'ASSET_CODE_ALREADY_EXISTS', + message: 'Ya existe un registro de Inventario con ese código', + }); + } + throw error; + } + } + + 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[]; + 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_AREA_MUST_BE_ROOT', + message: 'Un Área se crea como 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.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 + 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 Área → Yacimiento → Instalación → Subinstalación`, + }); + } + return parent; + } + + private async requireFamily( + manager: EntityManager, + kind: InventoryStructureKind, + familyId: string | null, + parent: ParentRow | null, + ): Promise { + const expectedLevel = FAMILY_LEVEL_BY_KIND[kind]; + if (!expectedLevel) { + if (familyId) throw new BadRequestException({ + code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', + message: 'Área y Yacimiento no llevan familia técnica', + }); + return null; + } + if (!familyId) throw new BadRequestException({ + code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', + message: `Elegí la familia 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", + parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" + FROM inventory_families family + LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id + LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id + 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 familia técnica no existe' }); + if (family.level !== expectedLevel) throw new BadRequestException({ + code: 'INVENTORY_FAMILY_LEVEL_INVALID', + message: 'La familia técnica no corresponde al nivel seleccionado', + }); + if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) { + throw new BadRequestException({ + code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', + message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada', + }); + } + return family; + } + + private generatedCode(kind: InventoryStructureKind, name: string): string { + const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; + 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 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 inventory_families family ON family.id=asset.inventory_family_id + WHERE asset.id=$1::uuid + `, [id]); + return rows[0]; + } +}