F4: implement temporal inventory function changes
This commit is contained in:
@@ -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<InventoryFunctionRow> {
|
||||||
|
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<string, unknown>,
|
||||||
|
}, manager);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
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) => {
|
||||||
|
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<string, unknown>,
|
||||||
|
afterData: updated as unknown as Record<string, unknown>,
|
||||||
|
}, 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<FunctionAssignmentRow | null> {
|
||||||
|
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<InventoryFunctionRow> {
|
||||||
|
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<FunctionEligibleAsset> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user