F4 inventory: version function changes in dossier history

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