feat(inventory): add function catalog and act data
DH V2 CI / WEB · typecheck, build (push) Successful in 45s
Production dependency audit / API · production dependencies (push) Successful in 27s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m21s
DH V2 CI / Promote verified main to deploy (push) Successful in 9s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m25s
DH V2 CI / WEB · typecheck, build (push) Successful in 45s
Production dependency audit / API · production dependencies (push) Successful in 27s
DH V2 CI / API · typecheck, tests, build (push) Successful in 1m15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m21s
DH V2 CI / Promote verified main to deploy (push) Successful in 9s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m25s
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-17",
|
||||
"version": "0.29.0-18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-17",
|
||||
"version": "0.29.0-18",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-17",
|
||||
"version": "0.29.0-18",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
|
||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { InventoryFunctionController } from './inventory-function.controller';
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { ActivityDossierService } from './activity-dossier.service';
|
||||
@@ -47,6 +48,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryTechnicalValuesController,
|
||||
InventoryFunctionController,
|
||||
InventoryBrowserController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
|
||||
@@ -44,6 +44,7 @@ import type { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-
|
||||
import type { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
|
||||
import type { ChangeAssetContextDto } from './dto/change-asset-context.dto';
|
||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
|
||||
export interface AssetListItem {
|
||||
id: string;
|
||||
@@ -97,9 +98,31 @@ export class AssetsService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
private readonly inventoryFunctions: InventoryFunctionService,
|
||||
private readonly fieldDiscoveryInspectionLinks: FieldDiscoveryInspectionLinkService,
|
||||
) {}
|
||||
|
||||
private isTechnicalInventoryType(type: AssetType): boolean {
|
||||
const code = type.code.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
return code === 'instalacion' || code === 'subinstalacion';
|
||||
}
|
||||
|
||||
private resolveIdentity(type: AssetType, name: string | null | undefined, commonName: string | null | undefined) {
|
||||
const technicalName = name?.trim() || null;
|
||||
const habitualName = commonName?.trim() || null;
|
||||
if (this.isTechnicalInventoryType(type) && !habitualName) {
|
||||
throw new BadRequestException({
|
||||
code: 'ASSET_COMMON_NAME_REQUIRED',
|
||||
message: 'El Nombre habitual es obligatorio para Instalaciones y Subinstalaciones',
|
||||
});
|
||||
}
|
||||
const persistedName = technicalName || habitualName;
|
||||
if (!persistedName) {
|
||||
throw new BadRequestException({ code: 'ASSET_NAME_REQUIRED', message: 'Ingresá un nombre para el registro' });
|
||||
}
|
||||
return { technicalName, habitualName, persistedName };
|
||||
}
|
||||
|
||||
async list(query: ListAssetsQueryDto) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
@@ -627,6 +650,7 @@ export class AssetsService {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const type = await this.requireActiveType(manager, dto.typeId);
|
||||
const identity = this.resolveIdentity(type, dto.name, dto.commonName);
|
||||
await this.validateParent(manager, type, dto.parentId ?? null, null);
|
||||
await this.validateOperationalAssignment(
|
||||
manager,
|
||||
@@ -644,8 +668,8 @@ export class AssetsService {
|
||||
operationalAreaId: dto.operationalAreaId ?? null,
|
||||
operatorCompanyId: dto.operatorCompanyId ?? null,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
commonName: dto.commonName ?? null,
|
||||
name: identity.persistedName,
|
||||
commonName: identity.habitualName,
|
||||
description: dto.description ?? null,
|
||||
informationStatus: principal.permissions.includes('assets.change_status')
|
||||
? dto.informationStatus
|
||||
@@ -676,6 +700,9 @@ export class AssetsService {
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
if (dto.functionId) {
|
||||
await this.inventoryFunctions.assignInitial(manager, asset.id, { functionId: dto.functionId }, principal, request);
|
||||
}
|
||||
await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto inicial del registro');
|
||||
const created = await this.loadView(manager, asset.id);
|
||||
await this.audit.record(
|
||||
@@ -751,6 +778,7 @@ export class AssetsService {
|
||||
if (!visit.assigned) throw new ConflictException({ code: 'FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección' });
|
||||
|
||||
const type = await this.requireActiveType(manager, dto.typeId);
|
||||
const identity = this.resolveIdentity(type, dto.name, dto.commonName);
|
||||
await this.validateParent(manager, type, dto.parentId, null);
|
||||
await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId);
|
||||
const definitions = await this.loadDefinitions(manager, type.id);
|
||||
@@ -763,8 +791,8 @@ export class AssetsService {
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
inventoryFamilyId: dto.inventoryFamilyId ?? null,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
commonName: dto.commonName ?? null,
|
||||
name: identity.persistedName,
|
||||
commonName: identity.habitualName,
|
||||
description: dto.description ?? null,
|
||||
informationStatus: AssetInformationStatus.DRAFT,
|
||||
createdBy: principal.userId,
|
||||
@@ -779,6 +807,13 @@ export class AssetsService {
|
||||
await manager.getRepository(Asset).save(asset);
|
||||
await this.replaceAttributeValues(manager, asset.id, values, principal.userId);
|
||||
const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.CREATED, principal, request);
|
||||
if (dto.functionId || dto.newFunctionName) {
|
||||
await this.inventoryFunctions.assignInitial(
|
||||
manager, asset.id,
|
||||
{ functionId: dto.functionId ?? null, newFunctionName: dto.newFunctionName ?? null },
|
||||
principal, request,
|
||||
);
|
||||
}
|
||||
await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto observado en alta de campo');
|
||||
const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach(
|
||||
manager,
|
||||
@@ -1136,9 +1171,16 @@ export class AssetsService {
|
||||
);
|
||||
}
|
||||
|
||||
const nextCommonName = dto.commonName === undefined ? asset.commonName : dto.commonName;
|
||||
if (this.isTechnicalInventoryType(type) && !nextCommonName?.trim()) {
|
||||
throw new BadRequestException({
|
||||
code: 'ASSET_COMMON_NAME_REQUIRED',
|
||||
message: 'El Nombre habitual es obligatorio para Instalaciones y Subinstalaciones',
|
||||
});
|
||||
}
|
||||
if (typeChanged) asset.assetTypeId = type.id;
|
||||
if (dto.code !== undefined) asset.code = dto.code;
|
||||
if (dto.name !== undefined) asset.name = dto.name;
|
||||
if (dto.name !== undefined) asset.name = dto.name?.trim() || nextCommonName?.trim() || asset.name;
|
||||
if (dto.commonName !== undefined) asset.commonName = dto.commonName;
|
||||
if (dto.parentId !== undefined) asset.parentId = dto.parentId;
|
||||
if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId;
|
||||
|
||||
@@ -21,11 +21,11 @@ export class CreateAssetDto {
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
name?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
@@ -35,6 +35,10 @@ export class CreateAssetDto {
|
||||
@MaxLength(200)
|
||||
commonName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
functionId?: string | null;
|
||||
|
||||
@IsUUID('4')
|
||||
typeId!: string;
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ export class CreateFieldDiscoveryDto {
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
name?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@@ -28,6 +28,16 @@ export class CreateFieldDiscoveryDto {
|
||||
@MaxLength(200)
|
||||
commonName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
functionId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
newFunctionName?: string | null;
|
||||
|
||||
@IsUUID('4')
|
||||
typeId!: string;
|
||||
|
||||
|
||||
@@ -64,8 +64,9 @@ export class UpdateInventoryFunctionDto {
|
||||
}
|
||||
|
||||
export class ChangeInventoryFunctionDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
functionId!: string;
|
||||
functionId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
|
||||
@@ -26,11 +26,10 @@ export class UpdateAssetDto {
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name?: string;
|
||||
name?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -98,19 +99,21 @@ export class InventoryFunctionService {
|
||||
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);
|
||||
const nextFunction = dto.functionId ? await this.requireFunction(manager, dto.functionId, true) : null;
|
||||
if (current?.functionId === nextFunction?.id || (!current && !nextFunction)) 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]);
|
||||
if (nextFunction) {
|
||||
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);
|
||||
@@ -119,13 +122,97 @@ export class InventoryFunctionService {
|
||||
...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 },
|
||||
afterData: nextFunction
|
||||
? { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber }
|
||||
: { functionId: null, 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;
|
||||
});
|
||||
}
|
||||
|
||||
async assignInitial(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
selection: { functionId?: string | null; newFunctionName?: string | null },
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<FunctionAssignmentRow | null> {
|
||||
const functionId = selection.functionId?.trim() || null;
|
||||
const newFunctionName = selection.newFunctionName?.trim() || null;
|
||||
if (functionId && newFunctionName) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_FUNCTION_SELECTION_AMBIGUOUS',
|
||||
message: 'Seleccioná una función del catálogo o cargá una nueva, no ambas opciones',
|
||||
});
|
||||
}
|
||||
if (!functionId && !newFunctionName) return null;
|
||||
|
||||
const asset = await this.requireEligibleAsset(manager, assetId, true);
|
||||
let nextFunction: InventoryFunctionRow;
|
||||
if (functionId) {
|
||||
nextFunction = await this.requireFunction(manager, functionId, true);
|
||||
} else {
|
||||
if (!newFunctionName || newFunctionName.length < 2) {
|
||||
throw new BadRequestException({ code: 'INVENTORY_FUNCTION_NAME_REQUIRED', message: 'Ingresá el nombre de la nueva función' });
|
||||
}
|
||||
const [existing] = 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 lower(trim(name))=lower(trim($1)) LIMIT 1
|
||||
`, [newFunctionName]) as InventoryFunctionRow[];
|
||||
if (existing && !existing.isActive) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_FUNCTION_INACTIVE_EXISTS',
|
||||
message: 'La función ya existe pero está inactiva. Reactivala desde el Catálogo de funciones.',
|
||||
});
|
||||
}
|
||||
if (existing) nextFunction = existing;
|
||||
else {
|
||||
const slug = newFunctionName.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 72) || 'OTRA';
|
||||
const digest = createHash('sha256').update(newFunctionName.toLocaleLowerCase('es')).digest('hex').slice(0, 10).toUpperCase();
|
||||
const code = `FIELD_${slug}_${digest}`;
|
||||
const [created] = await manager.query(`
|
||||
INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by)
|
||||
VALUES($1,$2,NULL,true,10000,$3,$3)
|
||||
ON CONFLICT (code) DO UPDATE SET updated_at=CURRENT_TIMESTAMP
|
||||
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
|
||||
created_at AS "createdAt",updated_at AS "updatedAt"
|
||||
`, [code, newFunctionName, principal.userId]) as InventoryFunctionRow[];
|
||||
nextFunction = created;
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INVENTORY_FUNCTION_CREATED,
|
||||
entityType: 'inventory_function', entityId: created.id,
|
||||
afterData: { ...created, source: 'FIELD' } as unknown as Record<string, unknown>,
|
||||
}, manager);
|
||||
}
|
||||
}
|
||||
|
||||
const current = await this.currentAssignment(manager, assetId, true);
|
||||
if (current?.functionId === nextFunction.id) return current;
|
||||
const effectiveAt = new Date();
|
||||
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, newFunctionName ? 'Función incorporada desde alta de campo' : 'Función inicial', 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 assigned = await this.currentAssignment(manager, assetId, false);
|
||||
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, versionNumber },
|
||||
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, source: newFunctionName ? 'FIELD' : 'CATALOG' },
|
||||
}, manager);
|
||||
return assigned;
|
||||
}
|
||||
|
||||
private async getForAssetWithManager(manager: EntityManager, assetId: string) {
|
||||
const asset = await this.requireEligibleAsset(manager, assetId, false);
|
||||
const currentFunction = await this.currentAssignment(manager, assetId, false);
|
||||
@@ -178,9 +265,9 @@ export class InventoryFunctionService {
|
||||
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' });
|
||||
const values = [asset.typeCode, asset.typeName].map(normalized);
|
||||
const eligible = values.some((value) => value === 'instalacion' || value === 'subinstalacion');
|
||||
if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'La función sólo se administra en Instalaciones y Subinstalaciones' });
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F618FunctionCatalog1790146200000 implements MigrationInterface {
|
||||
name = 'F618FunctionCatalog1790146200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_attribute_definitions definition
|
||||
SET is_required=false, updated_at=CURRENT_TIMESTAMP
|
||||
FROM asset_types type
|
||||
WHERE type.id=definition.asset_type_id
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
WITH legacy AS (
|
||||
SELECT DISTINCT trim(value.value #>> '{}') AS name
|
||||
FROM asset_attribute_values value
|
||||
JOIN asset_attribute_definitions definition ON definition.id=value.definition_id
|
||||
JOIN asset_types type ON type.id=definition.asset_type_id
|
||||
WHERE definition.code='campo_funcion'
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
AND nullif(trim(value.value #>> '{}'),'') IS NOT NULL
|
||||
)
|
||||
INSERT INTO inventory_functions(code,name,description,is_active,sort_order)
|
||||
SELECT 'MIGRATED_' || upper(substr(md5(lower(name)),1,12)), name,
|
||||
'Migrada desde el campo libre Función al catálogo F6.18.', true, 9000
|
||||
FROM legacy
|
||||
ON CONFLICT (code) DO UPDATE SET name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason)
|
||||
SELECT value.asset_id,function.id,COALESCE(value.updated_at,CURRENT_TIMESTAMP),'Migrado desde campo_funcion F6.18'
|
||||
FROM asset_attribute_values value
|
||||
JOIN asset_attribute_definitions definition ON definition.id=value.definition_id
|
||||
JOIN asset_types type ON type.id=definition.asset_type_id
|
||||
JOIN inventory_functions function
|
||||
ON function.code='MIGRATED_' || upper(substr(md5(lower(trim(value.value #>> '{}'))),1,12))
|
||||
WHERE definition.code='campo_funcion'
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
AND nullif(trim(value.value #>> '{}'),'') IS NOT NULL
|
||||
ON CONFLICT (asset_id) WHERE valid_until IS NULL DO NOTHING
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_attribute_definitions definition
|
||||
SET is_active=false,is_required=false,updated_at=CURRENT_TIMESTAMP
|
||||
FROM asset_types type
|
||||
WHERE type.id=definition.asset_type_id
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
AND definition.code='campo_funcion'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_attribute_definitions definition
|
||||
SET is_active=true,is_required=false,updated_at=CURRENT_TIMESTAMP
|
||||
FROM asset_types type
|
||||
WHERE type.id=definition.asset_type_id
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
AND definition.code='campo_funcion'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO asset_attribute_values(asset_id,definition_id,value,updated_at)
|
||||
SELECT assignment.asset_id,definition.id,to_jsonb(function.name),CURRENT_TIMESTAMP
|
||||
FROM inventory_function_assignments assignment
|
||||
JOIN inventory_functions function ON function.id=assignment.function_id
|
||||
JOIN assets asset ON asset.id=assignment.asset_id
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
JOIN asset_attribute_definitions definition
|
||||
ON definition.asset_type_id=asset.asset_type_id AND definition.code='campo_funcion'
|
||||
WHERE assignment.change_reason='Migrado desde campo_funcion F6.18'
|
||||
AND assignment.valid_until IS NULL
|
||||
ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DELETE FROM inventory_function_assignments WHERE change_reason='Migrado desde campo_funcion F6.18'`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM inventory_functions function
|
||||
WHERE function.code LIKE 'MIGRATED_%'
|
||||
AND NOT EXISTS (SELECT 1 FROM inventory_function_assignments assignment WHERE assignment.function_id=function.id)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type UploadedInspectionSignatureFile,
|
||||
} from './inspection-signature-file';
|
||||
|
||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
|
||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V6';
|
||||
const CONSENT_VERSION = 'F4-1';
|
||||
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
||||
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
||||
@@ -973,6 +973,23 @@ export class InspectionClosingService {
|
||||
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
|
||||
selected.type_code AS "typeCode",selected.type_name AS "typeName",
|
||||
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
|
||||
(SELECT JSONB_BUILD_OBJECT('id',fn.id,'code',fn.code,'name',fn.name)
|
||||
FROM inventory_function_assignments assignment
|
||||
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||
WHERE assignment.asset_id=selected.id AND assignment.valid_until IS NULL
|
||||
ORDER BY assignment.valid_from DESC,assignment.created_at DESC LIMIT 1) AS "function",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'code',definition.code,'name',definition.name,'unit',definition.unit,
|
||||
'dataType',definition.data_type,'value',value.value,'sortOrder',definition.sort_order
|
||||
) ORDER BY definition.sort_order,definition.name,definition.code)
|
||||
FROM asset_attribute_values value
|
||||
JOIN asset_attribute_definitions definition ON definition.id=value.definition_id
|
||||
WHERE value.asset_id=selected.id
|
||||
AND definition.is_active=true
|
||||
AND definition.code<>'campo_funcion'
|
||||
AND value.value IS NOT NULL
|
||||
AND (jsonb_typeof(value.value)<>'string' OR NULLIF(trim(value.value #>> '{}'),'') IS NOT NULL)
|
||||
),'[]'::jsonb) AS attributes,
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
|
||||
FROM selected ORDER BY selected.code,selected.id
|
||||
`, [actId]) as Array<Record<string, unknown>>;
|
||||
|
||||
@@ -36,6 +36,17 @@ function asArray(value: unknown): Array<Record<string, unknown>> {
|
||||
function text(value: unknown, fallback = ''): string {
|
||||
return String(value ?? '').trim() || fallback;
|
||||
}
|
||||
function sameText(left: unknown, right: unknown): boolean {
|
||||
const normalize = (value: unknown) => text(value).normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toLowerCase();
|
||||
return normalize(left) !== '' && normalize(left) === normalize(right);
|
||||
}
|
||||
function attributeText(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'boolean') return value ? 'Sí' : 'No';
|
||||
if (typeof value === 'string' || typeof value === 'number') return text(value);
|
||||
if (Array.isArray(value)) return value.map(attributeText).filter(Boolean).join(', ');
|
||||
try { return JSON.stringify(value); } catch { return text(value); }
|
||||
}
|
||||
function date(value: unknown): string {
|
||||
const parsed = new Date(String(value ?? ''));
|
||||
return Number.isFinite(parsed.getTime())
|
||||
@@ -228,8 +239,24 @@ export async function buildInspectionActPdf(
|
||||
need(100);
|
||||
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
||||
doc.moveDown(0.2);
|
||||
label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
||||
const habitualName = text(inventory.commonName) || text(inventory.name);
|
||||
const technicalName = text(inventory.name);
|
||||
const inventoryFunction = asRecord(inventory.function);
|
||||
const technicalAttributes = asArray(inventory.attributes);
|
||||
label('Elemento afectado', `${text(inventory.typeName)} - ${habitualName}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
||||
label('Nombre habitual', habitualName);
|
||||
if (technicalName && !sameText(technicalName, habitualName)) label('Nombre técnico', technicalName);
|
||||
if (text(inventory.code)) label('Código DH', inventory.code);
|
||||
if (text(inventory.typeName)) label('Tipo de elemento', inventory.typeName);
|
||||
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
|
||||
if (text(inventoryFunction.name)) label('Función', inventoryFunction.name);
|
||||
for (const attribute of technicalAttributes) {
|
||||
if (text(attribute.code) === 'campo_funcion') continue;
|
||||
const value = attributeText(attribute.value);
|
||||
if (!value) continue;
|
||||
const unit = text(attribute.unit);
|
||||
label(text(attribute.name, text(attribute.code)), unit ? `${value} ${unit}` : value);
|
||||
}
|
||||
if (route) label('Ubicación / Ruta jerárquica', route);
|
||||
label('Denominación del hallazgo', finding.title);
|
||||
subheading('Qué se constató');
|
||||
|
||||
@@ -36,11 +36,11 @@ export class CreateFieldInventoryDto {
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||
code?: string;
|
||||
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
name?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@@ -48,6 +48,16 @@ export class CreateFieldInventoryDto {
|
||||
@MaxLength(200)
|
||||
commonName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
functionId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
newFunctionName?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
|
||||
@@ -306,8 +306,10 @@ export class FieldInventoryService {
|
||||
clientGeneratedId: dto.clientGeneratedId,
|
||||
visitId,
|
||||
code,
|
||||
name: dto.name,
|
||||
name: dto.name ?? null,
|
||||
commonName: dto.commonName ?? null,
|
||||
functionId: dto.functionId ?? null,
|
||||
newFunctionName: dto.newFunctionName ?? null,
|
||||
typeId: dto.typeId,
|
||||
parentId,
|
||||
operationalAreaId: context.areaId,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.29.0-17';
|
||||
export const API_PHASE = 'F6.17';
|
||||
export const API_VERSION = '0.29.0-18';
|
||||
export const API_PHASE = 'F6.18';
|
||||
|
||||
@@ -60,7 +60,7 @@ test('Acta PDF explains each Hallazgo with frozen territorial, technical and rec
|
||||
assert.match(pdf, /Alcance de la inspección/);
|
||||
assert.match(pdf, /scopeTypeCode/);
|
||||
assert.match(pdf, /Antecedente relacionado/);
|
||||
assert.match(closing, /DH-ACT-LIFECYCLE-V5/);
|
||||
assert.match(closing, /DH-ACT-LIFECYCLE-V6/);
|
||||
assert.match(closing, /AS department/);
|
||||
assert.match(closing, /AS "leadInspector"/);
|
||||
assert.match(closing, /AS "installationTypeName"/);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F6.17 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.17');
|
||||
test('health metadata reports the current F6.18 release', () => {
|
||||
assert.equal(API_PHASE, 'F6.18');
|
||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||
assert.equal(API_VERSION, pkg.version);
|
||||
assert.equal(API_VERSION, '0.29.0-17');
|
||||
assert.equal(API_VERSION, '0.29.0-18');
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 41/);
|
||||
assert.match(gradle, /versionName = "0\.19\.13"/);
|
||||
assert.match(gradle, /versionCode = 42/);
|
||||
assert.match(gradle, /versionName = "0\.19\.14"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { AssetMasterModule } from '../../src/asset-master/asset-master.module';
|
||||
import { InventoryFunctionService } from '../../src/asset-master/inventory-function.service';
|
||||
|
||||
test('F5 AssetMasterModule keeps dossier-only function dependency injectable without reopening its controller', () => {
|
||||
test('F5 dossier dependency remains injectable and F6.18 reopens the Function catalog controller', () => {
|
||||
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AssetMasterModule) ?? []) as unknown[];
|
||||
const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>;
|
||||
const dossierSource = readFileSync(
|
||||
@@ -25,7 +25,7 @@ test('F5 AssetMasterModule keeps dossier-only function dependency injectable wit
|
||||
);
|
||||
assert.equal(
|
||||
controllers.some((controller) => controller?.name === 'InventoryFunctionController'),
|
||||
false,
|
||||
'F5 must not reopen the retired Inventory Function controller',
|
||||
true,
|
||||
'F6.18 explicitly reopens InventoryFunctionController as the authoritative Function catalog',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.17/);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.18/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
const root = (path: string) => readFileSync(resolve(process.cwd(), '..', path), 'utf8');
|
||||
|
||||
test('F6.18 reuses one auditable Function catalog for Installation and Subinstallation', () => {
|
||||
const module = api('src/asset-master/asset-master.module.ts');
|
||||
const service = api('src/asset-master/inventory-function.service.ts');
|
||||
const migration = api('src/database/migrations/1790146200000-f6-18-function-catalog.ts');
|
||||
const app = root('web-v2/src/app/App.tsx');
|
||||
const nav = root('web-v2/src/layout/AppLayout.tsx');
|
||||
assert.match(module, /InventoryFunctionController/);
|
||||
assert.match(service, /value === 'instalacion' \|\| value === 'subinstalacion'/);
|
||||
assert.match(service, /newFunctionName/);
|
||||
assert.match(service, /FIELD_\$\{slug\}_\$\{digest\}/);
|
||||
assert.match(migration, /definition\.code='campo_funcion'/);
|
||||
assert.match(migration, /inventory_function_assignments/);
|
||||
assert.match(migration, /is_active=false,is_required=false/);
|
||||
assert.match(app, /admin\/inventory-functions/);
|
||||
assert.match(nav, /Catálogo de funciones/);
|
||||
});
|
||||
|
||||
test('F6.18 makes habitual name mandatory and technical name optional for technical Inventory', () => {
|
||||
const createDto = api('src/asset-master/dto/create-asset.dto.ts');
|
||||
const fieldDto = api('src/inspection-visits/dto/create-field-inventory.dto.ts');
|
||||
const service = api('src/asset-master/assets.service.ts');
|
||||
const editor = root('web-v2/src/pages/AssetEditorPage.tsx');
|
||||
const mobile = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||
assert.match(createDto, /name\?: string \| null/);
|
||||
assert.match(fieldDto, /name\?: string \| null/);
|
||||
assert.match(service, /ASSET_COMMON_NAME_REQUIRED/);
|
||||
assert.match(service, /technicalName \|\| habitualName/);
|
||||
assert.match(editor, /Nombre habitual/);
|
||||
assert.match(editor, /Nombre técnico <em>opcional<\/em>/);
|
||||
assert.match(mobile, /Nombre habitual \*/);
|
||||
assert.match(mobile, /Nombre técnico \(opcional\)/);
|
||||
});
|
||||
|
||||
test('F6.18 APK can select a catalog Function or add Otro and keeps it in the offline queue', () => {
|
||||
const data = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
|
||||
const mobile = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
|
||||
const queue = root('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
|
||||
assert.match(data, /@GET\("inventory-functions"\)/);
|
||||
assert.match(data, /val functionId: String\? = null/);
|
||||
assert.match(data, /val newFunctionName: String\? = null/);
|
||||
assert.match(mobile, /Otro · agregar función/);
|
||||
assert.match(mobile, /Nueva función \*/);
|
||||
assert.match(queue, /put\("functionId", request\.functionId\)/);
|
||||
assert.match(queue, /put\("newFunctionName", request\.newFunctionName\)/);
|
||||
});
|
||||
|
||||
test('F6.18 freezes every completed technical datum except Inventory description into the Acta', () => {
|
||||
const closing = api('src/inspection-closing/inspection-closing.service.ts');
|
||||
const pdf = api('src/inspection-reports/inspection-act-pdf-builder.ts');
|
||||
assert.match(closing, /inventory_function_assignments/);
|
||||
assert.match(closing, /AS "function"/);
|
||||
assert.match(closing, /AS attributes/);
|
||||
assert.match(closing, /definition\.code<>'campo_funcion'/);
|
||||
assert.doesNotMatch(closing, /selected\.description/);
|
||||
assert.match(pdf, /label\('Nombre habitual'/);
|
||||
assert.match(pdf, /label\('Nombre técnico'/);
|
||||
assert.match(pdf, /label\('Código DH'/);
|
||||
assert.match(pdf, /label\('Función'/);
|
||||
assert.match(pdf, /technicalAttributes/);
|
||||
assert.doesNotMatch(pdf, /inventory\.description/);
|
||||
});
|
||||
Reference in New Issue
Block a user