diff --git a/api-v3/src/asset-master/inventory-function.service.ts b/api-v3/src/asset-master/inventory-function.service.ts index 8b5c644..1a2e315 100644 --- a/api-v3/src/asset-master/inventory-function.service.ts +++ b/api-v3/src/asset-master/inventory-function.service.ts @@ -8,55 +8,32 @@ 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 { AssetVersionChangeType, AuditAction } from '../database/entities'; import type { ChangeInventoryFunctionDto, CreateInventoryFunctionDto, UpdateInventoryFunctionDto, } from './dto/inventory-function.dto'; +import { AssetHistoryService } from './asset-history.service'; interface InventoryFunctionRow { - id: string; - code: string; - name: string; - description: string | null; - isActive: boolean; - sortOrder: number; - createdAt: Date; - updatedAt: Date; + id: string; code: string; name: string; description: string | null; + isActive: boolean; sortOrder: number; createdAt: Date; updatedAt: Date; } interface FunctionAssignmentRow { - id: string; - assetId: string; - functionId: string; - functionCode: string; - functionName: string; - validFrom: Date; - validUntil: Date | null; - reason: string | null; - changedBy: string | null; - changedByUsername: string | null; - createdAt: Date; + id: string; assetId: string; functionId: string; functionCode: string; functionName: string; + validFrom: Date; validUntil: Date | null; reason: string | null; + changedBy: string | null; changedByUsername: string | null; createdAt: Date; } interface FunctionEligibleAsset { - id: string; - code: string; - name: string; - typeCode: string; - typeName: string; - familyCode: string | null; - familyName: string | null; + id: string; code: string; name: string; typeCode: string; typeName: string; + familyCode: string | null; familyName: string | null; } function normalized(value: string | null): string { - return (value ?? '') - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, ' ') - .trim(); + return (value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); } @Injectable() @@ -64,6 +41,7 @@ export class InventoryFunctionService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, + private readonly history: AssetHistoryService, ) {} async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> { @@ -77,77 +55,38 @@ export class InventoryFunctionService { return { data }; } - async create( - dto: CreateInventoryFunctionDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { + async create(dto: CreateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise { return this.dataSource.transaction(async (manager) => { - const [duplicate] = await manager.query( - 'SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', - [dto.code], - ) as unknown[]; - if (duplicate) { - throw new ConflictException({ - code: 'INVENTORY_FUNCTION_CODE_EXISTS', - message: 'Ya existe una función con ese código', - }); - } + const [duplicate] = await manager.query('SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', [dto.code]) as unknown[]; + if (duplicate) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CODE_EXISTS', message: 'Ya existe una función con ese código' }); const [created] = await manager.query(` INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by) VALUES($1,$2,$3,true,$4,$5,$5) RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", created_at AS "createdAt",updated_at AS "updatedAt" `, [dto.code, dto.name, dto.description ?? null, dto.sortOrder ?? 0, principal.userId]) as InventoryFunctionRow[]; - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INVENTORY_FUNCTION_CREATED, - entityType: 'inventory_function', - entityId: created.id, - afterData: created as unknown as Record, - }, manager); + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_CREATED, + entityType: 'inventory_function', entityId: created.id, afterData: created as unknown as Record }, manager); return created; }); } - async update( - id: string, - dto: UpdateInventoryFunctionDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (Object.keys(dto).length === 0) { - throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); - } + async update(id: string, dto: UpdateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise { + if (Object.keys(dto).length === 0) throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); return this.dataSource.transaction(async (manager) => { const before = await this.requireFunction(manager, id, false); const [updated] = await manager.query(` UPDATE inventory_functions SET - name=COALESCE($2,name), - description=CASE WHEN $3::boolean THEN $4 ELSE description END, - is_active=COALESCE($5,is_active), - sort_order=COALESCE($6,sort_order), + name=COALESCE($2,name), description=CASE WHEN $3::boolean THEN $4 ELSE description END, + is_active=COALESCE($5,is_active), sort_order=COALESCE($6,sort_order), updated_by=$7,updated_at=CURRENT_TIMESTAMP WHERE id=$1 RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", created_at AS "createdAt",updated_at AS "updatedAt" - `, [ - id, - dto.name ?? null, - dto.description !== undefined, - dto.description ?? null, - dto.isActive ?? null, - dto.sortOrder ?? null, - principal.userId, - ]) as InventoryFunctionRow[]; - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INVENTORY_FUNCTION_UPDATED, - entityType: 'inventory_function', - entityId: id, - beforeData: before as unknown as Record, - afterData: updated as unknown as Record, - }, manager); + `, [id, dto.name ?? null, dto.description !== undefined, dto.description ?? null, dto.isActive ?? null, dto.sortOrder ?? null, principal.userId]) as InventoryFunctionRow[]; + await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_UPDATED, + entityType: 'inventory_function', entityId: id, beforeData: before as unknown as Record, + afterData: updated as unknown as Record }, manager); return updated; }); } @@ -156,80 +95,32 @@ export class InventoryFunctionService { return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId)); } - async changeForAsset( - assetId: string, - dto: ChangeInventoryFunctionDto, - principal: AuthPrincipal, - request: RequestWithContext, - ) { + async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const asset = await this.requireEligibleAsset(manager, assetId, true); const nextFunction = await this.requireFunction(manager, dto.functionId, true); const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date(); - if (!Number.isFinite(effectiveAt.getTime())) { - throw new BadRequestException({ - code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', - message: 'La fecha efectiva del cambio de función no es válida', - }); - } - if (effectiveAt.getTime() > Date.now() + 60_000) { - throw new BadRequestException({ - code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', - message: 'El cambio de función no puede registrarse con fecha futura', - }); - } + if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' }); + if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' }); const current = await this.currentAssignment(manager, assetId, true); - if (current?.functionId === nextFunction.id) { - return this.getForAssetWithManager(manager, assetId); - } + if (current?.functionId === nextFunction.id) return this.getForAssetWithManager(manager, assetId); if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) { - throw new ConflictException({ - code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', - message: 'La fecha efectiva debe ser posterior al inicio de la función vigente', - }); + throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' }); } + if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]); + await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`, + [assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]); + await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]); - if (current) { - await manager.query(` - UPDATE inventory_function_assignments - SET valid_until=$2 - WHERE id=$1 AND valid_until IS NULL - `, [current.id, effectiveAt]); - } - await manager.query(` - INSERT INTO inventory_function_assignments( - asset_id,function_id,valid_from,change_reason,changed_by - ) VALUES($1,$2,$3,$4,$5) - `, [assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]); - await manager.query(` - UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1 - `, [assetId, principal.userId]); + const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request); const after = await this.getForAssetWithManager(manager, assetId); await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.ASSET_FUNCTION_CHANGED, - entityType: 'asset', - entityId: assetId, - beforeData: current ? { - functionId: current.functionId, - functionCode: current.functionCode, - functionName: current.functionName, - validFrom: current.validFrom, - } : { functionId: null }, - afterData: { - functionId: nextFunction.id, - functionCode: nextFunction.code, - functionName: nextFunction.name, - effectiveAt, - reason: dto.reason ?? null, - }, - metadata: { - inventoryCode: asset.code, - inventoryName: asset.name, - temporal: true, - historySource: 'inventory_function_assignments', - }, + ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED, + entityType: 'asset', entityId: assetId, + beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null }, + afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber }, + metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED }, }, manager); return after; }); @@ -250,26 +141,10 @@ export class InventoryFunctionService { WHERE assignment.asset_id=$1 ORDER BY assignment.valid_from DESC,assignment.created_at DESC `, [assetId]) as FunctionAssignmentRow[]; - return { - asset: { - id: asset.id, - code: asset.code, - name: asset.name, - typeCode: asset.typeCode, - typeName: asset.typeName, - familyCode: asset.familyCode, - familyName: asset.familyName, - }, - currentFunction, - history, - }; + return { asset: { id: asset.id, code: asset.code, name: asset.name, typeCode: asset.typeCode, typeName: asset.typeName, familyCode: asset.familyCode, familyName: asset.familyName }, currentFunction, history }; } - private async currentAssignment( - manager: EntityManager, - assetId: string, - lock: boolean, - ): Promise { + private async currentAssignment(manager: EntityManager, assetId: string, lock: boolean): Promise { const [row] = await manager.query(` SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId", fn.code AS "functionCode",fn.name AS "functionName", @@ -285,59 +160,27 @@ export class InventoryFunctionService { return row ?? null; } - private async requireFunction( - manager: EntityManager, - id: string, - active: boolean, - ): Promise { + private async requireFunction(manager: EntityManager, id: string, active: boolean): Promise { const [row] = await manager.query(` - SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder", - created_at AS "createdAt",updated_at AS "updatedAt" - FROM inventory_functions - WHERE id=$1 ${active ? 'AND is_active=true' : ''} + SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",created_at AS "createdAt",updated_at AS "updatedAt" + FROM inventory_functions WHERE id=$1 ${active ? 'AND is_active=true' : ''} `, [id]) as InventoryFunctionRow[]; - if (!row) { - throw new NotFoundException({ - code: 'INVENTORY_FUNCTION_NOT_FOUND', - message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada', - }); - } + if (!row) throw new NotFoundException({ code: 'INVENTORY_FUNCTION_NOT_FOUND', message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada' }); return row; } - private async requireEligibleAsset( - manager: EntityManager, - assetId: string, - lock: boolean, - ): Promise { + private async requireEligibleAsset(manager: EntityManager, assetId: string, lock: boolean): Promise { const [asset] = await manager.query(` - SELECT asset.id,asset.code,asset.name, - asset_type.code AS "typeCode",asset_type.name AS "typeName", + SELECT asset.id,asset.code,asset.name,asset_type.code AS "typeCode",asset_type.name AS "typeName", family.code AS "familyCode",family.name AS "familyName" - FROM assets asset - JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id + FROM assets asset JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id - WHERE asset.id=$1 - ${lock ? 'FOR UPDATE OF asset' : ''} + WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''} `, [assetId]) as FunctionEligibleAsset[]; - if (!asset) { - throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' }); - } + if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' }); const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized); - const eligible = values.some((value) => - value === 'estacion' - || value === 'subestacion' - || value.includes('estacion ') - || value.includes('subestacion ') - || value.endsWith(' estacion') - || value.endsWith(' subestacion'), - ); - if (!eligible) { - throw new ConflictException({ - code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', - message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación', - }); - } + const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion')); + if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación' }); return asset; } }