Files
dh-inspeccion-v2/api-v3/src/asset-master/inventory-family-catalog.service.ts
T
admin d938db7913 F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
2026-09-09 11:19:12 -03:00

446 lines
20 KiB
TypeScript

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<string,unknown>,
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<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
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<string,unknown>,
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<string,unknown>,afterData:after as unknown as Record<string,unknown>,
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<FamilyRow> {
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<string[]> {
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<AttributeRow[]> {
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<AttributeRow[]>;
}
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
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<string,string>();
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');
}
}