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

This commit is contained in:
ChatGPT DH
2026-09-17 08:26:19 -03:00
parent 1beba14c3b
commit d623f235d4
41 changed files with 671 additions and 108 deletions
+2 -2
View File
@@ -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
View File
@@ -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,
+47 -5
View File
@@ -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,
+2 -2
View File
@@ -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"/);
+3 -3
View File
@@ -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');
});
+2 -2
View File
@@ -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/);
});