From dea9358451d1e169bf9f5e2b04a4e1040bfef37d Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 23:13:06 -0300 Subject: [PATCH] =?UTF-8?q?fix(android):=20Acta=20=E2=86=92=20Hallazgo=20?= =?UTF-8?q?=E2=86=92=20Inventario=20en=20campo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifica el flujo operativo móvil, corrige el alta desde el Yacimiento congelado, permite crear Actas antes de seleccionar Inventario y precarga Marca, Modelo, Capacidad, Número de serie y Función en Instalaciones/Subinstalaciones. Android 0.16.0. --- android-app/app/build.gradle.kts | 4 +- .../korexlabs/dhinspeccion/MainViewModel.kt | 13 +- .../korexlabs/dhinspeccion/data/MobileActs.kt | 4 +- .../korexlabs/dhinspeccion/ui/F3VisitRoot.kt | 342 +++++++++--------- .../dhinspeccion/ui/MobileActsScreen.kt | 57 +-- .../dhinspeccion/ReleaseMetadataTest.kt | 4 +- ...000-f6-3-mobile-field-common-attributes.ts | 57 +++ .../dto/create-inspection-act.dto.ts | 2 - .../inspection-acts.service.ts | 3 +- .../f3-field-inventory-structure.service.ts | 2 +- api-v3/test/unit/f5-android-test-cut.test.ts | 8 +- .../unit/f6-3-mobile-act-finding-flow.test.ts | 49 +++ 12 files changed, 336 insertions(+), 209 deletions(-) create mode 100644 api-v3/src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts create mode 100644 api-v3/test/unit/f6-3-mobile-act-finding-flow.test.ts diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 619a885..bc35ea9 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 24 - versionName = "0.15.2" + versionCode = 25 + versionName = "0.16.0" 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 6dc020d..006ea1d 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 @@ -168,10 +168,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { error = "Elegí si el Acta es urgente o no urgente." return } - if (asset == null) { - error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta." - return - } if (acts.any { it.status == "DRAFT" }) { error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente." return @@ -179,7 +175,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { val created = actsRepository.create( currentVisit.id, - asset.id, + asset?.id, currentVisit.code, urgency, ) @@ -188,17 +184,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { loadActsInternal(currentVisit.id, selectDraft = false) val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." - if (selectedFieldAsset?.capture?.readyForFinding == true) { + if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) { loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) } } } + fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency) + fun searchInventory(search: String, parentId: String? = null) { val currentVisit = visit ?: return launchBusy { - val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id - inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data + inventory = repository.fieldInventory(currentVisit.id, search, parentId).data } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index 49ca613..c88ef3d 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) { suspend fun create( visitId: String, - assetId: String, + assetId: String? = null, visitCode: String, urgency: String = "NON_URGENT", ): MobileActDetail = authorized { session -> @@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) { urgency = urgency, title = "Acta de inspección $visitCode", summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", - assetIds = listOf(assetId), + assetIds = listOfNotNull(assetId), ), ) } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt index 7c28d8d..7f137d2 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt @@ -86,7 +86,10 @@ fun F3VisitRoot(model: MainViewModel) { inventoryMode = true }, ) - inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false }) + inventoryMode -> F3FieldInventoryScreen(model, onBack = { + inventoryMode = false + actsMode = true + }) else -> F3VisitOverview( model = model, onInventory = { inventoryMode = true }, @@ -119,6 +122,9 @@ private fun F3VisitOverview( } Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}") + visit.scopeAsset?.let { + Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + } visit.plannedStartAt?.let { Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall) } @@ -138,10 +144,10 @@ private fun F3VisitOverview( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold) - Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.") + Text("Todo listo para comenzar", fontWeight = FontWeight.Bold) + Text("Al iniciar, pasás directamente a las Actas de esta inspección.") Button( - onClick = { model.startVisit() }, + onClick = { model.startVisit(); onActs() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth(), ) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") } @@ -150,12 +156,12 @@ private fun F3VisitOverview( } if (visit.status == "IN_PROGRESS") { - Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { - Text("Abrir Inventario de campo") - } - OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { + Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) { val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } - Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}") + Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}") + } + OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { + Text("Inventario / Hallazgos") } } else if (visit.status == "CLOSED") { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { @@ -163,7 +169,7 @@ private fun F3VisitOverview( } } else if (visit.status == "PLANNED") { Text( - "Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.", + "Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.", style = MaterialTheme.typography.bodySmall, ) } @@ -209,7 +215,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { var search by rememberSaveable(visit.id) { mutableStateOf("") } var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) } var parentId by rememberSaveable(visit.id) { mutableStateOf(null) } - var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") } + var parentLabel by rememberSaveable(visit.id) { + mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección") + } var name by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf(null) } @@ -323,10 +331,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - OutlinedButton(onClick = onBack) { Text("Volver") } + OutlinedButton(onClick = onBack) { Text("Acta") } Column(horizontalAlignment = Alignment.End) { - Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Text("Área → Yacimiento → Instalación → Subinstalación", style = MaterialTheme.typography.bodySmall) + Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall) } } Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) } @@ -410,170 +418,182 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) { } } - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = search, - onValueChange = { search = it }, - label = { Text("Buscar por nombre o código") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Spacer(Modifier.width(8.dp)) - Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") } - } - - Row( - Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text("Alta en campo", fontWeight = FontWeight.Bold) - Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall) + Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold) + OutlinedTextField( + value = search, + onValueChange = { search = it }, + label = { Text("Buscar instalación o subinstalación") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { model.searchInventory(search) }, + enabled = !model.busy, + modifier = Modifier.weight(1f), + ) { Text("Buscar") } + OutlinedButton( + onClick = { + showCreate = !showCreate + if (showCreate) model.loadFieldTypes(parentId) + }, + modifier = Modifier.weight(1f), + ) { Text(if (showCreate) "Cancelar" else "+ Agregar") } + } } - OutlinedButton(onClick = { - showCreate = !showCreate - if (showCreate) model.loadFieldTypes(parentId) - }) { Text(if (showCreate) "Ocultar" else "Agregar") } } if (showCreate) { - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .verticalScroll(rememberScrollState()) - .weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (parentId != null) { - OutlinedButton(onClick = { - parentId = null - parentLabel = "Área de la inspección" - resetCreateForm() - selectedTypeId = null - model.loadFieldTypes(null) - }) { Text("Volver al Área") } - } + Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Column( + Modifier + .fillMaxWidth() + .padding(12.dp) + .verticalScroll(rememberScrollState()) + .weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall) + if (parentId != null) { + OutlinedButton(onClick = { + parentId = null + parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" + resetCreateForm() + selectedTypeId = null + model.loadFieldTypes(null) + }) { Text("Volver al Yacimiento") } + } - if (model.fieldTypes.isEmpty()) { - Text("Este nivel no admite más hijos estructurales.") - } else { - Text("Vas a crear", style = MaterialTheme.typography.bodySmall) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(model.fieldTypes, key = { it.id }) { type -> - AssistChip( - onClick = { - selectedTypeId = type.id - selectedFamilyId = null - attributeValues.clear() - }, - label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) }, - ) + if (model.fieldTypes.isEmpty()) { + Text("No hay un tipo disponible para esta ubicación.") + } else { + Text("Tipo de registro", style = MaterialTheme.typography.bodySmall) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(model.fieldTypes, key = { it.id }) { type -> + AssistChip( + onClick = { + selectedTypeId = type.id + selectedFamilyId = null + attributeValues.clear() + }, + label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) }, + ) + } } } - } - if (selectedType?.familyRequired == true) { - Text("Familia técnica", fontWeight = FontWeight.Bold) - Text( - "Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.", - style = MaterialTheme.typography.bodySmall, - ) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(selectedType.families, key = { it.id }) { family -> - AssistChip( - onClick = { selectedFamilyId = family.id }, - label = { - val prefix = when { - selectedFamilyId == family.id -> "✓ " - family.isOther -> "+ " - else -> "" - } - Text(prefix + family.name) - }, - ) + if (selectedType?.familyRequired == true) { + Text("Clasificación", fontWeight = FontWeight.Bold) + Text( + "Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.", + style = MaterialTheme.typography.bodySmall, + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(selectedType.families, key = { it.id }) { family -> + AssistChip( + onClick = { selectedFamilyId = family.id }, + label = { + val prefix = when { + selectedFamilyId == family.id -> "✓ " + family.isOther -> "+ " + else -> "" + } + Text(prefix + family.name) + }, + ) + } + } + selectedFamily?.let { family -> + if (family.isOther) { + Text( + "Se registrará como no catalogado para revisión posterior en oficina.", + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.bodySmall, + ) + } + if (family.informationLabels.isNotEmpty()) { + Text( + "Información esperada: ${family.informationLabels.joinToString(" · ")}", + style = MaterialTheme.typography.bodySmall, + ) + } } } - selectedFamily?.let { family -> - if (family.isOther) { - Text( - "Se registrará como familia no catalogada para revisión posterior en oficina.", - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.bodySmall, - ) - } - if (family.informationLabels.isNotEmpty()) { - Text( - "Información esperada: ${family.informationLabels.joinToString(" · ")}", - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Nombre identificable *") }, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = commonName, - onValueChange = { commonName = it }, - label = { Text("Nombre habitual") }, - modifier = Modifier.fillMaxWidth(), - ) - - selectedType?.attributes?.forEach { definition -> OutlinedTextField( - value = attributeValues[definition.code].orEmpty(), - onValueChange = { attributeValues[definition.code] = it }, - label = { Text(definition.name + if (definition.isRequired) " *" else "") }, - supportingText = { - val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ") - if (details.isNotBlank()) Text(details) - }, - keyboardOptions = KeyboardOptions( - keyboardType = if ( - definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT") - ) KeyboardType.Decimal else KeyboardType.Text, - ), + value = name, + onValueChange = { name = it }, + label = { Text("Nombre o código identificable *") }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = commonName, + onValueChange = { commonName = it }, + label = { Text("Nombre habitual") }, modifier = Modifier.fillMaxWidth(), ) - } - val attributesReady = selectedType?.attributes - ?.filter { it.isRequired } - ?.all { attributeValues[it.code].orEmpty().isNotBlank() } - ?: false - val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null - Button( - onClick = { - if (f3HasLocation(context)) createWithLocation() - else locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, + selectedType?.attributes?.forEach { definition -> + OutlinedTextField( + value = attributeValues[definition.code].orEmpty(), + onValueChange = { attributeValues[definition.code] = it }, + label = { Text(definition.name + if (definition.isRequired) " *" else "") }, + supportingText = { + val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ") + if (details.isNotBlank()) Text(details) + }, + keyboardOptions = KeyboardOptions( + keyboardType = if ( + definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT") + ) KeyboardType.Decimal else KeyboardType.Text, ), + modifier = Modifier.fillMaxWidth(), ) - }, - enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Capturar GPS y crear") } - HorizontalDivider() + } + + val attributesReady = selectedType?.attributes + ?.filter { it.isRequired } + ?.all { attributeValues[it.code].orEmpty().isNotBlank() } + ?: false + val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null + Button( + onClick = { + if (f3HasLocation(context)) createWithLocation() + else locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + }, + enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Guardar alta y capturar GPS") } + } } } - Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold) + Text( + "Instalaciones y subinstalaciones", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + fontWeight = FontWeight.Bold, + ) LazyColumn( Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(model.inventory, key = { it.id }) { item -> + items( + model.inventory.filter { candidate -> + candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion") + }, + key = { it.id }, + ) { item -> F3InventoryCard( item = item, onInspect = { model.selectExisting(item) }, @@ -611,10 +631,10 @@ private fun F3CaptureCard( if (captureRequired) { Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos") if (!ready) { - Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error) + Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error) Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") } } else { - Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary) + Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary) } } else { Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall) @@ -631,8 +651,8 @@ private fun F3InventoryCard( ) { val typeCode = item.type?.let(::f3TypeCode).orEmpty() val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion") - val canHaveChild = typeCode in setOf("yacimiento", "instalacion") - val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí" + val canHaveChild = typeCode == "instalacion" + val childLabel = "+ Agregar subinstalación" Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { @@ -646,12 +666,12 @@ private fun F3InventoryCard( } if (canHaveFinding) { Text( - if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente", + if (item.readyForFinding) "Disponible" else "GPS/foto pendiente", color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) - OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { - Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección") + Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { + Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo") } } if (canHaveChild) { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index d1d0749..fcc56fd 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -126,13 +126,14 @@ fun MobileActsScreen( Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) } Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}") + visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) } F32ActMessage(model) Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Actas de esta Inspección", fontWeight = FontWeight.Bold) + Text("Actas de esta inspección", fontWeight = FontWeight.Bold) if (model.acts.isEmpty()) { - Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") + Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.") } model.acts.forEach { act -> val active = selected?.id == act.id @@ -156,15 +157,15 @@ fun MobileActsScreen( Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Nueva Acta", fontWeight = FontWeight.Bold) + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) if (model.acts.any { it.status == "LOCKED" }) { Text( "Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.", style = MaterialTheme.typography.bodySmall, ) } - Text("Urgencia del Acta", fontWeight = FontWeight.Bold) + Text("Urgencia", fontWeight = FontWeight.Bold) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip( onClick = { newActUrgency = "NON_URGENT" }, @@ -183,20 +184,15 @@ fun MobileActsScreen( }, style = MaterialTheme.typography.bodySmall, ) - val selectedInventory = model.selectedFieldAsset?.asset - if (selectedInventory == null) { - Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") - Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { - Text("Ir a Inventario y elegir") - } - } else { - Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") - Button( - onClick = { model.createActForSelectedInventory(newActUrgency) }, - enabled = !model.busy, - modifier = Modifier.fillMaxWidth(), - ) { Text("Crear nueva Acta") } - } + Text( + "Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.", + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = { model.createAct(newActUrgency) }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Crear nueva Acta") } } } } @@ -220,7 +216,21 @@ fun MobileActsScreen( when (selected.status) { "DRAFT" -> { - Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.") + Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { + Text("+ Agregar Hallazgo") + } + } + } + + HorizontalDivider() + Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) @@ -259,12 +269,7 @@ fun MobileActsScreen( } HorizontalDivider() - Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.") - Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } - - HorizontalDivider() - Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.") Button( onClick = { model.prepareSelectedAct() }, 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 ba3bee0..ea8f7e2 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(24, BuildConfig.VERSION_CODE) - assertEquals("0.15.2-debug", BuildConfig.VERSION_NAME) + assertEquals(25, BuildConfig.VERSION_CODE) + assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/api-v3/src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts b/api-v3/src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts new file mode 100644 index 0000000..e273e91 --- /dev/null +++ b/api-v3/src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface { + name = 'F63MobileFieldCommonAttributes1790099100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + WITH target_types AS ( + SELECT id + FROM asset_types + WHERE lower(code) IN ('instalacion','subinstalacion') + ), fields(code,name,sort_order) AS ( + VALUES + ('campo_marca','Marca',10), + ('campo_modelo','Modelo',20), + ('campo_capacidad','Capacidad',30), + ('campo_numero_serie','Número de serie',40), + ('campo_funcion','Función',50) + ) + INSERT INTO asset_attribute_definitions ( + asset_type_id, code, name, data_type, is_required, is_active, sort_order + ) + SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type, + false, true, fields.sort_order + FROM target_types target + CROSS JOIN fields + WHERE NOT EXISTS ( + SELECT 1 + FROM asset_attribute_definitions existing + WHERE existing.asset_type_id=target.id + AND lower(existing.code)=lower(fields.code) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM asset_attribute_values value + USING asset_attribute_definitions definition, asset_types type + WHERE value.definition_id=definition.id + AND definition.asset_type_id=type.id + AND lower(type.code) IN ('instalacion','subinstalacion') + AND definition.code IN ( + 'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion' + ) + `); + await queryRunner.query(` + DELETE FROM asset_attribute_definitions definition + USING asset_types type + WHERE definition.asset_type_id=type.id + AND lower(type.code) IN ('instalacion','subinstalacion') + AND definition.code IN ( + 'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion' + ) + `); + } +} diff --git a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts index 62c9566..16ae744 100644 --- a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts +++ b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts @@ -1,7 +1,6 @@ import { Transform } from 'class-transformer'; import { ArrayMaxSize, - ArrayMinSize, ArrayUnique, IsArray, IsEnum, @@ -42,7 +41,6 @@ export class CreateInspectionActDto { observations?: string | null; @IsArray() - @ArrayMinSize(1) @ArrayMaxSize(200) @ArrayUnique() @IsUUID('4', { each: true }) diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index 4de8913..4f0aa01 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -656,6 +656,7 @@ export class InspectionActsService { visitId: string, assetIds: string[], ): Promise { + if (assetIds.length === 0) return; const [row] = (await manager.query(` SELECT COUNT(*)::integer AS count FROM inspection_visit_assets @@ -663,7 +664,7 @@ export class InspectionActsService { AND asset_id = ANY($2::uuid[]) AND included = true `, [visitId, assetIds])) as Array<{ count: number }>; - if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) { + if (Number(row?.count ?? 0) !== assetIds.length) { throw new BadRequestException({ code: 'INSPECTION_ACT_ASSET_INVALID', message: 'Cada inventario del acta debe estar incluido en la inspección', diff --git a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts index f1406bf..0f9779b 100644 --- a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts +++ b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts @@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService { principal: AuthPrincipal, ) { const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase; - const effectiveParentId = parentId ?? base.context.area.id; + const effectiveParentId = parentId ?? base.parent.id; const parent = await this.parent(effectiveParentId); const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()]; const data = expectedTypeCode 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 c094c2d..80a2c80 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string { return readFileSync(resolve(process.cwd(), '..', path), 'utf8'); } -test('F6.1 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'); - assert.match(gradle, /versionCode = 24/); - assert.match(gradle, /versionName = "0\.15\.2"/); + assert.match(gradle, /versionCode = 25/); + assert.match(gradle, /versionName = "0\.16\.0"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); -test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => { +test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => { const service = readFileSync( resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'), 'utf8', diff --git a/api-v3/test/unit/f6-3-mobile-act-finding-flow.test.ts b/api-v3/test/unit/f6-3-mobile-act-finding-flow.test.ts new file mode 100644 index 0000000..0adf814 --- /dev/null +++ b/api-v3/test/unit/f6-3-mobile-act-finding-flow.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +test('F6.3 field types start at the frozen Yacimiento instead of the Area', () => { + const structure = source('src/inspection-visits/f3-field-inventory-structure.service.ts'); + assert.match(structure, /parentId \?\? base\.parent\.id/); + assert.doesNotMatch(structure, /parentId \?\? base\.context\.area\.id/); +}); + +test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are added', () => { + const dto = source('src/inspection-acts/dto/create-inspection-act.dto.ts'); + const service = source('src/inspection-acts/inspection-acts.service.ts'); + const mobile = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt'); + + assert.doesNotMatch(dto, /ArrayMinSize\(1\)/); + assert.match(service, /if \(assetIds\.length === 0\) return/); + assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/); +}); + +test('F6.3 every Installation and Subinstallation receives the common field card', () => { + const migration = source('src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts'); + + for (const label of ['Marca', 'Modelo', 'Capacidad', 'Número de serie', 'Función']) { + assert.match(migration, new RegExp(label)); + } + assert.match(migration, /'instalacion','subinstalacion'/); + assert.match(migration, /'TEXT'::asset_attribute_data_type/); + assert.match(migration, /false, true/); +}); + +test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => { + const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt'); + const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt'); + const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt'); + + assert.match(root, /model\.startVisit\(\); onActs\(\)/); + assert.match(root, /Text\("Nuevo Hallazgo"/); + assert.match(root, /Buscar instalación o subinstalación/); + assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/); + assert.match(acts, /model\.createAct\(newActUrgency\)/); + assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/); + assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/); +});