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
+8
View File
@@ -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. - 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. - 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. - 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.
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 41 versionCode = 42
versionName = "0.19.13" versionName = "0.19.14"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -19,6 +19,7 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.InventoryFunctionOption
import com.korexlabs.dhinspeccion.data.MobileActClosure import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
import com.korexlabs.dhinspeccion.data.MobileActDetail import com.korexlabs.dhinspeccion.data.MobileActDetail
@@ -72,6 +73,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList()) var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set private set
var inventoryFunctions: List<InventoryFunctionOption> by mutableStateOf(emptyList())
private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set private set
@@ -296,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun loadInventoryFunctions() {
launchBusy {
inventoryFunctions = repository.inventoryFunctions().data
}
}
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
@@ -372,13 +381,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
type: FieldType, type: FieldType,
parentId: String?, parentId: String?,
familyId: String?, familyId: String?,
name: String, name: String?,
commonName: String?, commonName: String?,
attributes: Map<String, Any?>, attributes: Map<String, Any?>,
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
description: String? = null, description: String? = null,
functionId: String? = null,
newFunctionName: String? = null,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") { 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." error = "Elegí el Tipo de instalación o subinstalación correspondiente."
return 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) { launchBusy(mutation = true) {
val assetId = DhRepository.newOperationId() val assetId = DhRepository.newOperationId()
val capturedAt = Instant.now().toString() val capturedAt = Instant.now().toString()
@@ -397,8 +416,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
typeId = type.id, typeId = type.id,
parentId = parentId, parentId = parentId,
familyId = familyId, familyId = familyId,
name = name.trim(), name = name?.trim()?.takeIf { it.isNotBlank() },
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
functionId = functionId,
newFunctionName = newFunctionName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() }, description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = FieldCoordinates.latitude(latitude), deviceLatitude = FieldCoordinates.latitude(latitude),
@@ -410,7 +431,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val localAsset = FieldInventoryItem( val localAsset = FieldInventoryItem(
id = assetId, id = assetId,
code = "PEND-${assetId.take(6).uppercase()}", code = "PEND-${assetId.take(6).uppercase()}",
name = request.name, name = request.name ?: request.commonName ?: "Sin denominación",
commonName = request.commonName, commonName = request.commonName,
informationStatus = "DRAFT", informationStatus = "DRAFT",
dataOrigin = "FIELD_SURVEY", dataOrigin = "FIELD_SURVEY",
@@ -256,14 +256,27 @@ data class FieldAssetDetail(
val capture: CaptureStatus = CaptureStatus(), 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<InventoryFunctionOption> = emptyList(),
)
data class CreateFieldInventoryRequest( data class CreateFieldInventoryRequest(
val clientGeneratedId: String? = null, val clientGeneratedId: String? = null,
val typeId: String, val typeId: String,
val parentId: String? = null, val parentId: String? = null,
val familyId: String? = null, val familyId: String? = null,
val code: String? = null, val code: String? = null,
val name: String, val name: String? = null,
val commonName: String? = null, val commonName: String? = null,
val functionId: String? = null,
val newFunctionName: String? = null,
val description: String? = null, val description: String? = null,
val discoveryNotes: String? = null, val discoveryNotes: String? = null,
val attributes: Map<String, Any?>, val attributes: Map<String, Any?>,
@@ -336,6 +349,11 @@ interface DhApi {
@Query("parentId") parentId: String? = null, @Query("parentId") parentId: String? = null,
): FieldTypeResponse ): FieldTypeResponse
@GET("inventory-functions")
suspend fun inventoryFunctions(
@Header("Authorization") authorization: String,
): InventoryFunctionListResponse
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select") @POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
suspend fun selectFieldAsset( suspend fun selectFieldAsset(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -461,6 +479,7 @@ class DhRepository(context: Context) {
private val visitAdapter = moshi.adapter(VisitDetail::class.java) private val visitAdapter = moshi.adapter(VisitDetail::class.java)
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java) private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java) private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
private val inventoryFunctionAdapter = moshi.adapter(InventoryFunctionListResponse::class.java)
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build()) .client(OkHttpClient.Builder().build())
@@ -521,6 +540,11 @@ class DhRepository(context: Context) {
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) } 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 -> suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId) api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
} }
@@ -205,6 +205,8 @@ class OfflineMutationQueue(private val context: Context) {
.put("code", request.code) .put("code", request.code)
.put("name", request.name) .put("name", request.name)
.put("commonName", request.commonName) .put("commonName", request.commonName)
.put("functionId", request.functionId)
.put("newFunctionName", request.newFunctionName)
.put("description", request.description) .put("description", request.description)
.put("discoveryNotes", request.discoveryNotes) .put("discoveryNotes", request.discoveryNotes)
.put("attributes", JSONObject(request.attributes)) .put("attributes", JSONObject(request.attributes))
@@ -424,8 +426,10 @@ class OfflineMutationQueue(private val context: Context) {
parentId = payload.optNullableString("parentId"), parentId = payload.optNullableString("parentId"),
familyId = payload.optNullableString("familyId"), familyId = payload.optNullableString("familyId"),
code = payload.optNullableString("code"), code = payload.optNullableString("code"),
name = payload.getString("name"), name = payload.optNullableString("name"),
commonName = payload.optNullableString("commonName"), commonName = payload.optNullableString("commonName"),
functionId = payload.optNullableString("functionId"),
newFunctionName = payload.optNullableString("newFunctionName"),
description = payload.optNullableString("description"), description = payload.optNullableString("description"),
discoveryNotes = payload.optNullableString("discoveryNotes"), discoveryNotes = payload.optNullableString("discoveryNotes"),
attributes = jsonObjectToMap(payload.getJSONObject("attributes")), attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
@@ -287,6 +287,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var name by rememberSaveable(visit.id) { mutableStateOf("") } var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var description by rememberSaveable(visit.id) { mutableStateOf("") } var description by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedFunctionId by rememberSaveable(visit.id) { mutableStateOf<String?>(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<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") } var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
@@ -304,6 +309,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
name = "" name = ""
commonName = "" commonName = ""
description = "" description = ""
selectedFunctionId = null
functionSearch = ""
functionExpanded = false
useNewFunction = false
newFunctionName = ""
selectedTypeId = null selectedTypeId = null
selectedFamilyId = null selectedFamilyId = null
familySearch = "" familySearch = ""
@@ -356,6 +366,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
LaunchedEffect(visit.id) { LaunchedEffect(visit.id) {
model.searchInventory("", visit.scopeAsset?.id) model.searchInventory("", visit.scopeAsset?.id)
model.loadInventoryFunctions()
} }
LaunchedEffect(modeName, parentSearch) { LaunchedEffect(modeName, parentSearch) {
@@ -393,7 +404,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId } val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val editableAttributes = selectedType?.attributes.orEmpty().filterNot { 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 selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) { 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<File?>(null) } var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<ModernGeoSnapshot?>(null) } var pendingPhotoGeo by remember { mutableStateOf<ModernGeoSnapshot?>(null) }
@@ -443,8 +462,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
type = type, type = type,
parentId = effectiveParent, parentId = effectiveParent,
familyId = selectedFamilyId, familyId = selectedFamilyId,
name = name, name = name.takeIf { it.isNotBlank() },
commonName = commonName, commonName = commonName,
functionId = if (useNewFunction) null else selectedFunctionId,
newFunctionName = if (useNewFunction) newFunctionName else null,
description = description, description = description,
attributes = buildModernAttributes(type, attributeValues), attributes = buildModernAttributes(type, attributeValues),
latitude = geo.latitude, latitude = geo.latitude,
@@ -823,24 +844,98 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
OutlinedTextField( OutlinedTextField(
value = name, value = commonName,
onValueChange = { name = it }, onValueChange = { commonName = it },
label = { Text("Nombre técnico *") }, label = { Text("Nombre habitual *") },
placeholder = { Text("Denominación técnica") }, supportingText = { Text("Es el nombre que se mostrará en el Acta.") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
) )
OutlinedTextField( OutlinedTextField(
value = commonName, value = name,
onValueChange = { commonName = it }, onValueChange = { name = it },
label = { Text("Nombre habitual (opcional)") }, label = { Text("Nombre técnico (opcional)") },
placeholder = { Text("Denominación técnica, si corresponde") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), 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( OutlinedTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
@@ -896,7 +991,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button( Button(
onClick = { requestCreate() }, 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(), modifier = Modifier.fillMaxWidth(),
) { ) {
Icon(Icons.Filled.CameraAlt, null) Icon(Icons.Filled.CameraAlt, null)
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(41, BuildConfig.VERSION_CODE) assertEquals(42, BuildConfig.VERSION_CODE)
assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.14-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-17", "version": "0.29.0-18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-17", "version": "0.29.0-18",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-17", "version": "0.29.0-18",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -32,6 +32,7 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller'; import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service'; import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
import { InventoryFunctionService } from './inventory-function.service'; import { InventoryFunctionService } from './inventory-function.service';
import { InventoryFunctionController } from './inventory-function.controller';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service'; import { ActivityDossierService } from './activity-dossier.service';
@@ -47,6 +48,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryTechnicalValuesController, InventoryTechnicalValuesController,
InventoryFunctionController,
InventoryBrowserController, InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, 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 { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
import type { ChangeAssetContextDto } from './dto/change-asset-context.dto'; import type { ChangeAssetContextDto } from './dto/change-asset-context.dto';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryFunctionService } from './inventory-function.service';
export interface AssetListItem { export interface AssetListItem {
id: string; id: string;
@@ -97,9 +98,31 @@ export class AssetsService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly history: AssetHistoryService, private readonly history: AssetHistoryService,
private readonly inventoryFunctions: InventoryFunctionService,
private readonly fieldDiscoveryInspectionLinks: FieldDiscoveryInspectionLinkService, 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) { async list(query: ListAssetsQueryDto) {
const conditions: string[] = []; const conditions: string[] = [];
const parameters: unknown[] = []; const parameters: unknown[] = [];
@@ -627,6 +650,7 @@ export class AssetsService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const type = await this.requireActiveType(manager, dto.typeId); 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.validateParent(manager, type, dto.parentId ?? null, null);
await this.validateOperationalAssignment( await this.validateOperationalAssignment(
manager, manager,
@@ -644,8 +668,8 @@ export class AssetsService {
operationalAreaId: dto.operationalAreaId ?? null, operationalAreaId: dto.operationalAreaId ?? null,
operatorCompanyId: dto.operatorCompanyId ?? null, operatorCompanyId: dto.operatorCompanyId ?? null,
code: dto.code, code: dto.code,
name: dto.name, name: identity.persistedName,
commonName: dto.commonName ?? null, commonName: identity.habitualName,
description: dto.description ?? null, description: dto.description ?? null,
informationStatus: principal.permissions.includes('assets.change_status') informationStatus: principal.permissions.includes('assets.change_status')
? dto.informationStatus ? dto.informationStatus
@@ -676,6 +700,9 @@ export class AssetsService {
principal, principal,
request, 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'); await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto inicial del registro');
const created = await this.loadView(manager, asset.id); const created = await this.loadView(manager, asset.id);
await this.audit.record( 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' }); 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 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.validateParent(manager, type, dto.parentId, null);
await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId); await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId);
const definitions = await this.loadDefinitions(manager, type.id); const definitions = await this.loadDefinitions(manager, type.id);
@@ -763,8 +791,8 @@ export class AssetsService {
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
inventoryFamilyId: dto.inventoryFamilyId ?? null, inventoryFamilyId: dto.inventoryFamilyId ?? null,
code: dto.code, code: dto.code,
name: dto.name, name: identity.persistedName,
commonName: dto.commonName ?? null, commonName: identity.habitualName,
description: dto.description ?? null, description: dto.description ?? null,
informationStatus: AssetInformationStatus.DRAFT, informationStatus: AssetInformationStatus.DRAFT,
createdBy: principal.userId, createdBy: principal.userId,
@@ -779,6 +807,13 @@ export class AssetsService {
await manager.getRepository(Asset).save(asset); await manager.getRepository(Asset).save(asset);
await this.replaceAttributeValues(manager, asset.id, values, principal.userId); await this.replaceAttributeValues(manager, asset.id, values, principal.userId);
const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.CREATED, principal, request); 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'); await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto observado en alta de campo');
const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach( const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach(
manager, 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 (typeChanged) asset.assetTypeId = type.id;
if (dto.code !== undefined) asset.code = dto.code; 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.commonName !== undefined) asset.commonName = dto.commonName;
if (dto.parentId !== undefined) asset.parentId = dto.parentId; if (dto.parentId !== undefined) asset.parentId = dto.parentId;
if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId; if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId;
@@ -21,11 +21,11 @@ export class CreateAssetDto {
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code!: string; code!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@MinLength(1)
@MaxLength(200) @MaxLength(200)
name!: string; name?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>
@@ -35,6 +35,10 @@ export class CreateAssetDto {
@MaxLength(200) @MaxLength(200)
commonName?: string | null; commonName?: string | null;
@IsOptional()
@IsUUID('4')
functionId?: string | null;
@IsUUID('4') @IsUUID('4')
typeId!: string; typeId!: string;
@@ -16,11 +16,11 @@ export class CreateFieldDiscoveryDto {
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code!: string; code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value) @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@MinLength(1)
@MaxLength(200) @MaxLength(200)
name!: string; name?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@@ -28,6 +28,16 @@ export class CreateFieldDiscoveryDto {
@MaxLength(200) @MaxLength(200)
commonName?: string | null; 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') @IsUUID('4')
typeId!: string; typeId!: string;
@@ -64,8 +64,9 @@ export class UpdateInventoryFunctionDto {
} }
export class ChangeInventoryFunctionDto { export class ChangeInventoryFunctionDto {
@IsOptional()
@IsUUID('4') @IsUUID('4')
functionId!: string; functionId?: string | null;
@IsOptional() @IsOptional()
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
@@ -26,11 +26,10 @@ export class UpdateAssetDto {
code?: string; code?: string;
@IsOptional() @IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@MinLength(1)
@MaxLength(200) @MaxLength(200)
name?: string; name?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -98,19 +99,21 @@ export class InventoryFunctionService {
async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) { async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const asset = await this.requireEligibleAsset(manager, assetId, true); const asset = await this.requireEligibleAsset(manager, assetId, true);
const nextFunction = await this.requireFunction(manager, dto.functionId, true);
const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date(); 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 (!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' }); if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' });
const current = await this.currentAssignment(manager, assetId, true); const current = await this.currentAssignment(manager, assetId, true);
if (current?.functionId === nextFunction.id) 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()) { 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' }); 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]); if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
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)`, 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]); [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]); 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 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, ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
entityType: 'asset', entityId: assetId, entityType: 'asset', entityId: assetId,
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null }, 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 }, metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED },
}, manager); }, manager);
return after; 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) { private async getForAssetWithManager(manager: EntityManager, assetId: string) {
const asset = await this.requireEligibleAsset(manager, assetId, false); const asset = await this.requireEligibleAsset(manager, assetId, false);
const currentFunction = await this.currentAssignment(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' : ''} WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''}
`, [assetId]) as FunctionEligibleAsset[]; `, [assetId]) as FunctionEligibleAsset[];
if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' }); 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 values = [asset.typeCode, asset.typeName].map(normalized);
const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion')); const eligible = values.some((value) => value === 'instalacion' || value === 'subinstalacion');
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' }); 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; 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, type UploadedInspectionSignatureFile,
} from './inspection-signature-file'; } 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 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 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.'; 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", 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.type_code AS "typeCode",selected.type_name AS "typeName",
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName", 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 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 FROM selected ORDER BY selected.code,selected.id
`, [actId]) as Array<Record<string, unknown>>; `, [actId]) as Array<Record<string, unknown>>;
@@ -36,6 +36,17 @@ function asArray(value: unknown): Array<Record<string, unknown>> {
function text(value: unknown, fallback = ''): string { function text(value: unknown, fallback = ''): string {
return String(value ?? '').trim() || fallback; 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 { function date(value: unknown): string {
const parsed = new Date(String(value ?? '')); const parsed = new Date(String(value ?? ''));
return Number.isFinite(parsed.getTime()) return Number.isFinite(parsed.getTime())
@@ -228,8 +239,24 @@ export async function buildInspectionActPdf(
need(100); need(100);
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`); doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
doc.moveDown(0.2); 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(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); if (route) label('Ubicación / Ruta jerárquica', route);
label('Denominación del hallazgo', finding.title); label('Denominación del hallazgo', finding.title);
subheading('Qué se constató'); subheading('Qué se constató');
@@ -36,11 +36,11 @@ export class CreateFieldInventoryDto {
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string; code?: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value) @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@MinLength(1)
@MaxLength(200) @MaxLength(200)
name!: string; name?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@@ -48,6 +48,16 @@ export class CreateFieldInventoryDto {
@MaxLength(200) @MaxLength(200)
commonName?: string | null; 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() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@@ -306,8 +306,10 @@ export class FieldInventoryService {
clientGeneratedId: dto.clientGeneratedId, clientGeneratedId: dto.clientGeneratedId,
visitId, visitId,
code, code,
name: dto.name, name: dto.name ?? null,
commonName: dto.commonName ?? null, commonName: dto.commonName ?? null,
functionId: dto.functionId ?? null,
newFunctionName: dto.newFunctionName ?? null,
typeId: dto.typeId, typeId: dto.typeId,
parentId, parentId,
operationalAreaId: context.areaId, operationalAreaId: context.areaId,
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-17'; export const API_VERSION = '0.29.0-18';
export const API_PHASE = 'F6.17'; 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, /Alcance de la inspección/);
assert.match(pdf, /scopeTypeCode/); assert.match(pdf, /scopeTypeCode/);
assert.match(pdf, /Antecedente relacionado/); 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 department/);
assert.match(closing, /AS "leadInspector"/); assert.match(closing, /AS "leadInspector"/);
assert.match(closing, /AS "installationTypeName"/); assert.match(closing, /AS "installationTypeName"/);
+3 -3
View File
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.17 release', () => { test('health metadata reports the current F6.18 release', () => {
assert.equal(API_PHASE, 'F6.17'); assert.equal(API_PHASE, 'F6.18');
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
assert.equal(API_VERSION, pkg.version); 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', () => { 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'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 41/); assert.match(gradle, /versionCode = 42/);
assert.match(gradle, /versionName = "0\.19\.13"/); assert.match(gradle, /versionName = "0\.19\.14"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); 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 { AssetMasterModule } from '../../src/asset-master/asset-master.module';
import { InventoryFunctionService } from '../../src/asset-master/inventory-function.service'; 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 providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AssetMasterModule) ?? []) as unknown[];
const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>; const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>;
const dossierSource = readFileSync( const dossierSource = readFileSync(
@@ -25,7 +25,7 @@ test('F5 AssetMasterModule keeps dossier-only function dependency injectable wit
); );
assert.equal( assert.equal(
controllers.some((controller) => controller?.name === 'InventoryFunctionController'), controllers.some((controller) => controller?.name === 'InventoryFunctionController'),
false, true,
'F5 must not reopen the retired Inventory Function controller', '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]; const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version); 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', () => { 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/);
});
+5
View File
@@ -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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-13", "version": "0.23.0-14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-13", "version": "0.23.0-14",
"dependencies": { "dependencies": {
"maplibre-gl": "6.4.1", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-13", "version": "0.23.0-14",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+2
View File
@@ -35,6 +35,7 @@ import { VerificationPlanningPage } from '../pages/VerificationPlanningPage';
import { ActAdministrationPage } from '../pages/ActAdministrationPage'; import { ActAdministrationPage } from '../pages/ActAdministrationPage';
import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage'; import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage';
import { FieldBriefingsPage } from '../pages/FieldBriefingsPage'; import { FieldBriefingsPage } from '../pages/FieldBriefingsPage';
import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage';
const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage }))); const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage })));
const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage }))); const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage })));
@@ -82,6 +83,7 @@ export function App() {
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route> <Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route> <Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AuthoritativeInventoryConfigPage />} /></Route> <Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AuthoritativeInventoryConfigPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.manage" />}><Route path="/admin/inventory-functions" element={<InventoryFunctionsPage />} /></Route>
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route> <Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route> <Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
<Route path="/sin-acceso" element={<AccessDeniedPage />} /> <Route path="/sin-acceso" element={<AccessDeniedPage />} />
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-13'; export const APP_VERSION = '0.23.0-14';
export const APP_PHASE = 'F6.17 · Tablas ordenables'; export const APP_PHASE = 'F6.18 · Catálogo de funciones';
@@ -29,7 +29,7 @@ function normalized(value: string): string {
function supportsFunctionChange(code: string, name: string): boolean { function supportsFunctionChange(code: string, name: string): boolean {
const value = `${normalized(code)} ${normalized(name)}`; 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({ export function AssetHistoryPanel({
@@ -85,7 +85,7 @@ export function InventoryFunctionPanel({ assetId }: { assetId: string }) {
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<span className="eyebrow">FUNCIÓN OPERATIVA</span> <span className="eyebrow">FUNCIÓN OPERATIVA</span>
<h2>Función de la Estación / Subestación</h2> <h2>Función de la Instalación / Subinstalación</h2>
<p className="section-copy">La identidad del Inventario no cambia. Cada mutación de función conserva fecha, usuario y antecedente para la consulta histórica.</p> <p className="section-copy">La identidad del Inventario no cambia. Cada mutación de función conserva fecha, usuario y antecedente para la consulta histórica.</p>
</div> </div>
{canManageCatalog && <Link className="button secondary" to="/admin/inventory-functions">Administrar catálogo</Link>} {canManageCatalog && <Link className="button secondary" to="/admin/inventory-functions">Administrar catálogo</Link>}
@@ -110,7 +110,7 @@ export function InventoryFunctionPanel({ assetId }: { assetId: string }) {
{canChange && <form className="form-section" onSubmit={submit}> {canChange && <form className="form-section" onSubmit={submit}>
<div> <div>
<h3>Cambiar función</h3> <h3>Cambiar función</h3>
<p className="section-copy">Ejemplo: Bombeo Mecánico AIB Bombeo Mecánico Rotaflex. No se crea una Estación nueva.</p> <p className="section-copy">La Función cambia sin crear un nuevo registro de Inventario y conserva su historial.</p>
</div> </div>
<div className="form-grid"> <div className="form-grid">
<label className="field"> <label className="field">
+1
View File
@@ -35,6 +35,7 @@ const administration: NavItem[] = [
{ to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' }, { to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' },
{ to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' }, { to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' },
{ to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' }, { to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' },
{ to: '/admin/inventory-functions', label: 'Catálogo de funciones', icon: 'layers', permission: 'asset_types.manage' },
{ to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' }, { to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' },
{ to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' }, { to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' },
]; ];
+1
View File
@@ -2061,6 +2061,7 @@ export function createAsset(input: {
code: string; code: string;
name: string; name: string;
commonName?: string | null; commonName?: string | null;
functionId?: string | null;
typeId: string; typeId: string;
parentId?: string | null; parentId?: string | null;
operationalAreaId?: string | null; operationalAreaId?: string | null;
+1 -1
View File
@@ -50,7 +50,7 @@ export function getInventoryFunctionHistory(assetId: string) {
export function changeInventoryFunction( export function changeInventoryFunction(
assetId: string, assetId: string,
input: { functionId: string; effectiveAt?: string; reason?: string | null }, input: { functionId: string | null; effectiveAt?: string; reason?: string | null },
) { ) {
return apiRequest<InventoryFunctionHistory>(`/assets/${assetId}/function`, { return apiRequest<InventoryFunctionHistory>(`/assets/${assetId}/function`, {
method: 'POST', method: 'POST',
+78 -34
View File
@@ -33,6 +33,8 @@ import {
updateAssetInformationStatus, updateAssetInformationStatus,
updateAssetOperationalStatus, updateAssetOperationalStatus,
} from '../lib/api'; } from '../lib/api';
import { changeInventoryFunction, getInventoryFunctionHistory, listInventoryFunctions } from '../lib/inventoryFunctionApi';
import type { InventoryFunction } from '../lib/inventoryFunctionApi';
import type { import type {
AssetAttributeDefinition, AssetAttributeDefinition,
AssetDetail, AssetDetail,
@@ -140,6 +142,9 @@ export function AssetEditorPage() {
const [operationalAreas, setOperationalAreas] = useState<OperationalAssetSummary[]>([]); const [operationalAreas, setOperationalAreas] = useState<OperationalAssetSummary[]>([]);
const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]); const [operationalCompanies, setOperationalCompanies] = useState<OperationalAssetSummary[]>([]);
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [functionCatalog, setFunctionCatalog] = useState<InventoryFunction[]>([]);
const [functionId, setFunctionId] = useState('');
const [currentFunctionId, setCurrentFunctionId] = useState('');
const [status, setStatus] = useState<AssetInformationStatus>('DRAFT'); const [status, setStatus] = useState<AssetInformationStatus>('DRAFT');
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN'); const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>('UNKNOWN');
const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({}); const [attributeValues, setAttributeValues] = useState<Record<string, unknown>>({});
@@ -154,9 +159,21 @@ export function AssetEditorPage() {
const selectedType = types.find((type) => type.id === typeId) ?? null; const selectedType = types.find((type) => type.id === typeId) ?? null;
const definitions = useMemo( const definitions = useMemo(
() => selectedType?.attributes.filter((item) => item.isActive) ?? [], () => selectedType?.attributes.filter((item) => item.isActive && item.code !== 'campo_funcion') ?? [],
[selectedType], [selectedType],
); );
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
const structuralTypeName = normalizeStructuralType(selectedType?.name);
const technicalInventory = structuralTypeCode === 'instalacion'
|| structuralTypeCode === 'subinstalacion'
|| structuralTypeName === 'instalacion'
|| structuralTypeName === 'subinstalacion';
useEffect(() => {
listInventoryFunctions()
.then(setFunctionCatalog)
.catch((requestError) => setError(errorMessage(requestError)));
}, []);
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -215,6 +232,20 @@ export function AssetEditorPage() {
.catch((requestError) => setError(errorMessage(requestError))); .catch((requestError) => setError(errorMessage(requestError)));
}, [editing, contextParentId, types]); }, [editing, contextParentId, types]);
useEffect(() => {
if (!editing || !id || !technicalInventory || !canReadHistory) {
if (!editing) { setFunctionId(''); setCurrentFunctionId(''); }
return;
}
getInventoryFunctionHistory(id)
.then((history) => {
const value = history.currentFunction?.functionId ?? '';
setFunctionId(value);
setCurrentFunctionId(value);
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, id, technicalInventory, canReadHistory]);
useEffect(() => { useEffect(() => {
if (!typeId) { if (!typeId) {
setParents([]); setParents([]);
@@ -274,6 +305,8 @@ export function AssetEditorPage() {
setOperationalAreaId(''); setOperationalAreaId('');
if (!editing) setOperatorCompanyId(''); if (!editing) setOperatorCompanyId('');
setAttributeValues({}); setAttributeValues({});
setFunctionId('');
setCurrentFunctionId('');
setParentSearch(''); setParentSearch('');
}; };
@@ -287,14 +320,19 @@ export function AssetEditorPage() {
setError(''); setError('');
setSuccess(''); setSuccess('');
try { try {
if (technicalInventory && !commonName.trim()) {
setError('Completá el Nombre habitual. Es obligatorio para Instalaciones y Subinstalaciones.');
return;
}
const attributes = normalizeAttributeValues(definitions, attributeValues); const attributes = normalizeAttributeValues(definitions, attributeValues);
const nameToSave = technicalInventory ? (name.trim() || commonName.trim()) : name.trim();
let saved: AssetDetail; let saved: AssetDetail;
if (editing && id) { if (editing && id) {
if (canEdit) { if (canEdit) {
saved = await updateAsset(id, { saved = await updateAsset(id, {
typeId: canDirectContextEdit ? typeId : undefined, typeId: canDirectContextEdit ? typeId : undefined,
code, code,
name, name: nameToSave,
commonName: commonName.trim() || null, commonName: commonName.trim() || null,
parentId: canDirectContextEdit ? parentId || null : undefined, parentId: canDirectContextEdit ? parentId || null : undefined,
operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined,
@@ -310,14 +348,21 @@ export function AssetEditorPage() {
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) { if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(id, operationalStatus); saved = await updateAssetOperationalStatus(id, operationalStatus);
} }
if (technicalInventory && functionId !== currentFunctionId) {
const functionHistory = await changeInventoryFunction(id, { functionId: functionId || null });
const nextFunctionId = functionHistory.currentFunction?.functionId ?? '';
setCurrentFunctionId(nextFunctionId);
setFunctionId(nextFunctionId);
}
setAsset(saved); setAsset(saved);
setSuccess('Registro actualizado correctamente'); setSuccess('Registro actualizado correctamente');
setHistoryRefreshKey((current) => current + 1); setHistoryRefreshKey((current) => current + 1);
} else { } else {
saved = await createAsset({ saved = await createAsset({
code, code,
name, name: nameToSave,
commonName: commonName.trim() || null, commonName: commonName.trim() || null,
functionId: technicalInventory ? (functionId || null) : null,
typeId, typeId,
parentId: parentId || null, parentId: parentId || null,
operationalAreaId: operationalAreaId || null, operationalAreaId: operationalAreaId || null,
@@ -351,8 +396,6 @@ export function AssetEditorPage() {
? 'companies' ? 'companies'
: 'territory'; : 'territory';
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
const structuralTypeName = normalizeStructuralType(selectedType?.name);
const compactStructuralSummary = editing && ( const compactStructuralSummary = editing && (
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion'] ['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
.includes(structuralTypeCode) .includes(structuralTypeCode)
@@ -462,24 +505,13 @@ export function AssetEditorPage() {
<div className="form-grid asset-compact-primary-grid"> <div className="form-grid asset-compact-primary-grid">
<label className="field"> <label className="field">
<span>Nombre técnico</span> <span>Nombre habitual <em>obligatorio</em></span>
<input <input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} required maxLength={200} placeholder="Nombre usado habitualmente en campo" />
value={name} <small>Es el nombre que se mostrará en el Acta.</small>
onChange={(event) => setName(event.target.value)}
disabled={!canEdit}
required
maxLength={200}
/>
</label> </label>
<label className="field"> <label className="field">
<span>Nombre habitual / sobrenombre <em>opcional</em></span> <span>Nombre técnico <em>opcional</em></span>
<input <input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Denominación técnica, si corresponde" />
value={commonName}
onChange={(event) => setCommonName(event.target.value)}
disabled={!canEdit}
maxLength={200}
placeholder="Ej.: planta vieja, celda principal…"
/>
</label> </label>
</div> </div>
@@ -628,6 +660,15 @@ export function AssetEditorPage() {
</label>} </label>}
</div>} </div>}
{technicalInventory && <label className="field">
<span>Función <em>opcional</em></span>
<SearchableSelect value={functionId} onChange={(event) => setFunctionId(event.target.value)} disabled={!canEdit}>
<option value="">Sin función asignada</option>
{functionCatalog.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</SearchableSelect>
{hasPermission('asset_types.manage') && <small><Link to="/admin/inventory-functions" className="text-link">Administrar Catálogo de funciones</Link></small>}
</label>}
<label className="field"> <label className="field">
<span>Descripción <em>opcional</em></span> <span>Descripción <em>opcional</em></span>
<textarea <textarea
@@ -671,21 +712,24 @@ export function AssetEditorPage() {
/> />
</label> </label>
<label className="field"> <label className="field">
<span>Nombre técnico</span> <span>{technicalInventory ? <>Nombre habitual <em>obligatorio</em></> : 'Nombre'}</span>
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /> <input value={technicalInventory ? commonName : name} onChange={(event) => technicalInventory ? setCommonName(event.target.value) : setName(event.target.value)} disabled={!canEdit} required maxLength={200} placeholder={technicalInventory ? 'Nombre usado habitualmente en campo' : undefined} />
</label> {technicalInventory && <small>Es el nombre que se mostrará en el Acta.</small>}
<label className="field">
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
<input
value={commonName}
onChange={(event) => setCommonName(event.target.value)}
disabled={!canEdit}
maxLength={200}
placeholder="Ej.: tanque grande, ET vieja, batería norte…"
/>
<small>También se usa en las búsquedas del Inventario.</small>
</label> </label>
{technicalInventory && <label className="field">
<span>Nombre técnico <em>opcional</em></span>
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Denominación técnica, si corresponde" />
</label>}
</div> </div>
{technicalInventory && <label className="field">
<span>Función <em>opcional</em></span>
<SearchableSelect value={functionId} onChange={(event) => setFunctionId(event.target.value)} disabled={!canEdit}>
<option value="">Sin función asignada</option>
{functionCatalog.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</SearchableSelect>
{hasPermission('asset_types.manage') && <small><Link to="/admin/inventory-functions" className="text-link">Administrar Catálogo de funciones</Link></small>}
</label>}
<label className="field"> <label className="field">
<span>Descripción <em>opcional</em></span> <span>Descripción <em>opcional</em></span>
<textarea <textarea
+1
View File
@@ -70,6 +70,7 @@ export function DashboardPage() {
<div className="quick-links"> <div className="quick-links">
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link> <Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link>
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>} {hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
{hasPermission('asset_types.manage') && <Link to="/admin/inventory-functions"><Icon name="layers" /><span><strong>Catálogo de funciones</strong><small>Funciones disponibles para Instalaciones y Subinstalaciones</small></span><Icon name="chevron" /></Link>}
{hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>} {hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>}
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>} {hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
</div> </div>
+2 -2
View File
@@ -51,7 +51,7 @@ export function InventoryFunctionsPage() {
setName(''); setName('');
setDescription(''); setDescription('');
setSortOrder('0'); setSortOrder('0');
setSuccess('Función incorporada al catálogo. Ya puede asignarse a Estaciones y Subestaciones.'); setSuccess('Función incorporada al catálogo. Ya puede asignarse a Instalaciones y Subinstalaciones.');
load(); load();
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)); setError(errorMessage(requestError));
@@ -112,7 +112,7 @@ export function InventoryFunctionsPage() {
return <section className="narrow-section"> return <section className="narrow-section">
<div className="page-heading"> <div className="page-heading">
<div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Catálogo de funciones</h1><p>Funciones operativas que pueden asumir las Estaciones y Subestaciones. Desactivar una opción nunca borra su uso histórico.</p></div> <div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Catálogo de funciones</h1><p>Funciones que pueden asignarse a Instalaciones y Subinstalaciones. Desactivar una opción nunca borra su uso histórico.</p></div>
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}