Compare commits

..
38 changed files with 940 additions and 1504 deletions
+2 -10
View File
@@ -48,7 +48,7 @@ jobs:
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.13'
@@ -63,7 +63,7 @@ jobs:
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug :app:lintRelease
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
@@ -130,14 +130,6 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Upload unsigned release candidate
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
+1 -6
View File
@@ -309,14 +309,9 @@ jobs:
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0
for _ in $(seq 1 30); do
if docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/tmp/dhv2-health.json 2>/dev/null; then
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
+1 -1
View File
@@ -1,4 +1,4 @@
# Checklist final Android 0.19.1
# Checklist final Android 0.19.0
La candidata sólo se promueve si el mismo SHA cumple:
+4 -24
View File
@@ -1,10 +1,10 @@
# DH Inspección Android · release final de campo 0.19.1
# DH Inspección Android · release final de campo 0.19.0
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.1**.
- `versionCode`: **29**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
@@ -13,7 +13,7 @@ La variante debug es independiente de la app productiva y puede instalarse para
## Procedimiento operativo validado
La APK 0.19.1 fija como recorrido principal de campo:
La APK 0.19.0 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo.
@@ -95,23 +95,3 @@ Antes de distribuir una APK productiva:
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida.
## Revisión 0.19.1
- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación.
- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector.
- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes.
- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior.
- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado.
- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia.
### Alcance real
Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final.
### Segunda pasada funcional
- Coordenadas normalizadas al contrato API (6 decimales, precisión 3).
- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación.
- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto.
- Se agregan siete pruebas para coordenadas y valores técnicos.
-49
View File
@@ -1,49 +0,0 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -2
View File
@@ -13,7 +13,7 @@ android {
minSdk = 26
targetSdk = 36
versionCode = 29
versionName = "0.19.1"
versionName = "0.20.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -77,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
}
@@ -6,7 +6,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
@@ -25,14 +24,11 @@ import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
@@ -88,7 +84,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña."
return
}
launchBusy(mutation = true) {
launchBusy {
session = repository.login(identifier, password)
notice = "Sesión iniciada."
loadVisitsInternal()
@@ -117,9 +113,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun openVisit(id: String) = launchBusy {
val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
@@ -129,8 +123,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null
inventory = emptyList()
inventoryParentId = null
@@ -143,7 +135,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() {
val id = visit?.id ?: return
launchBusy(mutation = true) {
launchBusy {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
@@ -180,7 +172,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy(mutation = true) {
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
@@ -202,10 +194,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
launchBusy {
val result = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
}
}
@@ -213,7 +203,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -227,7 +216,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy(mutation = true) {
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
@@ -263,7 +252,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return
}
launchBusy(mutation = true) {
launchBusy {
val request = CreateFieldInventoryRequest(
typeId = type.id,
parentId = parentId,
@@ -272,9 +261,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceLatitude = latitude,
deviceLongitude = longitude,
deviceAccuracyM = accuracyM,
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
@@ -294,7 +283,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado."
return
}
launchBusy(mutation = true) {
launchBusy {
val result = repository.mergeFieldAsset(
visitId = visitId,
assetId = source.id,
@@ -321,7 +310,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy(mutation = true) {
launchBusy {
val response = repository.uploadFieldPhoto(
visitId = visitId,
assetId = asset.id,
@@ -356,7 +345,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return
}
launchBusy(mutation = true) {
launchBusy {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -390,7 +379,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10."
return
}
launchBusy(mutation = true) {
launchBusy {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
@@ -421,7 +410,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null,
description: String? = null,
) {
launchBusy(mutation = true) {
launchBusy {
findingsRepository.uploadObservationPhoto(
findingId = findingId,
file = file,
@@ -453,7 +442,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
return
}
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
@@ -476,7 +465,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
@@ -490,7 +479,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
@@ -508,7 +497,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
@@ -523,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
@@ -545,7 +534,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de al menos 10 caracteres."
return
}
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
}
@@ -554,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
@@ -565,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeInspection() {
val visitId = visit?.id ?: return
launchBusy(mutation = true) {
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
@@ -648,21 +637,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap()
}
private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
private fun launchBusy(block: suspend () -> Unit) {
viewModelScope.launch {
busy = true
error = null
try {
block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) {
error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null
} finally {
operations.end()
busy = operations.busy
busy = false
}
}
}
@@ -1,16 +0,0 @@
package com.korexlabs.dhinspeccion
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
internal class OperationTracker {
private var pending = 0
val busy: Boolean get() = pending > 0
fun begin(mutation: Boolean): Boolean {
if (mutation && busy) return false
pending += 1
return true
}
fun end() {
check(pending > 0)
pending -= 1
}
}
@@ -7,6 +7,8 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -449,6 +451,7 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -527,13 +530,13 @@ class DhRepository(context: Context) {
visitId = visitId,
assetId = assetId,
file = part,
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
@@ -545,19 +548,23 @@ class DhRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object {
fun humanError(error: Throwable): String {
if (error is java.io.IOException) {
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
}
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
@@ -1,14 +0,0 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -4,6 +4,8 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -174,6 +176,7 @@ private interface FieldFindingsApi {
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -224,9 +227,9 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
@@ -238,11 +241,18 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -4,6 +4,8 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -308,6 +310,7 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -426,9 +429,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -451,11 +454,18 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -4,6 +4,8 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
@@ -53,6 +55,7 @@ private interface MobileInspectionOpenApi {
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -83,11 +86,18 @@ class MobileInspectionOpenRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -1,32 +0,0 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException
/** One refresh lock for every API client sharing the encrypted session. */
internal object MobileSessionCoordinator {
private val mutex = Mutex()
suspend fun refresh(
previous: StoredSession,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
request: suspend (RefreshRequest) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
if (latest.refreshToken != previous.refreshToken) return@withLock latest
val response = try {
request(RefreshRequest(previous.refreshToken))
} catch (error: HttpException) {
// Network failures and server outages must not discard the saved login.
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
throw error
}
// A logout or another login during the request must never be undone.
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
save(response)
}
}
@@ -1,64 +0,0 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.time.LocalDate
import java.time.OffsetDateTime
internal data class TechnicalDefinition(
val id: String, val code: String, val name: String, val dataType: String,
val isRequired: Boolean = false, val isActive: Boolean = true,
val unit: String? = null, val options: List<String>? = null,
)
internal data class TechnicalValues(
val assetId: String, val family: FieldInventoryFamily,
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
)
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
private interface TechnicalApi {
@GET("assets/{id}/technical-values")
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
@PUT("assets/{id}/technical-values")
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
internal class TechnicalRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
.build().create(TechnicalApi::class.java)
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
val previous = store.load() ?: error("Sesión no iniciada")
try { return block(previous) }
catch (error: HttpException) { if (error.code() != 401) throw error }
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
return block(current)
}
}
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
val value = text.trim()
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
if (value.isBlank()) return null
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
return when (definition.dataType) {
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
else -> value.takeIf { it.length <= 4000 } ?: invalid()
}
}
@@ -0,0 +1,793 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Factory
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
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.PlannedAsset
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.LocalDate
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private enum class F8VisitScreen { OVERVIEW, ACTS, FINDING_TARGET, PICK_ASSET, CREATE_ASSET }
private enum class F8Target { YACIMIENTO, INSTALACION, SUBINSTALACION }
private data class F8Geo(val latitude: Double, val longitude: Double, val accuracyM: Double?)
@Composable
fun F8VisitRoot(model: MainViewModel) {
val visit = model.visit ?: return
var screenName by rememberSaveable(visit.id) { mutableStateOf(F8VisitScreen.OVERVIEW.name) }
var targetName by rememberSaveable(visit.id) { mutableStateOf(F8Target.INSTALACION.name) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
val screen = runCatching { F8VisitScreen.valueOf(screenName) }.getOrDefault(F8VisitScreen.OVERVIEW)
val target = runCatching { F8Target.valueOf(targetName) }.getOrDefault(F8Target.INSTALACION)
fun goOverview() {
screenName = F8VisitScreen.OVERVIEW.name
parentId = null
parentLabel = ""
model.clearSelectedFieldAsset()
}
when (screen) {
F8VisitScreen.OVERVIEW -> F8VisitOverview(
model = model,
onActs = { screenName = F8VisitScreen.ACTS.name },
onCreateFinding = { screenName = F8VisitScreen.FINDING_TARGET.name },
)
F8VisitScreen.ACTS -> ModernMobileActsScreen(
model = model,
onBack = { screenName = F8VisitScreen.OVERVIEW.name },
onGoInventory = { screenName = F8VisitScreen.FINDING_TARGET.name },
)
F8VisitScreen.FINDING_TARGET -> F8FindingTargetScreen(
model = model,
onBack = { screenName = F8VisitScreen.OVERVIEW.name },
onYacimiento = {
val scope = visit.scopeAsset
if (scope != null) {
model.selectExisting(
FieldInventoryItem(
id = scope.id,
code = scope.code,
name = scope.name,
type = scope,
readyForFinding = true,
),
)
}
},
onInstallation = {
targetName = F8Target.INSTALACION.name
parentId = null
parentLabel = visit.scopeAsset?.name.orEmpty()
screenName = F8VisitScreen.PICK_ASSET.name
},
onSubinstallation = {
targetName = F8Target.SUBINSTALACION.name
parentId = null
parentLabel = ""
screenName = F8VisitScreen.PICK_ASSET.name
},
)
F8VisitScreen.PICK_ASSET -> F8AssetPicker(
model = model,
target = target,
parentId = parentId,
parentLabel = parentLabel,
onBack = {
if (target == F8Target.SUBINSTALACION && parentId != null) {
parentId = null
parentLabel = ""
} else {
screenName = F8VisitScreen.FINDING_TARGET.name
}
},
onParent = { item ->
parentId = item.id
parentLabel = item.name
},
onCreate = {
screenName = F8VisitScreen.CREATE_ASSET.name
},
)
F8VisitScreen.CREATE_ASSET -> F8QuickCreateAsset(
model = model,
target = target,
parentId = parentId,
parentLabel = parentLabel.ifBlank { visit.scopeAsset?.name.orEmpty() },
onBack = { screenName = F8VisitScreen.PICK_ASSET.name },
onFinishedWithoutFinding = { goOverview() },
)
}
}
@Composable
private fun F8VisitOverview(
model: MainViewModel,
onActs: () -> Unit,
onCreateFinding: () -> Unit,
) {
val visit = model.visit ?: return
val draft = model.acts.firstOrNull { it.status == "DRAFT" }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
F8Header("Inspección en campo", visit.code) { model.closeVisitView() }
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(visit.scopeAsset?.name ?: "Yacimiento", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
visit.operationalArea?.name?.takeIf { it.isNotBlank() }?.let {
Text("Área · $it", style = MaterialTheme.typography.bodyMedium)
}
Text(f8VisitStatus(visit.status), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
}
F8Messages(model)
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
F8RouteCard(visit)
if (visit.status == "PLANNED") {
Button(
onClick = { model.startVisit() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (model.busy) "Iniciando…" else "Iniciar inspección")
}
}
if (visit.status == "IN_PROGRESS") {
if (draft == null) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Empezar a documentar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Creá un Acta cuando empieces a registrar lo observado. El número definitivo se asignará al cerrarla.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.createAct("NON_URGENT") },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text("Nueva Acta")
}
OutlinedButton(
onClick = { model.createAct("URGENT") },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Nueva Acta urgente") }
}
}
} else {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Icon(Icons.Filled.Assignment, null, tint = MaterialTheme.colorScheme.primary)
Column {
Text("Acta en elaboración", fontWeight = FontWeight.Bold)
Text("${draft.findingCount} Hallazgo${if (draft.findingCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall)
}
}
Button(onClick = onCreateFinding, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text("Crear hallazgo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Acta actual") }
}
}
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
}
if (model.acts.isNotEmpty()) {
Text("Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
model.acts.take(4).forEach { act ->
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp) {
Row(Modifier.padding(13.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(if (act.status == "DRAFT") "Acta en elaboración" else act.code, fontWeight = FontWeight.SemiBold)
Text("${act.findingCount} hallazgos · ${f8ActStatus(act.status)}", style = MaterialTheme.typography.bodySmall)
}
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.secondary)
}
}
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver todas las Actas") }
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun F8RouteCard(visit: VisitDetail) {
val planned = visit.planningAssets.filter { it.included }
val checklist = visit.checklist.items.filter { it.assetIncluded && it.asset != null }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Recorrido sugerido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Usalo como guía. Podés registrar cualquier Hallazgo nuevo aunque no esté planificado.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (planned.isEmpty() && checklist.isEmpty()) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("No hay controles pendientes cargados para este recorrido.", Modifier.padding(12.dp))
}
} else {
planned.take(10).forEach { asset -> F8RouteLine(asset, "Planificado", false) }
checklist.take(12).forEach { item ->
val due = item.referenceOn?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() }
val overdue = due?.isBefore(LocalDate.now()) == true || item.findingStatus?.uppercase() == "OVERDUE"
val label = if (overdue) "Vencido" else "Por vencer"
val asset = item.asset
if (asset != null) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(
if (overdue) Icons.Filled.WarningAmber else Icons.Filled.LocationOn,
contentDescription = null,
tint = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
Column(Modifier.weight(1f)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(label, item.findingCode, item.findingTitle).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@Composable
private fun F8RouteLine(asset: PlannedAsset, label: String, error: Boolean) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.LocationOn, null, tint = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(label, asset.typeName?.takeIf { it.isNotBlank() }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun F8FindingTargetScreen(
model: MainViewModel,
onBack: () -> Unit,
onYacimiento: () -> Unit,
onInstallation: () -> Unit,
onSubinstallation: () -> Unit,
) {
val visit = model.visit ?: return
val hasDraft = model.acts.any { it.status == "DRAFT" }
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
F8Header("Crear hallazgo", visit.scopeAsset?.name ?: visit.code, onBack)
F8Messages(model)
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
if (!hasDraft) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Primero necesitás un Acta", fontWeight = FontWeight.Bold)
Text("Volvé y tocá Nueva Acta. Así cada Hallazgo queda asociado correctamente.")
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") }
}
}
return@Column
}
Text("¿Dónde está el hallazgo?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Elegí el lugar. Si todavía no existe en el Inventario, lo vas a poder crear en el siguiente paso.", color = MaterialTheme.colorScheme.onSurfaceVariant)
F8TargetCard("En el Yacimiento", visit.scopeAsset?.name ?: "Yacimiento", Icons.Filled.LocationOn, onYacimiento)
F8TargetCard("En una Instalación", "Elegir o crear Instalación", Icons.Filled.Factory, onInstallation)
F8TargetCard("En una Subinstalación", "Elegir Instalación y luego el elemento", Icons.Filled.Assignment, onSubinstallation)
}
}
@Composable
private fun F8TargetCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(18.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Icon(icon, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun F8AssetPicker(
model: MainViewModel,
target: F8Target,
parentId: String?,
parentLabel: String,
onBack: () -> Unit,
onParent: (FieldInventoryItem) -> Unit,
onCreate: () -> Unit,
) {
val visit = model.visit ?: return
var search by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") }
val pickingParent = target == F8Target.SUBINSTALACION && parentId == null
val title = when {
pickingParent -> "Elegir Instalación"
target == F8Target.SUBINSTALACION -> "Elegir Subinstalación"
else -> "Elegir Instalación"
}
LaunchedEffect(visit.id, target.name, parentId, search) {
delay(220)
val effectiveParent = if (pickingParent || target == F8Target.INSTALACION) visit.scopeAsset?.id else parentId
model.searchInventory(search.trim(), effectiveParent)
}
val rows = model.inventory.filter { item ->
val code = f8Norm(item.type?.code ?: item.type?.name)
if (pickingParent || target == F8Target.INSTALACION) {
code.contains("instalacion") && !code.contains("subinstalacion")
} else {
code.contains("subinstalacion")
}
}
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
F8Header(title, if (parentLabel.isBlank()) visit.scopeAsset?.name ?: visit.code else parentLabel, onBack)
F8Messages(model)
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre o código") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (!pickingParent) {
OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text(if (target == F8Target.SUBINSTALACION) "Crear Subinstalación" else "Crear Instalación")
}
} else if (rows.isEmpty() && !model.busy) {
OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
Text("No existe: crear Instalación")
}
}
if (rows.isEmpty() && !model.busy) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("No encontramos coincidencias. Podés crear el elemento sin salir del Hallazgo.", Modifier.padding(14.dp))
}
}
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(rows, key = { it.id }) { item ->
ElevatedCard(
onClick = { if (pickingParent) onParent(item) else model.selectExisting(item) },
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Factory, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
}
}
}
}
}
@Composable
private fun F8QuickCreateAsset(
model: MainViewModel,
target: F8Target,
parentId: String?,
parentLabel: String,
onBack: () -> Unit,
onFinishedWithoutFinding: () -> Unit,
) {
val visit = model.visit ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
var name by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
val attributes = remember { mutableStateMapOf<String, String>() }
var localError by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
var pendingAutoPhoto by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(false) }
var photoFile by remember { mutableStateOf<File?>(null) }
var photoGeo by remember { mutableStateOf<F8Geo?>(null) }
LaunchedEffect(visit.id, target.name, parentId) {
model.loadFieldTypes(if (target == F8Target.SUBINSTALACION) parentId else null)
}
LaunchedEffect(model.fieldTypes) {
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = model.fieldTypes.firstOrNull()?.id
selectedFamilyId = null
attributes.clear()
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedAsset = model.selectedFieldAsset
val takePhoto = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = photoFile
val geo = photoGeo
if (success && file != null && geo != null) {
runCatching { f8WriteExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
} else if (!success) {
localError = "La foto es necesaria para continuar con el Hallazgo."
}
photoFile = null
photoGeo = null
}
fun beginPhoto() {
scope.launch {
runCatching { f8CurrentGeo(context) }
.onSuccess { geo ->
val (file, uri) = f8NewPhoto(context)
photoFile = file
photoGeo = geo
takePhoto.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación." }
}
}
fun createNow() {
val type = selectedType ?: return
scope.launch {
runCatching { f8CurrentGeo(context) }
.onSuccess { geo ->
pendingAutoPhoto = true
model.createFieldAsset(
type = type,
parentId = if (target == F8Target.SUBINSTALACION) parentId else null,
familyId = selectedFamilyId,
name = name,
commonName = null,
description = null,
attributes = f8Attributes(type, attributes),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar el GPS." }
}
}
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val camera = result[Manifest.permission.CAMERA] == true || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f8HasLocation(context)
if (camera && location) {
if (selectedAsset?.capture?.captureRequired == true) beginPhoto() else createNow()
} else {
localError = "Para el alta en campo se necesitan cámara y ubicación."
}
}
fun requestCreate() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) createNow()
else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
}
fun requestPhoto() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) beginPhoto()
else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
}
LaunchedEffect(selectedAsset?.asset?.id, pendingAutoPhoto) {
if (pendingAutoPhoto && selectedAsset?.capture?.captureRequired == true && selectedAsset.capture.creationGpsCaptured && selectedAsset.capture.fieldPhotoCount == 0) {
pendingAutoPhoto = false
requestPhoto()
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp), verticalArrangement = Arrangement.spacedBy(11.dp)) {
F8Header(
if (target == F8Target.SUBINSTALACION) "Nueva Subinstalación" else "Nueva Instalación",
parentLabel,
onBack,
)
F8Messages(model)
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
if (selectedAsset != null && selectedAsset.capture.captureRequired) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(selectedAsset.asset.name, fontWeight = FontWeight.Bold)
Text("GPS ${if (selectedAsset.capture.creationGpsCaptured) "✓" else "pendiente"} · Foto ${if (selectedAsset.capture.fieldPhotoCount > 0) "✓" else "pendiente"}")
if (!selectedAsset.capture.readyForFinding) {
Button(onClick = { requestPhoto() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
Icon(Icons.Filled.CameraAlt, null)
Spacer(Modifier.width(7.dp))
Text("Tomar foto y continuar")
}
} else {
Button(onClick = { model.openFindingForSelected() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
Text("Continuar con el Hallazgo")
}
}
OutlinedButton(onClick = onFinishedWithoutFinding, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") }
}
}
} else {
Text("Datos básicos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
OutlinedTextField(name, { name = it }, label = { Text("Nombre *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
if (model.fieldTypes.size > 1) {
Text("Tipo", fontWeight = FontWeight.SemiBold)
model.fieldTypes.forEach { type ->
FilterChip(
selected = selectedTypeId == type.id,
onClick = { selectedTypeId = type.id; selectedFamilyId = null; attributes.clear() },
label = { Text(type.name) },
)
}
} else {
selectedType?.let { Text("Tipo · ${it.name}", color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
if (selectedType?.familyRequired == true) {
Text("Función / clasificación", fontWeight = FontWeight.SemiBold)
selectedType.families.forEach { family ->
FilterChip(
selected = selectedFamilyId == family.id,
onClick = { selectedFamilyId = family.id },
label = { Text(family.name) },
)
}
}
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributes[definition.code].orEmpty(),
onValueChange = { attributes[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(9.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
Text("Al guardar se toma GPS y foto. Después volvés directamente al Hallazgo.", style = MaterialTheme.typography.bodySmall)
}
}
val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributes[it.code].orEmpty().isNotBlank() } ?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = name.isNotBlank() && selectedType != null && requiredReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CameraAlt, null)
Spacer(Modifier.width(7.dp))
Text("Guardar y tomar foto")
}
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun F8Header(title: String, subtitle: String, onBack: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Volver") }
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun F8Messages(model: MainViewModel) {
model.error?.let {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
}
}
model.notice?.let {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
}
private fun f8VisitStatus(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun f8ActStatus(status: String): String = when (status) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun f8Norm(value: String?): String = (value ?: "")
.normalize(java.text.Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
.trim()
.lowercase()
private fun f8Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.associate { definition -> definition.code to f8AttributeValue(definition, values[definition.code].orEmpty()) }
private fun f8AttributeValue(definition: FieldAttributeDefinition, raw: String): Any? {
val value = raw.trim()
if (value.isBlank()) return null
return when (definition.dataType.uppercase()) {
"NUMBER", "DECIMAL", "FLOAT" -> value.replace(',', '.').toDoubleOrNull() ?: value
"INTEGER", "INT" -> value.toLongOrNull() ?: value
"BOOLEAN", "BOOL" -> value.equals("true", true) || value.equals("si", true) || value.equals("", true) || value == "1"
else -> value
}
}
private fun f8HasLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun f8CurrentGeo(context: Context): F8Geo = suspendCancellableCoroutine { continuation ->
if (!f8HasLocation(context)) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(F8Geo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun f8NewPhoto(context: Context): Pair<File, Uri> {
val dir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
val file = File(dir, "dh_field_${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
return file to uri
}
private fun f8WriteExif(file: File, geo: F8Geo) {
val exif = ExifInterface(file)
exif.setGpsInfo(android.location.Location("DH").apply {
latitude = geo.latitude
longitude = geo.longitude
accuracy = geo.accuracyM?.toFloat() ?: 0f
time = System.currentTimeMillis()
})
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, Instant.now().toString())
exif.saveAttributes()
}
@@ -75,7 +75,6 @@ fun FieldFindingScreen(model: MainViewModel) {
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -111,7 +110,6 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
@@ -124,7 +122,6 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = requestedFindingId
requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId)
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
}
fun requestPhoto(findingId: String) {
@@ -195,7 +192,6 @@ fun FieldFindingScreen(model: MainViewModel) {
}
}
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp))
@@ -11,10 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -84,7 +80,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}
DhTheme {
Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
Surface(Modifier.fillMaxSize()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true
@@ -100,7 +96,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> ModernVisitRoot(model)
model.visit != null -> F8VisitRoot(model)
else -> MobileHomeScreen(model)
}
}
@@ -110,10 +106,10 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
@@ -207,7 +203,7 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -226,4 +222,4 @@ private fun BiometricUnlockScreen(
OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") }
}
}
}
}
@@ -329,13 +329,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
model.loadFieldTypes(null)
}
fun startInstallation(parent: FieldInventoryItem? = null) {
fun startInstallation() {
model.clearSelectedFieldAsset()
modeName = ModernInventoryMode.CREATE_INSTALLATION.name
parentId = parent?.id
parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección"
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(parent?.id)
model.loadFieldTypes(null)
}
fun startSubinstallation() {
@@ -349,10 +349,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
fun chooseParent(item: FieldInventoryItem) {
if (modernItemTypeCode(item) == "yacimiento") {
startInstallation(item)
return
}
keyboard?.hide()
focusManager.clearFocus()
parentId = item.id
@@ -389,8 +385,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
}
if (selectedTypeId != preferred?.id) {
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
@@ -439,7 +435,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = parentId
val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null
scope.launch {
runCatching { currentModernGeo(context) }
.onSuccess { geo ->
@@ -548,9 +544,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
if (selectedCapture != null) {
if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) {
TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy)
}
ModernCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
@@ -742,9 +735,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
}
if (selectedType == null && !model.busy) {
ModernLocalError("Elegí un Yacimiento en Inventario y tocá Agregar Instalación. No se puede crear una Instalación directamente dentro de un Área.") {}
OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") }
if (model.fieldTypes.isEmpty() && !model.busy) {
ModernLocalError("No hay un tipo habilitado para esta ubicación.") {}
}
if (model.fieldTypes.size > 1) {
@@ -894,7 +886,7 @@ private fun ModernInventoryBrowse(
) {
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("yacimiento", "instalacion", "subinstalacion") }
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") }
Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) {
@@ -942,7 +934,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
StatusPill(when (typeCode) { "subinstalacion" -> "Subinstalación"; "yacimiento" -> "Yacimiento"; else -> "Instalación" })
StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación")
}
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
@@ -954,11 +946,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo")
}
if (typeCode in setOf("yacimiento", "instalacion")) {
if (typeCode == "instalacion") {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text(if (typeCode == "yacimiento") "Agregar Instalación" else "Agregar Subinstalación")
Text("Agregar Subinstalación")
}
}
}
@@ -0,0 +1,5 @@
package com.korexlabs.dhinspeccion.ui
import java.text.Normalizer
internal fun String.normalize(form: Normalizer.Form): String = Normalizer.normalize(this, form)
@@ -1,99 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -1,24 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import org.junit.Assert.*
import org.junit.Test
class FieldCoordinatesTest {
@Test fun realGpsIsRoundedToApiPrecision() {
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
}
@Test fun boundsAndZeroRemainValid() {
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
}
@Test fun invalidCoordinatesAreRejected() {
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
catch (_: IllegalArgumentException) { }
}
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
catch (_: IllegalArgumentException) { }
}
}
@@ -38,7 +38,7 @@ class FinalFieldFlowContractTest {
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
}
@Test
@@ -1,105 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import java.io.IOException
import kotlinx.coroutines.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class MobileSessionCoordinatorTest {
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
private class Store(var value: StoredSession?) {
var clears = 0
fun load() = value
fun clear() { clears++; value = null }
fun save(r: MobileSessionResponse): StoredSession =
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
}
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
val previous = old(); val store = Store(previous); var calls = 0
val results = coroutineScope {
(1..4).map {
async {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
calls++; delay(20); response()
}
}
}.awaitAll()
}
assertEquals(1, calls)
assertTrue(results.all { it.refreshToken == "r2" })
assertEquals(0, store.clears)
}
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
for (failure in listOf(IOException("offline"), http(500), http(429))) {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
fail("Expected request failure")
} catch (caught: Exception) { assertSame(failure, caught) }
assertEquals(previous, store.value)
assertEquals(0, store.clears)
}
}
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
fail("Expected unauthorized")
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
assertNull(store.value)
assertEquals(1, store.clears)
}
@Test fun cancelledRefreshKeepsSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
fail("Expected cancellation")
} catch (_: CancellationException) { }
assertEquals(previous, store.value)
}
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.clear(); response()
}
fail("Must reject a refresh after logout")
} catch (_: IllegalStateException) { }
assertNull(store.value)
}
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
fail("No refresh allowed for another inspector"); response()
}
fail("Must reject account switch")
} catch (_: IllegalStateException) { }
assertEquals("u2", store.value?.userId)
}
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.value = previous.copy(refreshToken = "new-login")
throw http(401)
}
} catch (_: HttpException) { }
assertEquals("new-login", store.value?.refreshToken)
assertEquals(0, store.clears)
}
}
@@ -1,28 +0,0 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.*
import org.junit.Test
class OperationTrackerTest {
@Test fun doubleTapCannotQueueASecondWrite() {
val tracker = OperationTracker()
assertTrue(tracker.begin(mutation = true))
assertFalse(tracker.begin(mutation = true))
tracker.end()
assertFalse(tracker.busy)
assertTrue(tracker.begin(mutation = true))
}
@Test fun BusyRemainsUntilEveryReadCompletes() {
val tracker = OperationTracker()
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
tracker.end(); assertTrue(tracker.busy)
assertFalse(tracker.begin(true))
tracker.end(); assertFalse(tracker.busy)
}
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
val tracker = OperationTracker()
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
tracker.end(); assertFalse(tracker.begin(true))
tracker.end(); assertTrue(tracker.begin(true))
}
}
@@ -9,7 +9,7 @@ class ReleaseMetadataTest {
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(29, BuildConfig.VERSION_CODE)
assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME)
assertEquals("0.20.0-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -1,28 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
@@ -1,407 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length
+ media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 500),
warnings: failedFacets,
};
}
}
@@ -32,7 +32,6 @@ import { InventoryTechnicalValuesService } from './inventory-technical-values.se
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@@ -67,7 +66,6 @@ import { InventoryBrowserService } from './inventory-browser.service';
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
@@ -1,13 +1,10 @@
import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
@@ -32,22 +29,11 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
@@ -58,7 +44,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.activity.dossier(assetId) as LooseRecord;
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
@@ -159,9 +145,6 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return {
asset: {
@@ -211,7 +194,6 @@ export class MergedInventoryDossierService {
media,
versions,
timeline: timeline.slice(0, 1000),
warnings,
};
}
}
+3 -3
View File
@@ -7,11 +7,11 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
test('F8 Android QA targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 29/);
assert.match(gradle, /versionName = "0\.19\.1"/);
assert.match(gradle, /versionName = "0\.20\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -25,4 +25,4 @@ test('F5/F6.3 field inventory exposes Other families as reviewable choices to An
assert.match(service, /F5:SYSTEM:OTHER:%/);
assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/);
});
});
@@ -21,13 +21,18 @@ test('F6.4 Android Installation picker searches live and selects a concrete pare
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
test('F6.4 Android field shell uses the modern theme and workspace', () => {
test('F8 Android field shell uses the shared theme and the inspection-first workspace', () => {
const gate = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
const theme = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt');
const f8 = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F8VisitRoot.kt');
assert.match(gate, /DhTheme \{/);
assert.match(gate, /model\.visit != null -> ModernVisitRoot\(model\)/);
assert.match(gate, /model\.visit != null -> F8VisitRoot\(model\)/);
assert.match(theme, /RoundedCornerShape\(16\.dp\)/);
assert.match(f8, /Recorrido sugerido/);
assert.match(f8, /Nueva Acta/);
assert.match(f8, /Crear hallazgo/);
assert.doesNotMatch(f8, /Inventario de campo/);
});
test('F6.4 Android Act list uses a mobile read model independent from office reports', () => {
@@ -40,4 +45,4 @@ test('F6.4 Android Act list uses a mobile read model independent from office rep
assert.match(service, /FROM inspection_acts act/);
assert.doesNotMatch(service, /inspection_reports/);
assert.doesNotMatch(service, /inspection_document/);
});
});
@@ -1,47 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { ActivityDossierService } from '../../src/asset-master/activity-dossier.service';
test('F7 Actividad no usa DISTINCT con ORDER BY COALESCE en visitas', () => {
const source = readFileSync('src/asset-master/activity-dossier.service.ts', 'utf8');
assert.doesNotMatch(source, /SELECT\s+DISTINCT\s+visit\.id/i);
assert.match(source, /ORDER BY COALESCE\(visit\.actual_started_at, visit\.planned_start_at, visit\.created_at\) DESC/);
assert.match(source, /EXISTS \(\s*SELECT 1\s*FROM inspection_visit_assets/s);
});
test('F7 una faceta auxiliar defectuosa no derriba todo el expediente', async () => {
const dataSource = {
query: async (sql: string) => {
if (sql.includes('FROM assets\n WHERE id = $1')) {
return [{
id: 'bcba740f-cc84-48b3-8d06-88a9c439e25c',
code: 'YAC-0005',
name: 'Agua Botada',
commonName: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
}];
}
if (sql.includes('inspection_finding_communications')) {
throw new Error('simulated optional facet failure');
}
return [];
},
};
const service = new ActivityDossierService(dataSource as never);
const dossier = await service.dossier('bcba740f-cc84-48b3-8d06-88a9c439e25c') as {
asset: { code: string; name: string };
counters: { inspections: number; findings: number };
warnings: string[];
timeline: unknown[];
};
assert.equal(dossier.asset.code, 'YAC-0005');
assert.equal(dossier.asset.name, 'Agua Botada');
assert.equal(dossier.counters.inspections, 0);
assert.equal(dossier.counters.findings, 0);
assert.deepEqual(dossier.warnings, ['communications']);
assert.deepEqual(dossier.timeline, []);
});
@@ -1,26 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const mergedDossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const dossierPanel = readFileSync('../web-v2/src/features/assets/AssetDossierPanel.tsx', 'utf8');
const simpleDetail = readFileSync('../web-v2/src/pages/SimpleInventoryDetailPage.tsx', 'utf8');
test('F7 mantiene Actividad disponible para Yacimiento', () => {
assert.match(simpleDetail, /kind === 'YACIMIENTO'/);
assert.match(simpleDetail, /setTab\('activity'\)/);
assert.match(simpleDetail, /AssetDossierPanel assetId=\{asset\.id\}/);
});
test('F7 no hace depender el dossier territorial del subsistema de fusión', () => {
assert.match(mergedDossier, /MERGEABLE_DOSSIER_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(mergedDossier, /if \(!MERGEABLE_DOSSIER_TYPES\.has\(requestedTypeCode\)\)/);
assert.match(mergedDossier, /private readonly activity: ActivityDossierService/);
assert.match(mergedDossier, /return await this\.activity\.dossier\(requestedAssetId\)/);
});
test('F7 la consulta auxiliar de merge nunca bloquea la carga de Actividad', () => {
assert.match(dossierPanel, /getInventoryMergeStatus\(assetId\)\.catch\(\(\) => null\)/);
assert.match(dossierPanel, /getAssetDossier\(assetId\)/);
});
-327
View File
@@ -1,327 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP="/var/www/dhv2.korexlabs.com"
BACKUP_ROOT="/root/DH_V2_BACKUPS"
LOCK="/var/lock/dhv2-deploy.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "Otro deploy de DH V2 ya está en curso. No se realiza ninguna modificación."
exit 0
fi
DEPLOY_REF="${DHV2_DEPLOY_REF:-deploy}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
STAGE="/root/dhv2-gitea-stage-${STAMP}"
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
PHASE="bootstrap"
PREV_SHA=""
TARGET_SHA=""
EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP"
exec > >(tee -a "$LOG") 2>&1
cleanup() {
set +e
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
rm -rf "$STAGE"
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
}
publish_status() {
local rc="${1:-1}"
set +e
local outcome="failure"
[ "$rc" -eq 0 ] && outcome="success"
local current="unknown"
current="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
local status_file log_file status_blob log_blob tree commit
status_file="$(mktemp /tmp/dhv2-status.XXXXXX)"
log_file="$(mktemp /tmp/dhv2-log.XXXXXX)"
{
echo "status=$outcome"
echo "exit_code=$rc"
echo "phase=$PHASE"
echo "timestamp=$(date --iso-8601=seconds)"
echo "deploy_ref=$DEPLOY_REF"
echo "previous_sha=${PREV_SHA:-unknown}"
echo "target_sha=${TARGET_SHA:-unknown}"
echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}"
} > "$status_file"
tail -n 500 "$LOG" > "$log_file" 2>/dev/null || true
status_blob="$(git hash-object -w "$status_file" 2>/dev/null || true)"
log_blob="$(git hash-object -w "$log_file" 2>/dev/null || true)"
if [ -n "$status_blob" ] && [ -n "$log_blob" ]; then
tree="$(printf '100644 blob %s\tdeploy.log\n100644 blob %s\tstatus.txt\n' "$log_blob" "$status_blob" | git mktree 2>/dev/null || true)"
if [ -n "$tree" ]; then
commit="$(printf 'deploy-status: %s · phase %s\n' "$outcome" "$PHASE" | git -c user.name='DH V2 Deploy Bot' -c user.email='deploy@dhv2.local' commit-tree "$tree" 2>/dev/null || true)"
[ -z "$commit" ] || git push --force origin "$commit:refs/heads/deploy-status" >/dev/null 2>&1 || true
fi
fi
rm -f "$status_file" "$log_file"
}
on_exit() {
local rc=$?
trap - EXIT ERR
cleanup
publish_status "$rc"
exit "$rc"
}
trap on_exit EXIT
rollback() {
local rc=$?
trap - ERR
PHASE="rollback"
echo
echo "============================================================"
echo " DH V2 · DEPLOY FALLÓ · ROLLBACK"
echo "============================================================"
cd "$APP"
if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi
echo
echo "Estado actual:"
docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo
if [ -d "$BACKUP" ]; then
echo "Backup PRE disponible en: $BACKUP"
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
else
echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc"
}
trap rollback ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY DESDE GITEA · $DEPLOY_REF"
echo "============================================================"
for cmd in git docker curl tar node; do
command -v "$cmd" >/dev/null || { echo "ERROR: falta $cmd"; false; }
done
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
ORIGIN_URL="$(git remote get-url origin)"
EXPECTED_ORIGIN="https://git.korexlabs.com.ar/admin/dh-inspeccion-v2.git"
if [ "$ORIGIN_URL" != "$EXPECTED_ORIGIN" ]; then
echo "ERROR: origin no apunta al Gitea autorizado."
echo "Actual: $ORIGIN_URL"
echo "Esperado: $EXPECTED_ORIGIN"
false
fi
git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción."
git status --short
false
fi
PREV_SHA="$(git rev-parse HEAD)"
PHASE="fetch"
git fetch origin "$DEPLOY_REF"
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
echo "Actual: $PREV_SHA"
echo "Objetivo: $TARGET_SHA"
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
echo "Producción ya está en el commit autorizado."
PHASE="complete"
exit 0
fi
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
false
fi
PHASE="candidate-preflight"
rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
EXPECTED_WEB_VERSION="$(node -p "require('$STAGE/web-v2/package.json').version")"
echo "API candidata: $EXPECTED_API_VERSION"
echo "WEB candidata: $EXPECTED_WEB_VERSION"
docker compose --env-file "$APP/.env" -f "$STAGE/docker-compose.yml" config >/dev/null
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
echo
echo "========== TEST API CANDIDATA =========="
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
docker run --rm \
-v "$STAGE/api-v3/test:/app/test:ro" \
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
-v "$STAGE/web-v2:/web-v2:ro" \
-v "$STAGE/android-app:/android-app:ro" \
"$API_TEST_IMAGE" npm test </dev/null
echo
echo "========== BUILD WEB CANDIDATA =========="
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
PHASE="backup"
echo
echo "========== BACKUP PRE =========="
install -d -m 700 "$BACKUP"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-before.dump"
tar \
--exclude='./.git' \
--exclude='./.env' \
--exclude='*/node_modules' \
--exclude='*/dist' \
--exclude='*.zip' \
--exclude='*.tar.gz' \
--exclude='*.tgz' \
-czf "$BACKUP/source-before.tar.gz" .
install -m 600 .env "$BACKUP/.env"
git rev-parse HEAD > "$BACKUP/previous.sha"
printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha"
docker compose ps -a > "$BACKUP/docker-before.txt"
(
cd "$BACKUP"
sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="fast-forward"
echo
echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build"
echo
echo "========== BUILD PRODUCCIÓN =========="
docker compose build api migrate web </dev/null
PHASE="migrations"
echo
echo "========== MIGRACIONES =========="
docker compose --profile tools run --rm migrate </dev/null
docker compose --profile tools run --rm migrate npm run migration:show </dev/null | tee "$BACKUP/migrations.txt"
grep -Fq 'Pending migrations: no' "$BACKUP/migrations.txt"
PHASE="recreate"
echo
echo "========== RECREATE API + WEB =========="
docker compose up -d --no-deps --force-recreate api web </dev/null
PHASE="health"
echo
echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
cat "$BACKUP/health.json"
echo
grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json"
grep -Fq '"database":"ok"' "$BACKUP/health.json"
WEB_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8182/)"
[ "$WEB_CODE" = "200" ] || { echo "ERROR: WEB HTTP $WEB_CODE"; false; }
PHASE="verify"
echo
echo "========== VERIFICACIÓN FINAL =========="
docker compose ps -a | tee "$BACKUP/docker-after.txt"
if docker compose ps --status running --services | grep -Fxq api && docker compose ps --status running --services | grep -Fxq web && docker compose ps --status running --services | grep -Fxq db; then
echo "Servicios críticos: OK"
else
echo "ERROR: falta un servicio crítico en ejecución."
false
fi
PHASE="post-backup"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-after.dump"
git rev-parse HEAD > "$BACKUP/deployed.sha"
printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt"
(
cd "$BACKUP"
sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="complete"
trap - ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY OK"
echo "============================================================"
echo "Commit: $TARGET_SHA"
echo "API: $EXPECTED_API_VERSION"
echo "WEB: $EXPECTED_WEB_VERSION"
echo "Backup: $BACKUP"
echo "============================================================"
@@ -81,12 +81,10 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
const load = () => {
setLoading(true);
setError('');
setMergeStatus(null);
Promise.all([
getAssetDossier(assetId),
getAsset(assetId),
// La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario.
getInventoryMergeStatus(assetId).catch(() => null),
getInventoryMergeStatus(assetId),
])
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier);
@@ -245,8 +245,8 @@ export function AuthoritativeInventoryConfigPage() {
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option"><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} /><span>{family.name}</span></label>)}
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)}
</div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>}
@@ -265,10 +265,10 @@ export function AuthoritativeInventoryConfigPage() {
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong>
<div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option">
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
<span>{family.name}</span>
{family.name}
</label>)}
</div>
</div>}
-52
View File
@@ -1296,55 +1296,3 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
/* D5.6.4 · combobox buscable global */
.searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px}
.inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}}
/* Parent choices need fixed-size controls even inside a generic form field. */
.inventory-parent-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 8px;
margin: 10px 0 14px;
}
.inventory-parent-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
}
.inventory-parent-option input[type="checkbox"] {
flex: 0 0 17px;
width: 17px;
height: 17px;
min-height: 17px;
padding: 0;
margin: 0;
accent-color: var(--blue);
cursor: inherit;
}
.inventory-parent-option > span {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.inventory-parent-option:has(input:checked) {
border-color: #9db9ed;
background: #eef4ff;
}
.inventory-parent-option:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.inventory-parent-option:has(input:disabled) {
cursor: default;
opacity: .65;
}