diff --git a/api-v3/src/asset-master/inventory-function.service.ts b/api-v3/src/asset-master/inventory-function.service.ts new file mode 100644 index 0000000..7cdd581 --- /dev/null +++ b/api-v3/src/asset-master/inventory-function.service.ts @@ -0,0 +1,352 @@ +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, AssetVersionChangeType } from '../database/entities'; +import { AssetHistoryService } from './asset-history.service'; +import type { + ChangeInventoryFunctionDto, + CreateInventoryFunctionDto, + UpdateInventoryFunctionDto, +} from './dto/inventory-function.dto'; + +interface InventoryFunctionRow { + 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; +} + +interface FunctionEligibleAsset { + 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(); +} + +@Injectable() +export class InventoryFunctionService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> { + const data = await this.dataSource.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 + ${includeInactive ? '' : 'WHERE is_active=true'} + ORDER BY sort_order,name,code + `) as InventoryFunctionRow[]; + return { data }; + } + + 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 [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); + 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' }); + } + 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), + 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); + return updated; + }); + } + + async getForAsset(assetId: string) { + return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId)); + } + + 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', + }); + } + + const current = await this.currentAssignment(manager, assetId, true); + 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', + }); + } + + 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, + versionNumber, + temporal: true, + }, + }, manager); + return after; + }); + } + + private async getForAssetWithManager(manager: EntityManager, assetId: string) { + const asset = await this.requireEligibleAsset(manager, assetId, false); + const currentFunction = await this.currentAssignment(manager, assetId, false); + const history = await manager.query(` + SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId", + fn.code AS "functionCode",fn.name AS "functionName", + assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil", + assignment.change_reason AS reason,assignment.changed_by AS "changedBy", + actor.username AS "changedByUsername",assignment.created_at AS "createdAt" + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + LEFT JOIN users actor ON actor.id=assignment.changed_by + 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, + }; + } + + 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", + assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil", + assignment.change_reason AS reason,assignment.changed_by AS "changedBy", + actor.username AS "changedByUsername",assignment.created_at AS "createdAt" + FROM inventory_function_assignments assignment + JOIN inventory_functions fn ON fn.id=assignment.function_id + LEFT JOIN users actor ON actor.id=assignment.changed_by + WHERE assignment.asset_id=$1 AND assignment.valid_until IS NULL + ${lock ? 'FOR UPDATE OF assignment' : ''} + `, [assetId]) as FunctionAssignmentRow[]; + return row ?? null; + } + + 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' : ''} + `, [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', + }); + } + return row; + } + + 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", + family.code AS "familyCode",family.name AS "familyName" + 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' : ''} + `, [assetId]) as FunctionEligibleAsset[]; + 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', + }); + } + return asset; + } +}