feat(inventory): administer installation and subinstallation classifications
This commit is contained in:
@@ -1,28 +1,71 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { DataSource } from 'typeorm';
|
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';
|
||||||
|
|
||||||
@Injectable()
|
type FamilyRow = {
|
||||||
export class InventoryFamilyCatalogService {
|
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
|
||||||
|
|
||||||
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;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
level: string;
|
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||||
informationLabels: string[];
|
informationLabels: string[];
|
||||||
}>;
|
sourceReference: string | null;
|
||||||
if (!family) {
|
isActive: boolean;
|
||||||
throw new NotFoundException({
|
parentFamilyId: string | null;
|
||||||
code: 'INVENTORY_FAMILY_NOT_FOUND',
|
parentFamilyCode: string | null;
|
||||||
message: 'La familia técnica no existe',
|
parentFamilyName: string | null;
|
||||||
});
|
assetCount: number;
|
||||||
|
findingCount: number;
|
||||||
|
findingItemIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryFamilyCatalogService {
|
||||||
|
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.family(familyId, false);
|
||||||
const items = await this.dataSource.query(`
|
const items = await this.dataSource.query(`
|
||||||
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
|
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
|
||||||
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
|
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
|
||||||
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
|
|||||||
`, [familyId]);
|
`, [familyId]);
|
||||||
return { family, items, count: items.length };
|
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<string,unknown>,
|
||||||
|
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<string,unknown>,
|
||||||
|
afterData: after as unknown as Record<string,unknown>,
|
||||||
|
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<FamilyRow> {
|
||||||
|
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<string|null> {
|
||||||
|
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<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()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user