F3.1: crear servicio guiado de Inventario estructural
This commit is contained in:
@@ -0,0 +1,333 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AssetDataOrigin,
|
||||||
|
AssetInformationStatus,
|
||||||
|
AssetOperationalStatus,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
|
||||||
|
type StructureTypeRow = { id: string; code: string; name: string };
|
||||||
|
type FamilyRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||||
|
legacyTypeCode: string | null;
|
||||||
|
informationLabels: string[];
|
||||||
|
parentFamilyId: string | null;
|
||||||
|
parentFamilyCode: string | null;
|
||||||
|
parentFamilyName: string | null;
|
||||||
|
};
|
||||||
|
type ParentRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeCode: string;
|
||||||
|
operationalAreaId: string | null;
|
||||||
|
operatorCompanyId: string | null;
|
||||||
|
inventoryFamilyId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
|
||||||
|
AREA: 'area',
|
||||||
|
YACIMIENTO: 'yacimiento',
|
||||||
|
INSTALACION: 'instalacion',
|
||||||
|
SUBINSTALACION: 'subinstalacion',
|
||||||
|
};
|
||||||
|
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
||||||
|
AREA: null,
|
||||||
|
YACIMIENTO: 'area',
|
||||||
|
INSTALACION: 'yacimiento',
|
||||||
|
SUBINSTALACION: 'instalacion',
|
||||||
|
};
|
||||||
|
const FAMILY_LEVEL_BY_KIND: Partial<Record<InventoryStructureKind, FamilyRow['level']>> = {
|
||||||
|
INSTALACION: 'INSTALLATION',
|
||||||
|
SUBINSTALACION: 'SUBINSTALLATION',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryStructureService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async options() {
|
||||||
|
const types = (await this.dataSource.query(`
|
||||||
|
SELECT id,code,name
|
||||||
|
FROM asset_types
|
||||||
|
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
||||||
|
AND is_active=true
|
||||||
|
ORDER BY CASE lower(code)
|
||||||
|
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
|
||||||
|
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
|
||||||
|
`)) as StructureTypeRow[];
|
||||||
|
if (types.length !== 4) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
|
||||||
|
message: 'La estructura del Inventario todavía no está completamente configurada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const families = (await this.dataSource.query(`
|
||||||
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
|
family.legacy_type_code AS "legacyTypeCode",
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||||
|
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.is_active=true
|
||||||
|
ORDER BY family.level,family.name,family.code
|
||||||
|
`)) as FamilyRow[];
|
||||||
|
return {
|
||||||
|
levels: [
|
||||||
|
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
|
||||||
|
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
|
||||||
|
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||||
|
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
|
||||||
|
],
|
||||||
|
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
||||||
|
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateInventoryStructureDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const type = await this.requireStructureType(manager, dto.kind);
|
||||||
|
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
||||||
|
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
||||||
|
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||||
|
const operationalAreaId = parent?.operationalAreaId ?? null;
|
||||||
|
const operatorCompanyId = parent?.operatorCompanyId ?? null;
|
||||||
|
|
||||||
|
const inserted = (await manager.query(`
|
||||||
|
INSERT INTO assets (
|
||||||
|
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
|
||||||
|
code,name,common_name,description,information_status,operational_status,
|
||||||
|
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||||
|
) VALUES (
|
||||||
|
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
|
||||||
|
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::varchar,$11::varchar,
|
||||||
|
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
type.id,
|
||||||
|
parent?.id ?? null,
|
||||||
|
operationalAreaId,
|
||||||
|
operatorCompanyId,
|
||||||
|
family?.id ?? null,
|
||||||
|
code,
|
||||||
|
dto.name,
|
||||||
|
dto.commonName ?? null,
|
||||||
|
dto.description ?? null,
|
||||||
|
AssetInformationStatus.DRAFT,
|
||||||
|
AssetOperationalStatus.IN_SERVICE,
|
||||||
|
AssetDataOrigin.MANUAL,
|
||||||
|
'Inventario estructural F3.1',
|
||||||
|
`inventory-structure:${dto.kind.toLowerCase()}`,
|
||||||
|
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
|
||||||
|
principal.userId,
|
||||||
|
])) as Array<{ id: string }>;
|
||||||
|
const id = inserted[0]?.id;
|
||||||
|
if (!id) throw new Error('No se pudo crear el registro estructural');
|
||||||
|
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
id,
|
||||||
|
AssetVersionChangeType.CREATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO asset_context_history (
|
||||||
|
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||||
|
change_reason,asset_version_number,source,request_id,created_by
|
||||||
|
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
parent?.id ?? null,
|
||||||
|
operationalAreaId,
|
||||||
|
operatorCompanyId,
|
||||||
|
'Alta guiada de Inventario estructural F3.1',
|
||||||
|
versionNumber,
|
||||||
|
request.requestId,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
const created = await this.loadView(manager, id);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_CREATED,
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: id,
|
||||||
|
afterData: created as unknown as Record<string, unknown>,
|
||||||
|
metadata: {
|
||||||
|
versionNumber,
|
||||||
|
inventoryStructureKind: dto.kind,
|
||||||
|
inventoryFamilyId: family?.id ?? null,
|
||||||
|
inventoryFamilyCode: family?.code ?? null,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_CODE_ALREADY_EXISTS',
|
||||||
|
message: 'Ya existe un registro de Inventario con ese código',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT id,code,name FROM asset_types
|
||||||
|
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
|
||||||
|
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
|
||||||
|
if (!rows[0]) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
||||||
|
message: `El nivel ${kind} no está configurado`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireParent(
|
||||||
|
manager: EntityManager,
|
||||||
|
kind: InventoryStructureKind,
|
||||||
|
parentId: string | null,
|
||||||
|
): Promise<ParentRow | null> {
|
||||||
|
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
||||||
|
if (!expectedType) {
|
||||||
|
if (parentId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_AREA_MUST_BE_ROOT',
|
||||||
|
message: 'Un Área se crea como registro raíz y no puede tener padre',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parentId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
||||||
|
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||||
|
asset.operational_area_id AS "operationalAreaId",
|
||||||
|
asset.operator_company_id AS "operatorCompanyId",
|
||||||
|
asset.inventory_family_id AS "inventoryFamilyId"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
||||||
|
FOR KEY SHARE
|
||||||
|
`, [parentId])) as ParentRow[];
|
||||||
|
const parent = rows[0];
|
||||||
|
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
|
||||||
|
if (parent.typeCode.toLowerCase() !== expectedType) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||||
|
message: `La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireFamily(
|
||||||
|
manager: EntityManager,
|
||||||
|
kind: InventoryStructureKind,
|
||||||
|
familyId: string | null,
|
||||||
|
parent: ParentRow | null,
|
||||||
|
): Promise<FamilyRow | null> {
|
||||||
|
const expectedLevel = FAMILY_LEVEL_BY_KIND[kind];
|
||||||
|
if (!expectedLevel) {
|
||||||
|
if (familyId) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||||
|
message: 'Área y Yacimiento no llevan familia técnica',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!familyId) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
||||||
|
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`,
|
||||||
|
});
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
|
family.legacy_type_code AS "legacyTypeCode",
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||||
|
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 AND family.is_active=true
|
||||||
|
LIMIT 1
|
||||||
|
`, [familyId])) as FamilyRow[];
|
||||||
|
const family = rows[0];
|
||||||
|
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
|
||||||
|
if (family.level !== expectedLevel) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||||
|
message: 'La familia técnica no corresponde al nivel seleccionado',
|
||||||
|
});
|
||||||
|
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
||||||
|
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||||
|
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||||
|
const readable = name
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 48) || 'REGISTRO';
|
||||||
|
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadView(manager: EntityManager, id: string) {
|
||||||
|
const rows = await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
|
||||||
|
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
||||||
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||||
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||||
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
||||||
|
'informationLabels',family.information_labels
|
||||||
|
) END AS "inventoryFamily",
|
||||||
|
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE asset.id=$1::uuid
|
||||||
|
`, [id]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user