diff --git a/api-v3/src/asset-master/inventory-family-catalog.service.ts b/api-v3/src/asset-master/inventory-family-catalog.service.ts index 0c76928..c6d7c93 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.service.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.service.ts @@ -1,28 +1,71 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction } from '../database/entities'; +import type { + CreateInventoryFamilyDto, + ReplaceInventoryFamilyFindingsDto, + UpdateInventoryFamilyDto, +} from './dto/inventory-family-admin.dto'; + +type FamilyRow = { + id: string; + code: string; + name: string; + level: 'INSTALLATION' | 'SUBINSTALLATION'; + informationLabels: string[]; + sourceReference: string | null; + isActive: boolean; + parentFamilyId: string | null; + parentFamilyCode: string | null; + parentFamilyName: string | null; + assetCount: number; + findingCount: number; + findingItemIds: string[]; +}; @Injectable() export class InventoryFamilyCatalogService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listAdmin() { + const data = await this.dataSource.query(` + SELECT + family.id,family.code,family.name,family.level, + family.information_labels AS "informationLabels", + family.source_reference AS "sourceReference", + family.is_active AS "isActive", + parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName", + (SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount", + (SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount", + COALESCE(( + SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id) + FROM finding_catalog_item_inventory_families mapping + JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id + WHERE mapping.inventory_family_id=family.id + ),'[]'::jsonb) AS "findingItemIds" + 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 + ORDER BY family.level,family.is_active DESC, + COALESCE(parent.name,''),family.name,family.code + `) as FamilyRow[]; + return { data }; + } async findings(familyId: string) { - const [family] = await this.dataSource.query(` - SELECT id,code,name,level,information_labels AS "informationLabels" - FROM inventory_families - WHERE id=$1::uuid AND is_active=true - `, [familyId]) as Array<{ - id: string; - code: string; - name: string; - level: string; - informationLabels: string[]; - }>; - if (!family) { - throw new NotFoundException({ - code: 'INVENTORY_FAMILY_NOT_FOUND', - message: 'La familia técnica no existe', - }); - } + const family = await this.family(familyId, false); const items = await this.dataSource.query(` SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", @@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService { `, [familyId]); return { family, items, count: items.length }; } + + async create( + dto: CreateInventoryFamilyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null); + const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`; + const [inserted] = (await manager.query(` + INSERT INTO inventory_families( + code,name,level,legacy_type_code,information_labels,source_reference,is_active + ) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true) + RETURNING id + `,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>; + if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario'); + if (parentId) { + await manager.query(` + INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) + VALUES ($1::uuid,$2::uuid) + `,[inserted.id,parentId]); + } + const created = await this.family(inserted.id,false,manager); + await this.audit.record({ + ...administrationAuditContext(principal,request), + action: AuditAction.ASSET_UPDATED, + entityType: 'inventory_family', + entityId: inserted.id, + afterData: created as unknown as Record, + metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' }, + },manager); + return created; + }); + } + + async update( + familyId: string, + dto: UpdateInventoryFamilyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const before = await this.family(familyId,false,manager,true); + const nextParentId = dto.parentFamilyId === undefined + ? before.parentFamilyId + : dto.parentFamilyId; + const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId); + await manager.query(` + UPDATE inventory_families SET + name=COALESCE($2::varchar,name), + information_labels=COALESCE($3::jsonb,information_labels), + is_active=COALESCE($4::boolean,is_active), + updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `,[ + familyId, + dto.name ?? null, + dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)), + dto.isActive ?? null, + ]); + if (before.level==='SUBINSTALLATION') { + await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]); + if (parentId) { + await manager.query(` + INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) + VALUES ($1::uuid,$2::uuid) + `,[familyId,parentId]); + } + } + const after = await this.family(familyId,false,manager); + await this.audit.record({ + ...administrationAuditContext(principal,request), + action: AuditAction.ASSET_UPDATED, + entityType: 'inventory_family', + entityId: familyId, + beforeData: before as unknown as Record, + afterData: after as unknown as Record, + metadata: { operation:'INVENTORY_FAMILY_UPDATED' }, + },manager); + return after; + }); + } + + async replaceFindings( + familyId: string, + dto: ReplaceInventoryFamilyFindingsDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const family = await this.family(familyId,false,manager,true); + const uniqueIds=[...new Set(dto.itemIds)]; + if (uniqueIds.length) { + const [count] = (await manager.query(` + SELECT COUNT(*)::integer AS total + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true + `,[uniqueIds])) as Array<{total:number}>; + if (Number(count?.total ?? 0)!==uniqueIds.length) { + throw new BadRequestException({ + code:'INVENTORY_FAMILY_FINDING_INVALID', + message:'Uno o más Hallazgos elegidos no están activos en el catálogo', + }); + } + } + const beforeIds=family.findingItemIds; + await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]); + if (uniqueIds.length) { + await manager.query(` + INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) + SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id) + ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING + `,[uniqueIds,familyId]); + } + const after=await this.family(familyId,false,manager); + await this.audit.record({ + ...administrationAuditContext(principal,request), + action: AuditAction.ASSET_UPDATED, + entityType:'inventory_family_findings', + entityId:familyId, + beforeData:{ itemIds:beforeIds }, + afterData:{ itemIds:after.findingItemIds }, + metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason }, + },manager); + return this.findingsWithManager(manager,familyId); + }); + } + + private async findingsWithManager(manager:EntityManager,familyId:string) { + const family=await this.family(familyId,false,manager); + const items=await manager.query(` + SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, + item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", + category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName" + FROM finding_catalog_item_inventory_families mapping + JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id + JOIN finding_categories category ON category.id=item.category_id + WHERE mapping.inventory_family_id=$1::uuid + AND item.is_active=true AND category.is_active=true + ORDER BY category.sort_order,item.source_number,item.title + `,[familyId]); + return {family,items,count:items.length}; + } + + private async family( + familyId:string, + activeOnly:boolean, + manager:EntityManager=this.dataSource.manager, + lock=false, + ):Promise { + const rows=(await manager.query(` + SELECT family.id,family.code,family.name,family.level, + family.information_labels AS "informationLabels", + family.source_reference AS "sourceReference",family.is_active AS "isActive", + parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName", + (SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount", + (SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount", + COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id) + FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds" + 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 ${activeOnly ? 'AND family.is_active=true' : ''} + ${lock ? 'FOR UPDATE OF family' : ''} + `,[familyId])) as FamilyRow[]; + if (!rows[0]) throw new NotFoundException({ + code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe', + }); + return rows[0]; + } + + private async validateParent( + manager:EntityManager, + level:'INSTALLATION'|'SUBINSTALLATION', + parentFamilyId:string|null, + ownId:string|null, + ):Promise { + if (level==='INSTALLATION') { + if (parentFamilyId) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', + message:'Una clasificación de Instalación no tiene clasificación padre', + }); + return null; + } + if (!parentFamilyId) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_REQUIRED', + message:'Una Subinstalación debe pertenecer a un tipo de Instalación', + }); + if (parentFamilyId===ownId) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre', + }); + const rows=(await manager.query(` + SELECT id FROM inventory_families + WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true + LIMIT 1 + `,[parentFamilyId])) as Array<{id:string}>; + if (!rows[0]) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_INVALID', + message:'La Subinstalación debe vincularse a una clasificación de Instalación activa', + }); + return parentFamilyId; + } + + private cleanLabels(labels:string[]):string[] { + const unique=new Map(); + for (const raw of labels) { + const clean=raw.trim(); + if (!clean) continue; + const identity=clean.toLocaleLowerCase('es-AR'); + if (!unique.has(identity)) unique.set(identity,clean); + } + if (unique.size>100) throw new ConflictException({ + code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información', + }); + return [...unique.values()]; + } }