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
@@ -19,6 +19,7 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.InventoryFunctionOption
import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
import com.korexlabs.dhinspeccion.data.MobileActDetail
@@ -72,6 +73,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set
var inventoryFunctions: List<InventoryFunctionOption> by mutableStateOf(emptyList())
private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set
@@ -296,6 +299,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun loadInventoryFunctions() {
launchBusy {
inventoryFunctions = repository.inventoryFunctions().data
}
}
fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
@@ -372,13 +381,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
type: FieldType,
parentId: String?,
familyId: String?,
name: String,
name: String?,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
description: String? = null,
functionId: String? = null,
newFunctionName: String? = null,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
@@ -389,6 +400,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí el Tipo de instalación o subinstalación correspondiente."
return
}
if (commonName.isNullOrBlank()) {
error = "Completá el Nombre habitual. Es obligatorio y se utilizará en el Acta."
return
}
if (functionId != null && !newFunctionName.isNullOrBlank()) {
error = "Elegí una función del catálogo o cargá Otra, no ambas."
return
}
launchBusy(mutation = true) {
val assetId = DhRepository.newOperationId()
val capturedAt = Instant.now().toString()
@@ -397,8 +416,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
typeId = type.id,
parentId = parentId,
familyId = familyId,
name = name.trim(),
name = name?.trim()?.takeIf { it.isNotBlank() },
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
functionId = functionId,
newFunctionName = newFunctionName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = FieldCoordinates.latitude(latitude),
@@ -410,7 +431,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val localAsset = FieldInventoryItem(
id = assetId,
code = "PEND-${assetId.take(6).uppercase()}",
name = request.name,
name = request.name ?: request.commonName ?: "Sin denominación",
commonName = request.commonName,
informationStatus = "DRAFT",
dataOrigin = "FIELD_SURVEY",
@@ -256,14 +256,27 @@ data class FieldAssetDetail(
val capture: CaptureStatus = CaptureStatus(),
)
data class InventoryFunctionOption(
val id: String,
val code: String,
val name: String,
val description: String? = null,
)
data class InventoryFunctionListResponse(
val data: List<InventoryFunctionOption> = emptyList(),
)
data class CreateFieldInventoryRequest(
val clientGeneratedId: String? = null,
val typeId: String,
val parentId: String? = null,
val familyId: String? = null,
val code: String? = null,
val name: String,
val name: String? = null,
val commonName: String? = null,
val functionId: String? = null,
val newFunctionName: String? = null,
val description: String? = null,
val discoveryNotes: String? = null,
val attributes: Map<String, Any?>,
@@ -336,6 +349,11 @@ interface DhApi {
@Query("parentId") parentId: String? = null,
): FieldTypeResponse
@GET("inventory-functions")
suspend fun inventoryFunctions(
@Header("Authorization") authorization: String,
): InventoryFunctionListResponse
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
suspend fun selectFieldAsset(
@Header("Authorization") authorization: String,
@@ -461,6 +479,7 @@ class DhRepository(context: Context) {
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
private val inventoryFunctionAdapter = moshi.adapter(InventoryFunctionListResponse::class.java)
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -521,6 +540,11 @@ class DhRepository(context: Context) {
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
}
suspend fun inventoryFunctions(): InventoryFunctionListResponse =
cached("inventory-functions", inventoryFunctionAdapter) {
authorized { session -> api.inventoryFunctions("Bearer ${session.accessToken}") }
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
}
@@ -205,6 +205,8 @@ class OfflineMutationQueue(private val context: Context) {
.put("code", request.code)
.put("name", request.name)
.put("commonName", request.commonName)
.put("functionId", request.functionId)
.put("newFunctionName", request.newFunctionName)
.put("description", request.description)
.put("discoveryNotes", request.discoveryNotes)
.put("attributes", JSONObject(request.attributes))
@@ -424,8 +426,10 @@ class OfflineMutationQueue(private val context: Context) {
parentId = payload.optNullableString("parentId"),
familyId = payload.optNullableString("familyId"),
code = payload.optNullableString("code"),
name = payload.getString("name"),
name = payload.optNullableString("name"),
commonName = payload.optNullableString("commonName"),
functionId = payload.optNullableString("functionId"),
newFunctionName = payload.optNullableString("newFunctionName"),
description = payload.optNullableString("description"),
discoveryNotes = payload.optNullableString("discoveryNotes"),
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
@@ -287,6 +287,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var description by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedFunctionId by rememberSaveable(visit.id) { mutableStateOf<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 selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
@@ -304,6 +309,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
name = ""
commonName = ""
description = ""
selectedFunctionId = null
functionSearch = ""
functionExpanded = false
useNewFunction = false
newFunctionName = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
@@ -356,6 +366,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
LaunchedEffect(visit.id) {
model.searchInventory("", visit.scopeAsset?.id)
model.loadInventoryFunctions()
}
LaunchedEffect(modeName, parentSearch) {
@@ -393,7 +404,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
selectedType?.familyRequired == true && it.code == "tipo_instalacion"
it.code == "campo_funcion" || (selectedType?.familyRequired == true && it.code == "tipo_instalacion")
}
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) {
@@ -403,6 +414,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
}
val selectedFunction = model.inventoryFunctions.firstOrNull { it.id == selectedFunctionId }
val filteredFunctions = remember(model.inventoryFunctions, functionSearch) {
val needle = modernNormalize(functionSearch)
model.inventoryFunctions.filter { item ->
needle.isBlank() || modernNormalize("${item.code} ${item.name}").contains(needle)
}
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<ModernGeoSnapshot?>(null) }
@@ -443,8 +462,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
type = type,
parentId = effectiveParent,
familyId = selectedFamilyId,
name = name,
name = name.takeIf { it.isNotBlank() },
commonName = commonName,
functionId = if (useNewFunction) null else selectedFunctionId,
newFunctionName = if (useNewFunction) newFunctionName else null,
description = description,
attributes = buildModernAttributes(type, attributeValues),
latitude = geo.latitude,
@@ -823,24 +844,98 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre técnico *") },
placeholder = { Text("Denominación técnica") },
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual *") },
supportingText = { Text("Es el nombre que se mostrará en el Acta.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual (opcional)") },
value = name,
onValueChange = { name = it },
label = { Text("Nombre técnico (opcional)") },
placeholder = { Text("Denominación técnica, si corresponde") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
ExposedDropdownMenuBox(
expanded = functionExpanded && !model.busy,
onExpandedChange = {
if (!model.busy) {
functionExpanded = it
if (it) functionSearch = ""
}
},
) {
OutlinedTextField(
value = if (functionExpanded) functionSearch else if (useNewFunction) "Otra función" else selectedFunction?.name.orEmpty(),
onValueChange = { functionSearch = it; functionExpanded = true },
label = { Text("Función (opcional)") },
placeholder = { Text("Buscar función") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = functionExpanded) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
singleLine = true,
)
ExposedDropdownMenu(
expanded = functionExpanded && !model.busy,
onDismissRequest = { functionExpanded = false; functionSearch = "" },
modifier = Modifier.heightIn(max = 280.dp),
) {
DropdownMenuItem(
text = { Text("Sin función") },
onClick = {
selectedFunctionId = null
useNewFunction = false
newFunctionName = ""
functionExpanded = false
},
)
filteredFunctions.forEach { item ->
DropdownMenuItem(
text = { Text(item.name) },
trailingIcon = { if (!useNewFunction && selectedFunctionId == item.id) Icon(Icons.Filled.CheckCircle, "Seleccionada") },
onClick = {
selectedFunctionId = item.id
useNewFunction = false
newFunctionName = ""
functionExpanded = false
functionSearch = ""
},
)
}
DropdownMenuItem(
text = { Text("Otro · agregar función") },
trailingIcon = { if (useNewFunction) Icon(Icons.Filled.CheckCircle, "Seleccionada") },
onClick = {
selectedFunctionId = null
useNewFunction = true
functionExpanded = false
functionSearch = ""
},
)
}
}
if (useNewFunction) {
OutlinedTextField(
value = newFunctionName,
onValueChange = { newFunctionName = it },
label = { Text("Nueva función *") },
supportingText = { Text("Se incorporará al Catálogo de funciones al sincronizar.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
}
OutlinedTextField(
value = description,
onValueChange = { description = it },
@@ -896,7 +991,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
enabled = selectedType != null && commonName.isNotBlank() && (!useNewFunction || newFunctionName.isNotBlank()) && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CameraAlt, null)