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'; import type { CreateInventoryFamilyAttributeDto, InventoryFamilyAttributeDataType, UpdateInventoryFamilyAttributeDto, } from './dto/inventory-family-attribute.dto'; type FamilyParent = { id: string; code: string; name: string }; type FamilyRow = { id: string; code: string; name: string; level: 'INSTALLATION' | 'SUBINSTALLATION'; informationLabels: string[]; sourceReference: string | null; isActive: boolean; parentFamilyIds: string[]; parentFamilies: FamilyParent[]; assetCount: number; findingCount: number; findingItemIds: string[]; technicalAttributeCount: number; }; type AttributeRow = { id: string; inventoryFamilyId: string; code: string; name: string; dataType: InventoryFamilyAttributeDataType; isRequired: boolean; isActive: boolean; unit: string | null; options: string[] | null; sortOrder: number; }; @Injectable() export class InventoryFamilyCatalogService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async listAdmin() { const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[]; return { data }; } async findings(familyId: string) { 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", 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 }; } async attributes(familyId: string) { const family = await this.family(familyId, false); const items = await this.attributeRows(this.dataSource.manager, familyId); return { family, items, count: items.length }; } async create( dto: CreateInventoryFamilyDto, principal: AuthPrincipal, request: RequestWithContext, ) { return this.dataSource.transaction(async (manager) => { const parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], 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:F6',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'); await this.replaceParents(manager, inserted.id, parentIds); 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:F6' }, },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 nextParentIds = dto.parentFamilyIds === undefined ? before.parentFamilyIds : dto.parentFamilyIds; const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId); if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) { await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds); } 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 (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds); 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 createAttribute( familyId: string, dto: CreateInventoryFamilyAttributeDto, principal: AuthPrincipal, request: RequestWithContext, ) { return this.dataSource.transaction(async (manager) => { await this.family(familyId,false,manager,true); const options = this.attributeOptions(dto.dataType,dto.options); try { const [created] = (await manager.query(` INSERT INTO inventory_family_attribute_definitions( inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by ) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid) RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType", is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder" `,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null, options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[]; if (!created) throw new Error('No se pudo crear el campo técnico'); await this.audit.record({ ...administrationAuditContext(principal,request), action: AuditAction.ASSET_UPDATED, entityType:'inventory_family_attribute',entityId:created.id, afterData:created as unknown as Record, metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId}, },manager); return created; } catch (error) { if (this.isUniqueViolation(error)) throw new ConflictException({ code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS', message:'Ya existe un campo técnico con ese código en la clasificación', }); throw error; } }); } async updateAttribute( familyId: string, attributeId: string, dto: UpdateInventoryFamilyAttributeDto, principal: AuthPrincipal, request: RequestWithContext, ) { return this.dataSource.transaction(async (manager) => { await this.family(familyId,false,manager,true); const before = await this.attribute(manager,familyId,attributeId,true); const nextType = dto.dataType ?? before.dataType; const nextOptions = dto.options === undefined ? this.attributeOptions(nextType,before.options ?? undefined) : this.attributeOptions(nextType,dto.options ?? undefined); const [after] = (await manager.query(` UPDATE inventory_family_attribute_definitions SET name=COALESCE($3::varchar,name), data_type=COALESCE($4::asset_attribute_data_type,data_type), is_required=COALESCE($5::boolean,is_required), is_active=COALESCE($6::boolean,is_active), unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END, options=$9::jsonb, sort_order=COALESCE($10::integer,sort_order), updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid AND inventory_family_id=$2::uuid RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType", is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder" `,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null, dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions), dto.sortOrder ?? null,principal.userId])) as AttributeRow[]; if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'}); await this.audit.record({ ...administrationAuditContext(principal,request), action: AuditAction.ASSET_UPDATED, entityType:'inventory_family_attribute',entityId:attributeId, beforeData:before as unknown as Record,afterData:after as unknown as Record, metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId}, },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 familySelect(suffix:string) { return ` SELECT family.id,family.code,family.name,family.level, family.information_labels AS "informationLabels", family.source_reference AS "sourceReference",family.is_active AS "isActive", COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code) FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds", COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code) FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies", (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", (SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount" FROM inventory_families family ${suffix} `; } private async family( familyId:string, activeOnly:boolean, manager:EntityManager=this.dataSource.manager, lock=false, ):Promise { const lockClause=lock ? 'FOR UPDATE OF family' : ''; const rows=(await manager.query(this.familySelect(` WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''} ${lockClause} `),[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 validateParents( manager:EntityManager, level:'INSTALLATION'|'SUBINSTALLATION', requestedIds:string[], ownId:string|null, ):Promise { const ids=[...new Set(requestedIds)]; if (level==='INSTALLATION') { if (ids.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', message:'Una clasificación de Instalación no lleva compatibilidades padre', }); return []; } if (!ids.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_REQUIRED', message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación', }); if (ownId && ids.includes(ownId)) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma', }); const [count]=(await manager.query(` SELECT COUNT(*)::integer AS total FROM inventory_families WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true `,[ids])) as Array<{total:number}>; if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_INVALID', message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas', }); return ids; } private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) { await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]); if (parentIds.length) await manager.query(` INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id) ON CONFLICT (child_family_id,parent_family_id) DO NOTHING `,[familyId,parentIds]); } private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) { const rows=await manager.query(` SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total FROM assets child JOIN assets parent ON parent.id=child.parent_id WHERE child.inventory_family_id=$1::uuid AND parent.inventory_family_id IS NOT NULL AND NOT (parent.inventory_family_id=ANY($2::uuid[])) GROUP BY child.name,parent.name ORDER BY total DESC LIMIT 1 `,[childFamilyId,nextParentIds]); if (rows[0]) throw new ConflictException({ code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE', message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`, }); } private async attributeRows(manager:EntityManager,familyId:string):Promise { return manager.query(` SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType", is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder" FROM inventory_family_attribute_definitions WHERE inventory_family_id=$1::uuid ORDER BY is_active DESC,sort_order,name,code `,[familyId]) as Promise; } private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise { const rows=(await manager.query(` SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType", is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder" FROM inventory_family_attribute_definitions WHERE id=$1::uuid AND inventory_family_id=$2::uuid ${lock ? 'FOR UPDATE' : ''} `,[attributeId,familyId])) as AttributeRow[]; if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'}); return rows[0]; } private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null { if (type!=='SELECT') return null; const values=[...new Map((raw ?? []).map((value) => { const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const; }).filter(([,value]) => Boolean(value))).values()]; if (!values.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED', message:'Un campo de lista necesita al menos una opción', }); return values; } 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()]; } private isUniqueViolation(error:unknown) { return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505'); } }