Compare commits

..
Author SHA1 Message Date
DH V2 08f164a209 fix(web): package Mendoza favicon in production image
DH V2 CI / API · typecheck, tests, build (push) Successful in 34s
DH V2 CI / Docker / scripts contract (push) Successful in 1m2s
DH V2 CI / WEB · typecheck, build (push) Successful in 20s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
2026-09-14 19:36:36 -03:00
DH V2 23450bb6d0 fix(field): repair installation creation and streamline mobile form
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m4s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / Docker / scripts contract (push) Successful in 1m0s
Production dependency audit / API · production dependencies (push) Successful in 9s
2026-09-14 19:25:20 -03:00
admin 20ccba5292 Android 0.19.5: tipo de instalación y alta corregida (#2)
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 51s
Unifica Tipo de instalación en un desplegable con buscador y corrige el payload de atributos técnicos para usar IDs de definición. Android CI: 26 pruebas, lint y compilaciones debug/release aprobadas. CI general y auditoría de dependencias aprobadas.
2026-09-14 18:48:26 -03:00
admin c0a5367920 test(android): align 0.19.5 version code
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 2m57s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 31s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 49s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
2026-09-14 18:42:46 -03:00
admin ff30462259 fix(android): send inventory attributes by definition id
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Failing after 2m13s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 0s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 29s
2026-09-14 18:40:06 -03:00
18 changed files with 114 additions and 62 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 32
versionName = "0.19.4"
versionCode = 34
versionName = "0.19.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -244,7 +244,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
if (type.familyRequired && familyId == null) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
error = "Elegí el Tipo de instalación o subinstalación correspondiente."
return
}
launchBusy(mutation = true) {
@@ -0,0 +1,19 @@
package com.korexlabs.dhinspeccion.data
/** Builds the API payload using definition IDs while the form keeps stable field codes. */
internal fun fieldAttributePayload(
definitions: List<FieldAttributeDefinition>,
valuesByCode: Map<String, String>,
): Map<String, Any?> = definitions.mapNotNull { definition ->
val raw = valuesByCode[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.id to coerceFieldAttribute(definition, raw)
}.toMap()
private fun coerceFieldAttribute(definition: FieldAttributeDefinition, raw: String): Any =
when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
@@ -60,6 +60,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@@ -521,18 +522,7 @@ private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect
}
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceAttribute(definition, raw)
}.toMap()
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
fieldAttributePayload(type.attributes, values)
private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -56,6 +56,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -693,11 +694,11 @@ private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit
}
if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
Text("Tipo de instalación *", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") },
label = { Text("Buscar tipo de instalación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -906,18 +907,7 @@ private fun dynamicStatusLabel(status: String): String = when (status) {
}
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceDynamicAttribute(definition, raw)
}.toMap()
private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
fieldAttributePayload(type.attributes, values)
private fun dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -57,6 +57,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -720,18 +721,7 @@ private fun statusLabel(status: String): String = when (status) {
}
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceF3Attribute(definition, raw)
}.toMap()
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
fieldAttributePayload(type.attributes, values)
private fun f3HasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -63,6 +63,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -82,6 +83,7 @@ import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -777,6 +779,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
enabled = !model.busy,
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
familyExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
)
ExposedDropdownMenu(
expanded = familyExpanded && !model.busy,
@@ -817,9 +824,11 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
value = name,
onValueChange = { name = it },
label = { Text("Nombre técnico *") },
supportingText = { Text("Usá la denominación técnica del elemento. El código DH se genera automáticamente.") },
placeholder = { Text("Denominación técnica") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = commonName,
@@ -827,17 +836,24 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
label = { Text("Nombre habitual (opcional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción (opcional)") },
supportingText = { Text("Dato equivalente al alta desde el panel web.") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = if (editableAttributes.isEmpty()) ImeAction.Done else ImeAction.Next),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { keyboard?.hide(); focusManager.clearFocus() },
),
)
editableAttributes.forEach { definition ->
editableAttributes.forEachIndexed { index, definition ->
val isLastAttribute = index == editableAttributes.lastIndex
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
@@ -848,8 +864,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
imeAction = if (isLastAttribute) ImeAction.Done else ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) },
onDone = { keyboard?.hide(); focusManager.clearFocus() },
),
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
@@ -1099,18 +1121,7 @@ private fun modernNormalize(value: String): String = value.trim().lowercase()
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceModernAttribute(definition, raw)
}.toMap()
private fun coerceModernAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
fieldAttributePayload(type.attributes, values)
private fun modernHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -10,7 +10,7 @@ class DynamicFieldFlowContractTest {
@Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *"))
assertTrue(source.contains("Tipo de instalación *"))
assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id"))
@@ -0,0 +1,29 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FieldAttributePayloadTest {
@Test
fun payloadUsesDefinitionIdsExpectedByApiAndKeepsTypedValues() {
val definitions = listOf(
FieldAttributeDefinition("id-marca", "campo_marca", "Marca", "TEXT"),
FieldAttributeDefinition("id-capacidad", "campo_capacidad", "Capacidad", "NUMBER"),
FieldAttributeDefinition("id-serie", "campo_numero_serie", "Número de serie", "TEXT"),
)
val payload = fieldAttributePayload(
definitions,
mapOf("campo_marca" to " algo ", "campo_capacidad" to "37,73", "campo_numero_serie" to " "),
)
assertEquals("algo", payload["id-marca"])
assertEquals(37.73, payload["id-capacidad"])
assertFalse(payload.containsKey("id-serie"))
assertTrue(payload.keys.none { it.startsWith("campo_") })
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(32, BuildConfig.VERSION_CODE)
assertEquals("0.19.4-debug", BuildConfig.VERSION_NAME)
assertEquals(34, BuildConfig.VERSION_CODE)
assertEquals("0.19.6-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -760,6 +760,7 @@ export class AssetsService {
parentId: dto.parentId,
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
inventoryFamilyId: dto.inventoryFamilyId ?? null,
code: dto.code,
name: dto.name,
commonName: dto.commonName ?? null,
@@ -36,6 +36,10 @@ export class CreateFieldDiscoveryDto {
@IsUUID('4')
operatorCompanyId!: string;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@@ -295,6 +295,7 @@ export class FieldInventoryService {
operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId,
inventoryFamilyId: dto.familyId ?? null,
description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null,
attributes: dto.attributes,
+17 -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', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 32/);
assert.match(gradle, /versionName = "0\.19\.4"/);
assert.match(gradle, /versionCode = 34/);
assert.match(gradle, /versionName = "0\.19\.6"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -26,3 +26,18 @@ test('F5/F6.3 field inventory exposes Other families as reviewable choices to An
assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/);
});
test('field inventory persists the validated Tipo de instalación in the initial asset insert', () => {
const fieldService = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/field-inventory.service.ts'),
'utf8',
);
const assetsService = readFileSync(
resolve(process.cwd(), 'src/asset-master/assets.service.ts'),
'utf8',
);
assert.match(fieldService, /inventoryFamilyId: dto\.familyId \?\? null/);
assert.match(assetsService, /inventoryFamilyId: dto\.inventoryFamilyId \?\? null/);
});
+1
View File
@@ -3,6 +3,7 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig*.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src
RUN npm run build
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<link rel="icon" type="image/webp" href="/favicon.webp" />
<title>DH Inspección V2</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB