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.
446 lines
20 KiB
TypeScript
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');
|
|
}
|
|
}
|