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 c6d7c93..7d48fe9 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.service.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.service.ts @@ -15,7 +15,13 @@ import type { 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; @@ -24,12 +30,24 @@ type FamilyRow = { informationLabels: string[]; sourceReference: string | null; isActive: boolean; - parentFamilyId: string | null; - parentFamilyCode: string | null; - parentFamilyName: string | null; + 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() @@ -40,27 +58,7 @@ export class InventoryFamilyCatalogService { ) {} 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[]; + const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[]; return { data }; } @@ -80,27 +78,28 @@ export class InventoryFamilyCatalogService { 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 parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null); + 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:F5',true) + ) 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'); - 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]); - } + await this.replaceParents(manager, inserted.id, parentIds); const created = await this.family(inserted.id,false,manager); await this.audit.record({ ...administrationAuditContext(principal,request), @@ -108,7 +107,7 @@ export class InventoryFamilyCatalogService { entityType: 'inventory_family', entityId: inserted.id, afterData: created as unknown as Record, - metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' }, + metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' }, },manager); return created; }); @@ -122,10 +121,13 @@ export class InventoryFamilyCatalogService { ) { 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); + 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), @@ -139,15 +141,7 @@ export class InventoryFamilyCatalogService { 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]); - } - } + 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), @@ -162,6 +156,85 @@ export class InventoryFamilyCatalogService { }); } + 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, @@ -178,12 +251,10 @@ export class InventoryFamilyCatalogService { 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', - }); - } + 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]); @@ -198,11 +269,9 @@ export class InventoryFamilyCatalogService { 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 }, + 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); }); @@ -217,77 +286,150 @@ export class InventoryFamilyCatalogService { 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 + 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 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 + 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' : ''} - ${lock ? 'FOR UPDATE OF family' : ''} - `,[familyId])) as FamilyRow[]; + ${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 validateParent( + private async validateParents( manager:EntityManager, level:'INSTALLATION'|'SUBINSTALLATION', - parentFamilyId:string|null, + requestedIds:string[], ownId:string|null, - ):Promise { + ):Promise { + const ids=[...new Set(requestedIds)]; if (level==='INSTALLATION') { - if (parentFamilyId) throw new BadRequestException({ + if (ids.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', - message:'Una clasificación de Instalación no tiene clasificación padre', + message:'Una clasificación de Instalación no lleva compatibilidades padre', }); - return null; + return []; } - if (!parentFamilyId) throw new BadRequestException({ + if (!ids.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_REQUIRED', - message:'Una Subinstalación debe pertenecer a un tipo de Instalación', + message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación', }); - if (parentFamilyId===ownId) throw new BadRequestException({ - code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre', + if (ownId && ids.includes(ownId)) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma', }); - 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({ + 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:'La Subinstalación debe vincularse a una clasificación de Instalación activa', + message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas', }); - return parentFamilyId; + 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 clean=raw.trim(); if (!clean) continue; const identity=clean.toLocaleLowerCase('es-AR'); if (!unique.has(identity)) unique.set(identity,clean); } @@ -296,4 +438,8 @@ export class InventoryFamilyCatalogService { }); return [...unique.values()]; } + + private isUniqueViolation(error:unknown) { + return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505'); + } }