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' run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13 - name: Gradle 8.13
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 uses: gradle/actions/setup-gradle@v4
with: with:
gradle-version: '8.13' gradle-version: '8.13'
@@ -63,7 +63,7 @@ jobs:
- name: Android lint - name: Android lint
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:lintDebug :app:lintRelease run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures - name: Print complete lint failures
if: failure() if: failure()
@@ -130,14 +130,6 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 30 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 - name: Upload Android diagnostics
if: always() if: always()
uses: actions/upload-artifact@v4 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 build api
docker compose --env-file .env.example up -d 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 api_ready=0
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if docker compose --env-file .env.example exec -T api \ if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
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
api_ready=1 api_ready=1
break break
fi 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: 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 ## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.1**. - `versionName`: **0.19.0**.
- `versionCode`: **29**. - `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`. - Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`. - 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 ## 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. 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. 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; - 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; - 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. - 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 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 29 versionCode = 29
versionName = "0.19.1" versionName = "0.20.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -77,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.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.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository 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.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application) private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application) private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application) private val actsRepository = MobileActsRepository(application)
@@ -88,7 +84,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña." error = "Ingresá usuario y contraseña."
return return
} }
launchBusy(mutation = true) { launchBusy {
session = repository.login(identifier, password) session = repository.login(identifier, password)
notice = "Sesión iniciada." notice = "Sesión iniciada."
loadVisitsInternal() loadVisitsInternal()
@@ -117,9 +113,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
val loadedVisit = repository.visit(id) visit = repository.visit(id)
clearActState()
visit = loadedVisit
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
@@ -129,8 +123,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun closeVisitView() { fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null inventoryParentId = null
@@ -143,7 +135,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() { fun startVisit() {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy(mutation = true) { launchBusy {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada." 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." error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return return
} }
launchBusy(mutation = true) { launchBusy {
val created = actsRepository.create( val created = actsRepository.create(
currentVisit.id, currentVisit.id,
asset?.id, asset?.id,
@@ -202,10 +194,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
launchBusy { launchBusy {
val result = repository.fieldInventory(currentVisit.id, search, parentId).data inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
} }
} }
@@ -213,7 +203,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -227,7 +216,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy(mutation = true) { launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección." notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId) 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." error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return return
} }
launchBusy(mutation = true) { launchBusy {
val request = CreateFieldInventoryRequest( val request = CreateFieldInventoryRequest(
typeId = type.id, typeId = type.id,
parentId = parentId, parentId = parentId,
@@ -272,9 +261,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() }, description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = FieldCoordinates.latitude(latitude), deviceLatitude = latitude,
deviceLongitude = FieldCoordinates.longitude(longitude), deviceLongitude = longitude,
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy), deviceAccuracyM = accuracyM,
deviceCapturedAt = Instant.now().toString(), deviceCapturedAt = Instant.now().toString(),
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) 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." error = "Explicá brevemente por qué se trata de un duplicado."
return return
} }
launchBusy(mutation = true) { launchBusy {
val result = repository.mergeFieldAsset( val result = repository.mergeFieldAsset(
visitId = visitId, visitId = visitId,
assetId = source.id, assetId = source.id,
@@ -321,7 +310,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return val asset = selectedFieldAsset?.asset ?: return
launchBusy(mutation = true) { launchBusy {
val response = repository.uploadFieldPhoto( val response = repository.uploadFieldPhoto(
visitId = visitId, visitId = visitId,
assetId = asset.id, 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." error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return return
} }
launchBusy(mutation = true) { launchBusy {
selectedAct = actsRepository.ensureAsset(draft.id, assetId) selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false) loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id) loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -390,7 +379,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10." error = "La gravedad debe estar entre 1 y 10."
return return
} }
launchBusy(mutation = true) { launchBusy {
selectedAct = actsRepository.ensureAsset(actId, assetId) selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create( val response = findingsRepository.create(
visitId, visitId,
@@ -421,7 +410,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
) { ) {
launchBusy(mutation = true) { launchBusy {
findingsRepository.uploadObservationPhoto( findingsRepository.uploadObservationPhoto(
findingId = findingId, findingId = findingId,
file = file, file = file,
@@ -453,7 +442,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Completá nombre, documento y cargo del responsable de la empresa." error = "Completá nombre, documento y cargo del responsable de la empresa."
return return
} }
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -476,7 +465,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres." error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return return
} }
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -490,7 +479,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun prepareSelectedAct() { fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.lock(actId) actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación." 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?, accuracyM: Double?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM) actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta." notice = "Firma del inspector incorporada al Acta."
} }
@@ -523,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?, statement: String?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.signCompany( actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement, 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." error = "Indicá un motivo de al menos 10 caracteres."
return return
} }
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason) actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada." notice = "Negativa a firmar asentada."
} }
@@ -554,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeSelectedAct() { fun closeSelectedAct() {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy(mutation = true) { launchBusy {
actClosure = actsRepository.seal(actId) actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
@@ -565,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeInspection() { fun closeInspection() {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy(mutation = true) { launchBusy {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." 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() fieldFindingEvidence = emptyMap()
} }
private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) { private fun launchBusy(block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
viewModelScope.launch { viewModelScope.launch {
busy = true
error = null error = null
try { try {
block() block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
error = DhRepository.humanError(throwable) error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null if (repository.currentSession() == null) session = null
} finally { } finally {
operations.end() busy = false
busy = operations.busy
} }
} }
} }
@@ -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.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -449,6 +451,7 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -527,13 +530,13 @@ class DhRepository(context: Context) {
visitId = visitId, visitId = visitId,
assetId = assetId, assetId = assetId,
file = part, file = part,
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), latitude = latitude.toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text),
) )
} }
@@ -545,19 +548,23 @@ class DhRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session) session = refresh(session.refreshToken)
return block(session) return block(session)
} }
private suspend fun refresh(previous: StoredSession): StoredSession = private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) 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 { companion object {
fun humanError(error: Throwable): String { 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) { if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.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.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -174,6 +176,7 @@ private interface FieldFindingsApi {
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -224,9 +227,9 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), latitude = latitude.toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
) )
} }
@@ -238,11 +241,18 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session) session = refresh(session.refreshToken)
return block(session) return block(session)
} }
private suspend fun refresh(previous: StoredSession): StoredSession = private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) 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.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -308,6 +310,7 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -426,9 +429,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text) val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text) val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text) val device = "DH Android".toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text) val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text) val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text) val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) { if (company) {
api.signCompany( api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt, "Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -451,11 +454,18 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session) session = refresh(session.refreshToken)
return block(session) return block(session)
} }
private suspend fun refresh(previous: StoredSession): StoredSession = private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) 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.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -53,6 +55,7 @@ private interface MobileInspectionOpenApi {
class MobileInspectionOpenRepository(context: Context) { class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder() private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -83,11 +86,18 @@ class MobileInspectionOpenRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session) session = refresh(session.refreshToken)
return block(session) return block(session)
} }
private suspend fun refresh(previous: StoredSession): StoredSession = private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) 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 severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn 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 requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) } var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) } var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -111,7 +110,6 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId pendingPhotoFindingId = findingId
takePicture.launch(uri) 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 val findingId = requestedFindingId
requestedFindingId = null requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId) 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) { fun requestPhoto(findingId: String) {
@@ -195,7 +192,6 @@ fun FieldFindingScreen(model: MainViewModel) {
} }
} }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let { model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp)) 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
@@ -84,7 +80,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
} }
DhTheme { DhTheme {
Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) { Surface(Modifier.fillMaxSize()) {
when { when {
model.session == null -> EnhancedLoginScreen(model) { model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true passwordLoginInFlight = true
@@ -100,7 +96,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}, },
) )
model.fieldFindingOptions != null -> FieldFindingScreen(model) model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> ModernVisitRoot(model) model.visit != null -> F8VisitRoot(model)
else -> MobileHomeScreen(model) else -> MobileHomeScreen(model)
} }
} }
@@ -110,10 +106,10 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable @Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) { private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") } var identifier by rememberSaveable { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) } 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) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image( Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact), painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
@@ -207,7 +203,7 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() } LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -226,4 +222,4 @@ private fun BiometricUnlockScreen(
OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") } 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) model.loadFieldTypes(null)
} }
fun startInstallation(parent: FieldInventoryItem? = null) { fun startInstallation() {
model.clearSelectedFieldAsset() model.clearSelectedFieldAsset()
modeName = ModernInventoryMode.CREATE_INSTALLATION.name modeName = ModernInventoryMode.CREATE_INSTALLATION.name
parentId = parent?.id parentId = null
parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección" parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm() resetForm()
model.loadFieldTypes(parent?.id) model.loadFieldTypes(null)
} }
fun startSubinstallation() { fun startSubinstallation() {
@@ -349,10 +349,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
fun chooseParent(item: FieldInventoryItem) { fun chooseParent(item: FieldInventoryItem) {
if (modernItemTypeCode(item) == "yacimiento") {
startInstallation(item)
return
}
keyboard?.hide() keyboard?.hide()
focusManager.clearFocus() focusManager.clearFocus()
parentId = item.id parentId = item.id
@@ -389,8 +385,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion") ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false else -> false
} }
} } ?: model.fieldTypes.firstOrNull()
if (selectedTypeId != preferred?.id) { if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = preferred?.id selectedTypeId = preferred?.id
selectedFamilyId = null selectedFamilyId = null
familySearch = "" familySearch = ""
@@ -439,7 +435,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun createWithLocation() { fun createWithLocation() {
val type = selectedType ?: return val type = selectedType ?: return
val effectiveParent = parentId val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null
scope.launch { scope.launch {
runCatching { currentModernGeo(context) } runCatching { currentModernGeo(context) }
.onSuccess { geo -> .onSuccess { geo ->
@@ -548,9 +544,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
if (selectedCapture != null) { if (selectedCapture != null) {
if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) {
TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy)
}
ModernCaptureCard( ModernCaptureCard(
item = selectedCapture.asset, item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured, gps = selectedCapture.capture.creationGpsCaptured,
@@ -742,9 +735,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
} }
if (selectedType == null && !model.busy) { if (model.fieldTypes.isEmpty() && !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.") {} ModernLocalError("No hay un tipo habilitado para esta ubicación.") {}
OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") }
} }
if (model.fieldTypes.size > 1) { if (model.fieldTypes.size > 1) {
@@ -894,7 +886,7 @@ private fun ModernInventoryBrowse(
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.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)) { Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) { 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.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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) } item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text( Text(
@@ -954,11 +946,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo") 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()) { OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null) Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp)) 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 @Test
fun structuralSelectionUsesTheServerTypeCode() { fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }")) 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 @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() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(29, BuildConfig.VERSION_CODE) assertEquals(29, BuildConfig.VERSION_CODE)
assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME) assertEquals("0.20.0-debug", BuildConfig.VERSION_NAME)
} }
@Test @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 { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller'; import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service'; import { InventoryBrowserService } from './inventory-browser.service';
@@ -67,7 +66,6 @@ import { InventoryBrowserService } from './inventory-browser.service';
InventoryFunctionService, InventoryFunctionService,
InventoryBrowserService, InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
AssetHistoryService, AssetHistoryService,
@@ -1,13 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service'; import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>; type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] { function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: T[] = []; const result: T[] = [];
@@ -32,22 +29,11 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService { export class MergedInventoryDossierService {
constructor( constructor(
private readonly assets: AssetsService, private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService, private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService, private readonly functions: InventoryFunctionService,
) {} ) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> { 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 mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord; const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord; const requested = mergeStatus.requested as LooseRecord;
@@ -58,7 +44,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index); .filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { 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 identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null; const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier }; return { assetId, identity, dossier, functionDossier };
@@ -159,9 +145,6 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').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 { return {
asset: { asset: {
@@ -211,7 +194,6 @@ export class MergedInventoryDossierService {
media, media,
versions, versions,
timeline: timeline.slice(0, 1000), 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'); 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'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 29/); 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, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); 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, /F5:SYSTEM:OTHER:%/);
assert.match(service, /AS "isOther"/); assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.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\)/); 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 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 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, /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(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', () => { 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.match(service, /FROM inspection_acts act/);
assert.doesNotMatch(service, /inspection_reports/); assert.doesNotMatch(service, /inspection_reports/);
assert.doesNotMatch(service, /inspection_document/); 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 = () => { const load = () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setMergeStatus(null);
Promise.all([ Promise.all([
getAssetDossier(assetId), getAssetDossier(assetId),
getAsset(assetId), getAsset(assetId),
// La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario. getInventoryMergeStatus(assetId),
getInventoryMergeStatus(assetId).catch(() => null),
]) ])
.then(([loadedDossier, loadedAsset, loadedMerge]) => { .then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier); setDossier(loadedDossier as ExtendedDossier);
@@ -245,8 +245,8 @@ export function AuthoritativeInventoryConfigPage() {
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}> {canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3> <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> <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"> {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} className="inventory-parent-option"><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} /><span>{family.name}</span></label>)} {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>} </div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button> <button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>} </div>}
@@ -265,10 +265,10 @@ export function AuthoritativeInventoryConfigPage() {
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}> {selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong> <strong>Puede estar dentro de:</strong>
<div className="inventory-parent-options"> <div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option"> {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)} /> <input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
<span>{family.name}</span> {family.name}
</label>)} </label>)}
</div> </div>
</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 */ /* 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} .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}} .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;
}