From d623f235d4b396faa1ca4036a86715bfdd79d595 Mon Sep 17 00:00:00 2001 From: ChatGPT DH Date: Thu, 17 Sep 2026 08:26:19 -0300 Subject: [PATCH] feat(inventory): add function catalog and act data --- android-app/RELEASE.md | 8 ++ android-app/app/build.gradle.kts | 4 +- .../korexlabs/dhinspeccion/MainViewModel.kt | 27 +++- .../korexlabs/dhinspeccion/data/DhMobile.kt | 26 +++- .../dhinspeccion/data/offline/OfflineQueue.kt | 6 +- .../dhinspeccion/ui/ModernVisitRoot.kt | 115 ++++++++++++++++-- .../dhinspeccion/ReleaseMetadataTest.kt | 4 +- api-v3/package-lock.json | 4 +- api-v3/package.json | 2 +- .../src/asset-master/asset-master.module.ts | 2 + api-v3/src/asset-master/assets.service.ts | 52 +++++++- .../src/asset-master/dto/create-asset.dto.ts | 10 +- .../dto/create-field-discovery.dto.ts | 16 ++- .../dto/inventory-function.dto.ts | 3 +- .../src/asset-master/dto/update-asset.dto.ts | 5 +- .../inventory-function.service.ts | 103 ++++++++++++++-- .../1790146200000-f6-18-function-catalog.ts | 87 +++++++++++++ .../inspection-closing.service.ts | 19 ++- .../inspection-act-pdf-builder.ts | 29 ++++- .../dto/create-field-inventory.dto.ts | 16 ++- .../field-inventory.service.ts | 4 +- api-v3/src/version.ts | 4 +- .../unit/act-findings-only-regression.test.ts | 2 +- api-v3/test/unit/f4-health-metadata.test.ts | 6 +- api-v3/test/unit/f5-android-test-cut.test.ts | 4 +- .../test/unit/f5-runtime-di-contract.test.ts | 6 +- .../f6-1-presentation-ready-contract.test.ts | 2 +- .../f6-18-function-catalog-act-data.test.ts | 69 +++++++++++ docs/PHASE_F6_18_FUNCTION_CATALOG.md | 5 + web-v2/package-lock.json | 4 +- web-v2/package.json | 2 +- web-v2/src/app/App.tsx | 2 + web-v2/src/config/version.ts | 4 +- .../src/features/assets/AssetHistoryPanel.tsx | 2 +- .../assets/InventoryFunctionPanel.tsx | 4 +- web-v2/src/layout/AppLayout.tsx | 1 + web-v2/src/lib/api.ts | 1 + web-v2/src/lib/inventoryFunctionApi.ts | 2 +- web-v2/src/pages/AssetEditorPage.tsx | 112 +++++++++++------ web-v2/src/pages/DashboardPage.tsx | 1 + web-v2/src/pages/InventoryFunctionsPage.tsx | 4 +- 41 files changed, 671 insertions(+), 108 deletions(-) create mode 100644 api-v3/src/database/migrations/1790146200000-f6-18-function-catalog.ts create mode 100644 api-v3/test/unit/f6-18-function-catalog-act-data.test.ts create mode 100644 docs/PHASE_F6_18_FUNCTION_CATALOG.md diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 4d891d9..4ac4e52 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -168,3 +168,11 @@ Esta candidata requiere conexión. No implementa trabajo offline ni cola persist - El Yacimiento elegido queda congelado como alcance de la Inspección y del Acta. - Los Hallazgos pueden registrarse sobre ese Yacimiento, sus Instalaciones o sus Subinstalaciones; nunca fuera de esa rama. - Al abrir una Inspección que ya está en curso, la APK entra directamente a Actas y Hallazgos. + + +## F6.18 · catálogo de funciones e identidad documental + +- `versionName`: **0.19.14**; `versionCode`: **42**. +- Nombre habitual obligatorio para Instalaciones/Subinstalaciones; Nombre técnico opcional. +- Función seleccionable desde el catálogo común; `Otro` permite incorporar una función faltante y conservarla para usos posteriores. +- La cola offline conserva `functionId` o `newFunctionName` hasta sincronizar. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 4475c25..0337e09 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 41 - versionName = "0.19.13" + versionCode = 42 + versionName = "0.19.14" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 279adf9..d23e4fb 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -19,6 +19,7 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse import com.korexlabs.dhinspeccion.data.FieldFindingsRepository import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.InventoryFunctionOption import com.korexlabs.dhinspeccion.data.MobileActClosure import com.korexlabs.dhinspeccion.data.MobileActClosureHeader import com.korexlabs.dhinspeccion.data.MobileActDetail @@ -72,6 +73,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private set var fieldTypes: List by mutableStateOf(emptyList()) private set + var inventoryFunctions: List by mutableStateOf(emptyList()) + private set var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) private set @@ -296,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + fun loadInventoryFunctions() { + launchBusy { + inventoryFunctions = repository.inventoryFunctions().data + } + } + fun loadFieldTypes(parentId: String? = null) { val currentVisit = visit ?: return launchBusy { @@ -372,13 +381,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { type: FieldType, parentId: String?, familyId: String?, - name: String, + name: String?, commonName: String?, attributes: Map, latitude: Double, longitude: Double, accuracyM: Double?, description: String? = null, + functionId: String? = null, + newFunctionName: String? = null, ) { val visitId = visit?.id ?: return if (visit?.status != "IN_PROGRESS") { @@ -389,6 +400,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Elegí el Tipo de instalación o subinstalación correspondiente." return } + if (commonName.isNullOrBlank()) { + error = "Completá el Nombre habitual. Es obligatorio y se utilizará en el Acta." + return + } + if (functionId != null && !newFunctionName.isNullOrBlank()) { + error = "Elegí una función del catálogo o cargá Otra, no ambas." + return + } launchBusy(mutation = true) { val assetId = DhRepository.newOperationId() val capturedAt = Instant.now().toString() @@ -397,8 +416,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { typeId = type.id, parentId = parentId, familyId = familyId, - name = name.trim(), + name = name?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, + functionId = functionId, + newFunctionName = newFunctionName?.trim()?.takeIf { it.isNotBlank() }, description = description?.trim()?.takeIf { it.isNotBlank() }, attributes = attributes, deviceLatitude = FieldCoordinates.latitude(latitude), @@ -410,7 +431,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val localAsset = FieldInventoryItem( id = assetId, code = "PEND-${assetId.take(6).uppercase()}", - name = request.name, + name = request.name ?: request.commonName ?: "Sin denominación", commonName = request.commonName, informationStatus = "DRAFT", dataOrigin = "FIELD_SURVEY", diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt index 771e279..98b10f3 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt @@ -256,14 +256,27 @@ data class FieldAssetDetail( val capture: CaptureStatus = CaptureStatus(), ) +data class InventoryFunctionOption( + val id: String, + val code: String, + val name: String, + val description: String? = null, +) + +data class InventoryFunctionListResponse( + val data: List = emptyList(), +) + data class CreateFieldInventoryRequest( val clientGeneratedId: String? = null, val typeId: String, val parentId: String? = null, val familyId: String? = null, val code: String? = null, - val name: String, + val name: String? = null, val commonName: String? = null, + val functionId: String? = null, + val newFunctionName: String? = null, val description: String? = null, val discoveryNotes: String? = null, val attributes: Map, @@ -336,6 +349,11 @@ interface DhApi { @Query("parentId") parentId: String? = null, ): FieldTypeResponse + @GET("inventory-functions") + suspend fun inventoryFunctions( + @Header("Authorization") authorization: String, + ): InventoryFunctionListResponse + @POST("inspection-visits/{visitId}/field-inventory/{assetId}/select") suspend fun selectFieldAsset( @Header("Authorization") authorization: String, @@ -461,6 +479,7 @@ class DhRepository(context: Context) { private val visitAdapter = moshi.adapter(VisitDetail::class.java) private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java) private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java) + private val inventoryFunctionAdapter = moshi.adapter(InventoryFunctionListResponse::class.java) private val api: DhApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(OkHttpClient.Builder().build()) @@ -521,6 +540,11 @@ class DhRepository(context: Context) { authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) } } + suspend fun inventoryFunctions(): InventoryFunctionListResponse = + cached("inventory-functions", inventoryFunctionAdapter) { + authorized { session -> api.inventoryFunctions("Bearer ${session.accessToken}") } + } + suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session -> api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId) } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt index 0c98631..ff3cc67 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt @@ -205,6 +205,8 @@ class OfflineMutationQueue(private val context: Context) { .put("code", request.code) .put("name", request.name) .put("commonName", request.commonName) + .put("functionId", request.functionId) + .put("newFunctionName", request.newFunctionName) .put("description", request.description) .put("discoveryNotes", request.discoveryNotes) .put("attributes", JSONObject(request.attributes)) @@ -424,8 +426,10 @@ class OfflineMutationQueue(private val context: Context) { parentId = payload.optNullableString("parentId"), familyId = payload.optNullableString("familyId"), code = payload.optNullableString("code"), - name = payload.getString("name"), + name = payload.optNullableString("name"), commonName = payload.optNullableString("commonName"), + functionId = payload.optNullableString("functionId"), + newFunctionName = payload.optNullableString("newFunctionName"), description = payload.optNullableString("description"), discoveryNotes = payload.optNullableString("discoveryNotes"), attributes = jsonObjectToMap(payload.getJSONObject("attributes")), diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt index 12d73ec..84a14f3 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt @@ -287,6 +287,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) var name by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var description by rememberSaveable(visit.id) { mutableStateOf("") } + var selectedFunctionId by rememberSaveable(visit.id) { mutableStateOf(null) } + var functionSearch by rememberSaveable(visit.id) { mutableStateOf("") } + var functionExpanded by remember { mutableStateOf(false) } + var useNewFunction by rememberSaveable(visit.id) { mutableStateOf(false) } + var newFunctionName by rememberSaveable(visit.id) { mutableStateOf("") } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf(null) } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } @@ -304,6 +309,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) name = "" commonName = "" description = "" + selectedFunctionId = null + functionSearch = "" + functionExpanded = false + useNewFunction = false + newFunctionName = "" selectedTypeId = null selectedFamilyId = null familySearch = "" @@ -356,6 +366,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) LaunchedEffect(visit.id) { model.searchInventory("", visit.scopeAsset?.id) + model.loadInventoryFunctions() } LaunchedEffect(modeName, parentSearch) { @@ -393,7 +404,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } val editableAttributes = selectedType?.attributes.orEmpty().filterNot { - selectedType?.familyRequired == true && it.code == "tipo_instalacion" + it.code == "campo_funcion" || (selectedType?.familyRequired == true && it.code == "tipo_instalacion") } val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId } val filteredFamilies = remember(selectedType, familySearch) { @@ -403,6 +414,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } } + val selectedFunction = model.inventoryFunctions.firstOrNull { it.id == selectedFunctionId } + val filteredFunctions = remember(model.inventoryFunctions, functionSearch) { + val needle = modernNormalize(functionSearch) + model.inventoryFunctions.filter { item -> + needle.isBlank() || modernNormalize("${item.code} ${item.name}").contains(needle) + } + } + var pendingPhotoFile by remember { mutableStateOf(null) } var pendingPhotoGeo by remember { mutableStateOf(null) } @@ -443,8 +462,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) type = type, parentId = effectiveParent, familyId = selectedFamilyId, - name = name, + name = name.takeIf { it.isNotBlank() }, commonName = commonName, + functionId = if (useNewFunction) null else selectedFunctionId, + newFunctionName = if (useNewFunction) newFunctionName else null, description = description, attributes = buildModernAttributes(type, attributeValues), latitude = geo.latitude, @@ -823,24 +844,98 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Nombre técnico *") }, - placeholder = { Text("Denominación técnica") }, + value = commonName, + onValueChange = { commonName = it }, + label = { Text("Nombre habitual *") }, + supportingText = { Text("Es el nombre que se mostrará en el Acta.") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), ) OutlinedTextField( - value = commonName, - onValueChange = { commonName = it }, - label = { Text("Nombre habitual (opcional)") }, + value = name, + onValueChange = { name = it }, + label = { Text("Nombre técnico (opcional)") }, + placeholder = { Text("Denominación técnica, si corresponde") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), ) + + ExposedDropdownMenuBox( + expanded = functionExpanded && !model.busy, + onExpandedChange = { + if (!model.busy) { + functionExpanded = it + if (it) functionSearch = "" + } + }, + ) { + OutlinedTextField( + value = if (functionExpanded) functionSearch else if (useNewFunction) "Otra función" else selectedFunction?.name.orEmpty(), + onValueChange = { functionSearch = it; functionExpanded = true }, + label = { Text("Función (opcional)") }, + placeholder = { Text("Buscar función") }, + leadingIcon = { Icon(Icons.Filled.Search, null) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = functionExpanded) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable), + singleLine = true, + ) + ExposedDropdownMenu( + expanded = functionExpanded && !model.busy, + onDismissRequest = { functionExpanded = false; functionSearch = "" }, + modifier = Modifier.heightIn(max = 280.dp), + ) { + DropdownMenuItem( + text = { Text("Sin función") }, + onClick = { + selectedFunctionId = null + useNewFunction = false + newFunctionName = "" + functionExpanded = false + }, + ) + filteredFunctions.forEach { item -> + DropdownMenuItem( + text = { Text(item.name) }, + trailingIcon = { if (!useNewFunction && selectedFunctionId == item.id) Icon(Icons.Filled.CheckCircle, "Seleccionada") }, + onClick = { + selectedFunctionId = item.id + useNewFunction = false + newFunctionName = "" + functionExpanded = false + functionSearch = "" + }, + ) + } + DropdownMenuItem( + text = { Text("Otro · agregar función") }, + trailingIcon = { if (useNewFunction) Icon(Icons.Filled.CheckCircle, "Seleccionada") }, + onClick = { + selectedFunctionId = null + useNewFunction = true + functionExpanded = false + functionSearch = "" + }, + ) + } + } + if (useNewFunction) { + OutlinedTextField( + value = newFunctionName, + onValueChange = { newFunctionName = it }, + label = { Text("Nueva función *") }, + supportingText = { Text("Se incorporará al Catálogo de funciones al sincronizar.") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + ) + } + OutlinedTextField( value = description, onValueChange = { description = it }, @@ -896,7 +991,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null Button( onClick = { requestCreate() }, - enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy, + enabled = selectedType != null && commonName.isNotBlank() && (!useNewFunction || newFunctionName.isNotBlank()) && attributesReady && familyReady && !model.busy, modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Filled.CameraAlt, null) diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index bfb3c9b..11a6326 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(41, BuildConfig.VERSION_CODE) - assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME) + assertEquals(42, BuildConfig.VERSION_CODE) + assertEquals("0.19.14-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 51dffeb..0c40328 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -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", diff --git a/api-v3/package.json b/api-v3/package.json index 555413a..df69a48 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-17", + "version": "0.29.0-18", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index 0ca9008..8b28136 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -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, diff --git a/api-v3/src/asset-master/assets.service.ts b/api-v3/src/asset-master/assets.service.ts index a2ebb75..1e18d17 100644 --- a/api-v3/src/asset-master/assets.service.ts +++ b/api-v3/src/asset-master/assets.service.ts @@ -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; diff --git a/api-v3/src/asset-master/dto/create-asset.dto.ts b/api-v3/src/asset-master/dto/create-asset.dto.ts index deb68f0..b9e1f7b 100644 --- a/api-v3/src/asset-master/dto/create-asset.dto.ts +++ b/api-v3/src/asset-master/dto/create-asset.dto.ts @@ -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; diff --git a/api-v3/src/asset-master/dto/create-field-discovery.dto.ts b/api-v3/src/asset-master/dto/create-field-discovery.dto.ts index 1d771c5..c80c954 100644 --- a/api-v3/src/asset-master/dto/create-field-discovery.dto.ts +++ b/api-v3/src/asset-master/dto/create-field-discovery.dto.ts @@ -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; diff --git a/api-v3/src/asset-master/dto/inventory-function.dto.ts b/api-v3/src/asset-master/dto/inventory-function.dto.ts index b47e34a..dfb3fd4 100644 --- a/api-v3/src/asset-master/dto/inventory-function.dto.ts +++ b/api-v3/src/asset-master/dto/inventory-function.dto.ts @@ -64,8 +64,9 @@ export class UpdateInventoryFunctionDto { } export class ChangeInventoryFunctionDto { + @IsOptional() @IsUUID('4') - functionId!: string; + functionId?: string | null; @IsOptional() @IsISO8601({ strict: true }) diff --git a/api-v3/src/asset-master/dto/update-asset.dto.ts b/api-v3/src/asset-master/dto/update-asset.dto.ts index a91671a..73f0b8b 100644 --- a/api-v3/src/asset-master/dto/update-asset.dto.ts +++ b/api-v3/src/asset-master/dto/update-asset.dto.ts @@ -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 }) => diff --git a/api-v3/src/asset-master/inventory-function.service.ts b/api-v3/src/asset-master/inventory-function.service.ts index f0a1e39..7055a5b 100644 --- a/api-v3/src/asset-master/inventory-function.service.ts +++ b/api-v3/src/asset-master/inventory-function.service.ts @@ -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 { + 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, + }, 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; } } diff --git a/api-v3/src/database/migrations/1790146200000-f6-18-function-catalog.ts b/api-v3/src/database/migrations/1790146200000-f6-18-function-catalog.ts new file mode 100644 index 0000000..4520d00 --- /dev/null +++ b/api-v3/src/database/migrations/1790146200000-f6-18-function-catalog.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F618FunctionCatalog1790146200000 implements MigrationInterface { + name = 'F618FunctionCatalog1790146200000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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) + `); + } +} diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index b98e332..e9750f8 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -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>; diff --git a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts index ad89fe1..d78a0a8 100644 --- a/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts +++ b/api-v3/src/inspection-reports/inspection-act-pdf-builder.ts @@ -36,6 +36,17 @@ function asArray(value: unknown): Array> { 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ó'); diff --git a/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts b/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts index 1d79f09..8f07c7f 100644 --- a/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts +++ b/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts @@ -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() diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts index 6d49458..43479f9 100644 --- a/api-v3/src/inspection-visits/field-inventory.service.ts +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -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, diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 4579b0f..e543af0 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -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'; diff --git a/api-v3/test/unit/act-findings-only-regression.test.ts b/api-v3/test/unit/act-findings-only-regression.test.ts index 3abac9b..f0573ea 100644 --- a/api-v3/test/unit/act-findings-only-regression.test.ts +++ b/api-v3/test/unit/act-findings-only-regression.test.ts @@ -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"/); diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 2609510..78ab9b0 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -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'); }); diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index 8fbbf92..af19199 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -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"/); }); diff --git a/api-v3/test/unit/f5-runtime-di-contract.test.ts b/api-v3/test/unit/f5-runtime-di-contract.test.ts index 462c531..375a5b7 100644 --- a/api-v3/test/unit/f5-runtime-di-contract.test.ts +++ b/api-v3/test/unit/f5-runtime-di-contract.test.ts @@ -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', ); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index 5d28c3a..d01411b 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -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', () => { diff --git a/api-v3/test/unit/f6-18-function-catalog-act-data.test.ts b/api-v3/test/unit/f6-18-function-catalog-act-data.test.ts new file mode 100644 index 0000000..d7ecf38 --- /dev/null +++ b/api-v3/test/unit/f6-18-function-catalog-act-data.test.ts @@ -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 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/); +}); diff --git a/docs/PHASE_F6_18_FUNCTION_CATALOG.md b/docs/PHASE_F6_18_FUNCTION_CATALOG.md new file mode 100644 index 0000000..12c6438 --- /dev/null +++ b/docs/PHASE_F6_18_FUNCTION_CATALOG.md @@ -0,0 +1,5 @@ +# F6.18 · Catálogo de funciones e identidad documental + +Alcance exclusivo del Inventario técnico: Nombre habitual obligatorio, Nombre técnico opcional, campos técnicos opcionales y Función catalogada para Instalaciones/Subinstalaciones. + +La migración conserva valores históricos de `campo_funcion`, los convierte en catálogo + asignación temporal y retira el texto libre como fuente de verdad. La APK reutiliza el catálogo y puede incorporar `Otro` de forma auditada. El Acta congela y muestra todos los datos completados del elemento con Hallazgo, excepto la Descripción del Inventario. diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index f8b0a51..0a078d1 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-13", + "version": "0.23.0-14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-13", + "version": "0.23.0-14", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index f313649..5b9ccd2 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-13", + "version": "0.23.0-14", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index 08bf1f9..d856bc6 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -35,6 +35,7 @@ import { VerificationPlanningPage } from '../pages/VerificationPlanningPage'; import { ActAdministrationPage } from '../pages/ActAdministrationPage'; import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage'; import { FieldBriefingsPage } from '../pages/FieldBriefingsPage'; +import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage'; const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage }))); const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage }))); @@ -82,6 +83,7 @@ export function App() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> } /> diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 5e90114..242d230 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.23.0-13'; -export const APP_PHASE = 'F6.17 · Tablas ordenables'; +export const APP_VERSION = '0.23.0-14'; +export const APP_PHASE = 'F6.18 · Catálogo de funciones'; diff --git a/web-v2/src/features/assets/AssetHistoryPanel.tsx b/web-v2/src/features/assets/AssetHistoryPanel.tsx index 2896bef..126cf33 100644 --- a/web-v2/src/features/assets/AssetHistoryPanel.tsx +++ b/web-v2/src/features/assets/AssetHistoryPanel.tsx @@ -29,7 +29,7 @@ function normalized(value: string): string { function supportsFunctionChange(code: string, name: string): boolean { const value = `${normalized(code)} ${normalized(name)}`; - return value.split(' ').some((part) => part === 'estacion' || part === 'subestacion'); + return value.split(' ').some((part) => part === 'instalacion' || part === 'subinstalacion'); } export function AssetHistoryPanel({ diff --git a/web-v2/src/features/assets/InventoryFunctionPanel.tsx b/web-v2/src/features/assets/InventoryFunctionPanel.tsx index 73a48af..c8201a7 100644 --- a/web-v2/src/features/assets/InventoryFunctionPanel.tsx +++ b/web-v2/src/features/assets/InventoryFunctionPanel.tsx @@ -85,7 +85,7 @@ export function InventoryFunctionPanel({ assetId }: { assetId: string }) {
FUNCIÓN OPERATIVA -

Función de la Estación / Subestación

+

Función de la Instalación / Subinstalación

La identidad del Inventario no cambia. Cada mutación de función conserva fecha, usuario y antecedente para la consulta histórica.

{canManageCatalog && Administrar catálogo} @@ -110,7 +110,7 @@ export function InventoryFunctionPanel({ assetId }: { assetId: string }) { {canChange &&

Cambiar función

-

Ejemplo: Bombeo Mecánico AIB → Bombeo Mecánico Rotaflex. No se crea una Estación nueva.

+

La Función cambia sin crear un nuevo registro de Inventario y conserva su historial.

}
} + {technicalInventory && } +