fix(android): align Acta flow and defer urgency to close
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m31s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m30s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 33s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Failing after 1m33s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m5s

This commit is contained in:
2026-09-14 09:27:30 -03:00
parent d44ae049f3
commit e30e5d90ea
28 changed files with 217 additions and 187 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 29
versionName = "0.19.1"
versionCode = 30
versionName = "0.19.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -165,41 +165,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
fun createAct() {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy(mutation = true) {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
currentVisit.code,
urgency,
)
val created = actsRepository.create(currentVisit.id, currentVisit.code)
selectedAct = created
actClosure = actsRepository.closure(created.id)
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 (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
@@ -488,12 +472,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun prepareSelectedAct() {
fun prepareSelectedAct(urgency: String) {
val actId = selectedAct?.id ?: return
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Definí la urgencia antes de cerrar el Acta."
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId)
actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel y pendiente de firma. Su contenido quedó inmutable."
}
}
@@ -35,7 +35,7 @@ data class MobileActSummary(
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String = "NON_URGENT",
val urgency: String? = null,
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -60,7 +60,7 @@ data class MobileActDetail(
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String = "NON_URGENT",
val urgency: String? = null,
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -91,13 +91,16 @@ data class MobileActListResponse(
data class CreateMobileActRequest(
val occurredAt: String,
val urgency: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class PrepareMobileActRequest(
val urgency: String,
)
data class UpdateMobileActRequest(
val assetIds: List<String>,
)
@@ -130,7 +133,7 @@ data class MobileActClosureHeader(
val code: String,
val status: String,
val visitId: String,
val urgency: String = "NON_URGENT",
val urgency: String? = null,
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -249,6 +252,7 @@ private interface MobileActsApi {
suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure
@Multipart
@@ -326,19 +330,16 @@ class MobileActsRepository(context: Context) {
suspend fun create(
visitId: String,
assetId: String? = null,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
occurredAt = Instant.now().toString(),
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 = listOfNotNull(assetId),
assetIds = emptyList(),
),
)
}
@@ -360,8 +361,8 @@ class MobileActsRepository(context: Context) {
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId)
suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
}
suspend fun signInspector(
@@ -60,7 +60,7 @@ fun MobileActsScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
}
@@ -137,7 +137,11 @@ fun MobileActsScreen(
}
model.acts.forEach { act ->
val active = selected?.id == act.id
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
val urgency = when (act.urgency) {
"URGENT" -> "URGENTE"
"NON_URGENT" -> "No urgente"
else -> "Urgencia pendiente"
}
OutlinedButton(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
@@ -165,23 +169,8 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
"La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
Text(
@@ -189,7 +178,7 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.createAct(newActUrgency) },
onClick = { model.createAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
@@ -203,7 +192,13 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia: Urgente"
"NON_URGENT" -> "Urgencia: No urgente"
else -> "Urgencia: se define al cerrar el Acta"
},
)
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -270,10 +265,20 @@ fun MobileActsScreen(
HorizontalDivider()
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.")
Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora la urgencia según lo constatado.")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { closingUrgency = "NON_URGENT" },
label = { Text(if (closingUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { closingUrgency = "URGENT" },
label = { Text(if (closingUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") }
}
@@ -75,7 +75,7 @@ fun ModernMobileActsScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
@@ -195,7 +195,7 @@ fun ModernMobileActsScreen(
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold)
Text(
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
"${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -225,36 +225,13 @@ fun ModernMobileActsScreen(
}
}
Text("Urgencia", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = newActUrgency == "NON_URGENT",
onClick = { newActUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = newActUrgency == "URGENT",
onClick = { newActUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
"La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.createAct(newActUrgency) },
onClick = { model.createAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
@@ -278,7 +255,14 @@ fun ModernMobileActsScreen(
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
when (selected.urgency) {
"URGENT" -> "Urgencia · Urgente"
"NON_URGENT" -> "Urgencia · No urgente"
else -> "Urgencia · Se define al cerrar el Acta"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -356,12 +340,34 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia según lo constatado en campo.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = closingUrgency == "NON_URGENT",
onClick = { closingUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = closingUrgency == "URGENT",
onClick = { closingUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Text(
when (closingUrgency) {
"URGENT" -> "El plazo urgente se computará desde el Acta según la política vigente."
"NON_URGENT" -> "El plazo no urgente comenzará con la oficialización GEDO."
else -> "Elegí una opción para habilitar el cierre."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
}
@@ -106,7 +106,6 @@ fun ModernVisitRoot(model: MainViewModel) {
ModernVisitScreen.OVERVIEW -> ModernVisitOverview(
model = model,
onActs = { screenName = ModernVisitScreen.ACTS.name },
onInventory = { screenName = ModernVisitScreen.INVENTORY.name },
)
ModernVisitScreen.ACTS -> ModernMobileActsScreen(
model = model,
@@ -124,7 +123,6 @@ fun ModernVisitRoot(model: MainViewModel) {
private fun ModernVisitOverview(
model: MainViewModel,
onActs: () -> Unit,
onInventory: () -> Unit,
) {
val visit = model.visit ?: return
Column(
@@ -183,7 +181,7 @@ private fun ModernVisitOverview(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.",
"Al iniciar se habilitan Actas y Hallazgos.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
@@ -217,25 +215,6 @@ private fun ModernVisitOverview(
}
}
}
ElevatedCard(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Icon(Icons.Filled.Factory, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.secondary)
}
Column(Modifier.weight(1f)) {
Text("Inventario de campo", fontWeight = FontWeight.Bold)
Text(
"Buscar existente o cargar Instalación / Subinstalación",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
}
@@ -533,7 +512,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.PICK_PARENT -> "Elegir Instalación"
ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
ModernInventoryMode.BROWSE -> "Inventario de campo"
ModernInventoryMode.BROWSE -> "Ubicación del Hallazgo"
},
subtitle = visit.scopeAsset?.name ?: visit.code,
onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() },
@@ -912,7 +891,7 @@ private fun ModernInventoryBrowse(
OutlinedTextField(
value = search,
onValueChange = onSearchChange,
label = { Text("Buscar Inventario") },
label = { Text("Buscar Instalación o Subinstalación") },
placeholder = { Text("Nombre, código o dato técnico") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(29, BuildConfig.VERSION_CODE)
assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME)
assertEquals(30, BuildConfig.VERSION_CODE)
assertEquals("0.19.2-debug", BuildConfig.VERSION_NAME)
}
@Test