diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3030b92..dd00ccb 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android CI / RC -# F6.7 field closing barrier: lint + real tests + debug artifact + release compile. +# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile. on: push: @@ -111,7 +111,7 @@ jobs: cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" sha256sum "$apk" > "${apk}.sha256" { - echo "phase=F6.7" + echo "phase=F6.8" echo "version=$version" echo "versionCode=$code" echo "commit=$GITHUB_SHA" diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 9da78cf..b649dff 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,18 +1,28 @@ -# DH Inspección Android · release final de campo 0.19.9 +# DH Inspección Android · release final de campo 0.19.10 ## Candidata vigente - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. -- `versionName`: **0.19.9**. -- `versionCode`: **37**. +- `versionName`: **0.19.10**. +- `versionCode`: **38**. - Application ID release: `com.korexlabs.dhinspeccion`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - API: `https://dhv2.korexlabs.com/api/v3/`. -- Servidor compatible de esta candidata: **API 0.29.0-7 / WEB 0.23.0-4**. +- Servidor compatible de esta candidata: **API 0.29.0-8 / WEB 0.23.0-5**. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. -### Ajustes 0.19.9 +### Ajustes 0.19.10 + +- **Trabajo offline seguro:** Room conserva operaciones pendientes y WorkManager las sincroniza cuando vuelve la red, respetando el orden en que fueron creadas. +- La cola offline queda separada por usuario autenticado para impedir que una sesión sincronice trabajo de otra persona en el mismo dispositivo. +- Inspecciones, Actas, Inventario, Hallazgos, fotos, representante, manifestación y cierre conservan una identidad de cliente estable para reintentos idempotentes. +- Las listas de trabajo, Inventario, Actas, cierres, Hallazgos y estado de firma usan caché local por usuario para poder consultar contexto ya descargado sin conectividad. +- Antes de incorporar una foto de Hallazgo se muestra **Revisar fotografía** con **Usar esta foto**, **Volver a tomar** y **Eliminar foto**. +- Corrige el cierre que enviaba `uploadMode=ONLINE`; el contrato vigente usa `IMMEDIATE` o `DEFERRED`. +- La APK consulta el estado real de la firma reutilizable del Inspector y no afirma que existe cuando todavía no fue configurada. +- El Dashboard de Actas prioriza el **PDF real del Acta** y muestra directamente las fotografías de Hallazgos y del Inventario relacionado. +- En toda la interfaz activa, la antigua expresión “clasificación técnica” se presenta como **Tipo de instalación** o **Tipo de subinstalación**. - Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED. - El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante. @@ -25,7 +35,7 @@ La variante debug es independiente de la app productiva y puede instalarse para ## Procedimiento operativo validado -La APK 0.19.9 fija como recorrido principal de campo: +La APK 0.19.10 fija como recorrido principal de campo: 1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección. 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. La urgencia todavía no se define. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index c4097d8..0bf2b85 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.kapt") } android { @@ -12,8 +13,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 37 - versionName = "0.19.9" + versionCode = 38 + versionName = "0.19.10" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -73,6 +74,10 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.google.android.gms:play-services-location:21.3.0") implementation("androidx.exifinterface:exifinterface:1.4.1") + implementation("androidx.room:room-runtime:2.7.2") + implementation("androidx.room:room-ktx:2.7.2") + kapt("androidx.room:room-compiler:2.7.2") + implementation("androidx.work:work-runtime-ktx:2.10.3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 21f5f89..d47dacd 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -6,7 +6,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.korexlabs.dhinspeccion.data.AssetSummary import com.korexlabs.dhinspeccion.data.FieldCoordinates +import com.korexlabs.dhinspeccion.data.CaptureStatus import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.DhRepository @@ -18,10 +20,14 @@ import com.korexlabs.dhinspeccion.data.FieldFindingsRepository import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType import com.korexlabs.dhinspeccion.data.MobileActClosure +import com.korexlabs.dhinspeccion.data.MobileActClosureHeader import com.korexlabs.dhinspeccion.data.MobileActDetail import com.korexlabs.dhinspeccion.data.MobileActSummary import com.korexlabs.dhinspeccion.data.MobileActsRepository +import com.korexlabs.dhinspeccion.data.MobileVisitClosureHeader +import com.korexlabs.dhinspeccion.data.MobileResponsible import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest +import com.korexlabs.dhinspeccion.data.offline.OfflineMutationQueue import com.korexlabs.dhinspeccion.data.StoredSession import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitSummary @@ -36,6 +42,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val repository = DhRepository(application) private val findingsRepository = FieldFindingsRepository(application) private val actsRepository = MobileActsRepository(application) + private val offlineQueue = OfflineMutationQueue(application) var session: StoredSession? by mutableStateOf(repository.currentSession()) private set @@ -45,6 +52,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private set var notice: String? by mutableStateOf(null) private set + var pendingSyncCount by mutableStateOf(0) + private set + var inspectorSignatureConfigured: Boolean? by mutableStateOf(null) + private set + var selectedActCompanySignaturePending by mutableStateOf(false) + private set + var selectedActSealPending by mutableStateOf(false) + private set var visits: List by mutableStateOf(emptyList()) private set @@ -75,6 +90,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private set init { + OfflineMutationQueue.schedule(application) + viewModelScope.launch { refreshOfflineStateInternal() } if (session != null) loadVisits() } @@ -143,24 +160,80 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun startVisit() { val id = visit?.id ?: return + val previousVisit = visit launchBusy(mutation = true) { - visit = repository.startVisit(id) - inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id - notice = "Inspección iniciada." - loadActsInternal(id, selectDraft = true) - loadVisitsInternal() + offlineQueue.enqueueVisitStart(id) + val localStartedAt = Instant.now().toString() + visit = visit?.copy(status = "IN_PROGRESS", actualStartedAt = localStartedAt) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + visit = repository.visit(id) + inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id + notice = "Inspección iniciada." + loadActsInternal(id, selectDraft = true) + loadVisitsInternal() + } else if (result.retry) { + inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id + notice = "Inspección iniciada en el dispositivo. Podés trabajar sin conexión; se sincronizará automáticamente." + } else { + visit = previousVisit + error = result.lastError ?: "No se pudo iniciar la Inspección." + } } } fun reloadActs() { val visitId = visit?.id ?: return - launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) } + launchBusy { + loadActsInternal(visitId, selectDraft = selectedAct == null) + refreshInspectorSignatureStatusInternal() + refreshOfflineStateInternal() + } + } + + fun syncPendingNow() { + launchBusy { + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + selectedAct?.id?.let { actId -> + runCatching { refreshSelectedActInternal(actId) } + } + if (result.pending == 0) { + notice = if (result.synced > 0) "Sincronización completa. ${result.synced} cambio${if (result.synced == 1) "" else "s"} enviado${if (result.synced == 1) "" else "s"}." else "No hay cambios pendientes de sincronización." + } else if (result.retry) { + notice = "Sin conexión. Los cambios siguen guardados en este dispositivo." + } else { + error = result.lastError ?: "Quedan cambios pendientes de sincronización." + } + } + } + + fun refreshInspectorSignatureStatus() { + viewModelScope.launch { + refreshInspectorSignatureStatusInternal() + if (inspectorSignatureConfigured == true && pendingSyncCount > 0) { + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + val actId = selectedAct?.id + if (actId != null && !selectedActSealPending) { + runCatching { refreshSelectedActInternal(actId) } + } + when { + result.pending == 0 -> notice = "Firma verificada y cambios pendientes sincronizados." + result.retry -> notice = "Firma verificada. Los cambios siguen guardados hasta recuperar conexión." + else -> error = result.lastError ?: "Quedan cambios pendientes de sincronización." + } + } + } } fun selectAct(actId: String) { launchBusy { selectedAct = actsRepository.get(actId) actClosure = actsRepository.closure(actId) + refreshInspectorSignatureStatusInternal() + refreshOfflineStateInternal() clearFindingState() } } @@ -176,11 +249,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - val created = actsRepository.create(currentVisit.id, currentVisit.code) - selectedAct = created - actClosure = actsRepository.closure(created.id) - loadActsInternal(currentVisit.id, selectDraft = false) - notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo." + val actId = DhRepository.newOperationId() + val occurredAt = Instant.now().toString() + offlineQueue.enqueueActCreate(actId, currentVisit.id, currentVisit.code, occurredAt) + val localCode = "ACTA-PEND-${actId.take(6).uppercase()}" + val local = MobileActDetail( + id = actId, + visitId = currentVisit.id, + code = localCode, + status = "DRAFT", + occurredAt = occurredAt, + title = "Acta de inspección ${currentVisit.code}", + summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", + ) + selectedAct = local + acts = acts + MobileActSummary( + id = actId, visitId = currentVisit.id, code = localCode, status = "DRAFT", occurredAt = occurredAt, + title = local.title, summary = local.summary, + ) + actClosure = MobileActClosure( + act = MobileActClosureHeader( + id = actId, code = localCode, status = "DRAFT", visitId = currentVisit.id, currentVersion = 0, + ), + visit = MobileVisitClosureHeader( + id = currentVisit.id, code = currentVisit.code, status = currentVisit.status, actualClosedAt = currentVisit.actualClosedAt, + ), + ) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + loadActsInternal(currentVisit.id, selectDraft = false) + refreshSelectedActInternal(actId) + notice = "${selectedAct?.code ?: localCode} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo." + } else { + notice = "Acta creada en el dispositivo. Podés continuar trabajando; recibirá su código oficial cuando vuelva internet." + } } } @@ -209,16 +312,55 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { inventory = repository.fieldInventory(visitId, null, effectiveParentId).data } + private suspend fun ensureAssetInActOffline( + actId: String, + assetId: String, + code: String, + name: String, + typeName: String?, + ): OfflineMutationQueue.SyncResult { + if (selectedAct?.assets?.none { it.id == assetId } != false) { + offlineQueue.enqueueActAsset(actId, assetId) + selectedAct = selectedAct?.copy( + assets = selectedAct!!.assets + AssetSummary(assetId, code, name, typeName), + assetCount = selectedAct!!.assetCount + 1, + ) + acts = acts.map { if (it.id == actId) it.copy(assetCount = selectedAct?.assetCount ?: it.assetCount) else it } + } + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) selectedAct = actsRepository.get(actId) + return result + } + fun selectExisting(item: FieldInventoryItem) { val visitId = visit?.id ?: return launchBusy(mutation = true) { - selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) - notice = "Inventario agregado a la Inspección." - reloadCurrentInventory(visitId) + offlineQueue.enqueueFieldAssetSelect(visitId, item.id) + selectedFieldAsset = FieldAssetDetail( + context = null, + asset = item.copy(selectedInInspection = true), + selectedInInspection = true, + capture = CaptureStatus( + captureRequired = item.captureRequired, + hasGeometry = item.hasGeometry, + creationGpsCaptured = item.hasGeometry, + fieldPhotoCount = item.fieldPhotoCount, + readyForFinding = item.readyForFinding, + ), + ) + val sync = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (sync.pending == 0) { + selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) + runCatching { reloadCurrentInventory(visitId) } + notice = "Inventario agregado a la Inspección." + } else { + notice = "Inventario seleccionado en el dispositivo. Se sincronizará automáticamente al volver internet." + } val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { - selectedAct = actsRepository.ensureAsset(draft.id, item.id) - loadActsInternal(visitId, selectDraft = false) + ensureAssetInActOffline(draft.id, item.id, item.code, item.name, item.type?.name) loadFindingOptionsInternal(visitId, item.id, draft.id) } else if (selectedFieldAsset?.capture?.readyForFinding == true) { notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." @@ -248,7 +390,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { + val assetId = DhRepository.newOperationId() + val capturedAt = Instant.now().toString() val request = CreateFieldInventoryRequest( + clientGeneratedId = assetId, typeId = type.id, parentId = parentId, familyId = familyId, @@ -259,11 +404,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { deviceLatitude = FieldCoordinates.latitude(latitude), deviceLongitude = FieldCoordinates.longitude(longitude), deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy), - deviceCapturedAt = Instant.now().toString(), + deviceCapturedAt = capturedAt, ) - selectedFieldAsset = repository.createFieldAsset(visitId, request) - notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." - reloadCurrentInventory(visitId) + offlineQueue.enqueueFieldAssetCreate(assetId, visitId, request) + val localAsset = FieldInventoryItem( + id = assetId, + code = "PEND-${assetId.take(6).uppercase()}", + name = request.name, + commonName = request.commonName, + informationStatus = "DRAFT", + dataOrigin = "FIELD_SURVEY", + type = AssetSummary(type.id, type.code, type.name, type.name), + selectedInInspection = true, + captureRequired = true, + hasGeometry = true, + fieldPhotoCount = 0, + readyForFinding = false, + ) + selectedFieldAsset = FieldAssetDetail( + asset = localAsset, + selectedInInspection = true, + capture = CaptureStatus( + captureRequired = true, + hasGeometry = true, + creationGpsCaptured = true, + fieldPhotoCount = 0, + readyForFinding = false, + ), + ) + inventory = inventory + localAsset + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + selectedFieldAsset = repository.selectFieldAsset(visitId, assetId) + runCatching { reloadCurrentInventory(visitId) } + notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." + } else { + notice = "Inventario creado y guardado en el dispositivo. Sacá la foto obligatoria; se sincronizará automáticamente al volver internet." + } } } @@ -290,8 +468,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { reloadCurrentInventory(visitId) val draft = selectedDraftAct() if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { - selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) - loadActsInternal(visitId, selectDraft = false) + ensureAssetInActOffline( + draft.id, result.canonical.id, result.canonical.code, result.canonical.name, result.canonical.typeName, + ) loadFindingOptionsInternal(visitId, result.canonical.id, draft.id) } } @@ -306,27 +485,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val visitId = visit?.id ?: return val asset = selectedFieldAsset?.asset ?: return launchBusy(mutation = true) { - val response = repository.uploadFieldPhoto( - visitId = visitId, + offlineQueue.enqueueFieldAssetPhoto( assetId = asset.id, - file = file, + visitId = visitId, + source = file, latitude = latitude, longitude = longitude, accuracyM = accuracyM, ) - selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture) - notice = if (response.capture.readyForFinding) { - "Inventario listo: ubicación y foto registradas." + val localCapture = selectedFieldAsset?.capture?.copy( + hasGeometry = true, + creationGpsCaptured = true, + fieldPhotoCount = (selectedFieldAsset?.capture?.fieldPhotoCount ?: 0) + 1, + readyForFinding = true, + ) ?: CaptureStatus( + captureRequired = true, hasGeometry = true, creationGpsCaptured = true, fieldPhotoCount = 1, readyForFinding = true, + ) + selectedFieldAsset = selectedFieldAsset?.copy( + asset = asset.copy(hasGeometry = true, fieldPhotoCount = localCapture.fieldPhotoCount, readyForFinding = true), + capture = localCapture, + ) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + selectedFieldAsset = repository.selectFieldAsset(visitId, asset.id) + runCatching { reloadCurrentInventory(visitId) } + notice = "Inventario listo: ubicación y foto registradas." } else { - "Fotografía registrada." + notice = "Foto del Inventario guardada en el dispositivo. Ya podés continuar; se sincronizará al volver internet." } - reloadCurrentInventory(visitId) val draft = selectedDraftAct() - if (response.capture.readyForFinding && draft != null) { - selectedAct = actsRepository.ensureAsset(draft.id, asset.id) - loadActsInternal(visitId, selectDraft = false) - loadFindingOptionsInternal(visitId, asset.id, draft.id) - } else if (response.capture.readyForFinding) { + if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { + val current = selectedFieldAsset?.asset ?: asset + ensureAssetInActOffline(draft.id, current.id, current.code, current.name, current.type?.name) + loadFindingOptionsInternal(visitId, current.id, draft.id) + } else if (selectedFieldAsset?.capture?.readyForFinding == true) { notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." } } @@ -341,8 +534,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - selectedAct = actsRepository.ensureAsset(draft.id, assetId) - loadActsInternal(visitId, selectDraft = false) + val asset = selectedFieldAsset?.asset ?: return@launchBusy + ensureAssetInActOffline(draft.id, assetId, asset.code, asset.name, asset.type?.name) loadFindingOptionsInternal(visitId, assetId, draft.id) } } @@ -374,23 +567,60 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - selectedAct = actsRepository.ensureAsset(actId, assetId) - val response = findingsRepository.create( - visitId, - assetId, - CreateFieldFindingRequest( - actId = actId, - catalogItemId = catalogItemId, - customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() }, - customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() }, - description = description.trim(), - severity = severity, - ), + val clientFindingId = DhRepository.newOperationId() + val request = CreateFieldFindingRequest( + actId = actId, + clientGeneratedId = clientFindingId, + catalogItemId = catalogItemId, + customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() }, + customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() }, + description = description.trim(), + severity = severity, ) - lastCreatedFinding = response.finding - notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica." - loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true) - loadActsInternal(visitId, selectDraft = false) + if (selectedAct?.assets?.none { it.id == assetId } != false) { + offlineQueue.enqueueActAsset(actId, assetId) + selectedFieldAsset?.asset?.let { asset -> + selectedAct = selectedAct?.copy(assets = selectedAct!!.assets + AssetSummary( + id = asset.id, + code = asset.code, + name = asset.name, + typeName = asset.type?.name, + )) + } + } + offlineQueue.enqueueFindingCreate(clientFindingId, visitId, assetId, request) + + val catalog = fieldFindingOptions?.catalog?.items?.firstOrNull { it.id == catalogItemId } + val localNumber = (fieldFindingOptions?.findings?.maxOfOrNull { it.findingNumber } ?: 0) + 1 + val localFinding = FieldFindingItem( + id = clientFindingId, + actId = actId, + assetId = assetId, + catalogItemId = catalogItemId, + findingNumber = localNumber, + code = "PEND-H${localNumber.toString().padStart(3, '0')}", + status = "OPEN", + title = catalog?.title ?: customTitle?.trim().orEmpty().ifBlank { "Hallazgo pendiente" }, + description = description.trim(), + severity = severity ?: catalog?.suggestedSeverity, + suggestedSeverity = catalog?.suggestedSeverity, + ) + lastCreatedFinding = localFinding + fieldFindingOptions = fieldFindingOptions?.copy( + findings = fieldFindingOptions!!.findings + localFinding, + canAddAnother = true, + ) + + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true) + lastCreatedFinding = fieldFindingOptions?.findings?.firstOrNull { it.id == clientFindingId } ?: localFinding + loadActsInternal(visitId, selectDraft = false) + notice = "Hallazgo ${lastCreatedFinding?.code ?: localFinding.code} registrado. Podés agregar evidencia fotográfica." + } else { + notice = "Hallazgo guardado en el dispositivo. Podés seguir sacando fotos; todo se sincronizará automáticamente al volver internet." + } } } @@ -404,17 +634,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { description: String? = null, ) { launchBusy(mutation = true) { - findingsRepository.uploadObservationPhoto( + offlineQueue.enqueueFindingPhoto( findingId = findingId, - file = file, + source = file, latitude = latitude, longitude = longitude, accuracyM = accuracyM, title = title, description = description, ) - loadEvidenceInternal(findingId) - notice = "Evidencia fotográfica registrada con GPS." + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + loadEvidenceInternal(findingId) + notice = "Evidencia fotográfica registrada con GPS." + } else { + notice = "Foto guardada en el dispositivo. Se sincronizará automáticamente al volver la conexión." + } } } @@ -438,19 +674,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - actClosure = actsRepository.setResponsible( - actId, - MobileResponsibleRequest( - attendanceStatus = "PRESENT", - fullName = fullName.trim(), - documentType = documentType, - documentNumber = documentNumber.trim(), - position = position.trim(), - email = normalizedEmail.lowercase(), - phone = phone?.trim()?.takeIf { it.isNotBlank() }, - ), + val request = MobileResponsibleRequest( + attendanceStatus = "PRESENT", + fullName = fullName.trim(), + documentType = documentType, + documentNumber = documentNumber.trim(), + position = position.trim(), + email = normalizedEmail.lowercase(), + phone = phone?.trim()?.takeIf { it.isNotBlank() }, ) - notice = "Representante de la empresa registrado para esta Acta." + offlineQueue.enqueueResponsible(actId, request) + actClosure = actClosure?.copy(responsible = MobileResponsible( + actId = actId, + attendanceStatus = request.attendanceStatus, + fullName = request.fullName, + documentType = request.documentType, + documentNumber = request.documentNumber, + position = request.position, + email = request.email, + phone = request.phone, + )) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + actClosure = actsRepository.closure(actId) + notice = "Representante de la empresa registrado para esta Acta." + } else { + notice = "Representante guardado en el dispositivo. Se sincronizará automáticamente cuando vuelva internet." + } } } @@ -461,14 +712,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - actClosure = actsRepository.setResponsible( - actId, - MobileResponsibleRequest( - attendanceStatus = "ABSENT", - absenceReason = reason.trim(), - ), + val request = MobileResponsibleRequest( + attendanceStatus = "ABSENT", + absenceReason = reason.trim(), ) - notice = "Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta." + offlineQueue.enqueueResponsible(actId, request) + actClosure = actClosure?.copy(responsible = MobileResponsible( + actId = actId, + attendanceStatus = "ABSENT", + absenceReason = reason.trim(), + )) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) actClosure = actsRepository.closure(actId) + notice = if (result.pending == 0) { + "Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta." + } else { + "Ausencia guardada en el dispositivo. Se sincronizará automáticamente cuando vuelva internet." + } } } @@ -479,10 +740,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - actClosure = actsRepository.lock(actId, urgency) - refreshSelectedActInternal(actId) + offlineQueue.enqueueActLock(actId, urgency) + selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency) + acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency) else it } + actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency)) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" - notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas." + if (result.pending == 0) { + refreshSelectedActInternal(actId) + notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas." + } else { + notice = "Cierre de contenido guardado en el dispositivo como $urgencyLabel. Podés continuar con la firma; se sincronizará al recuperar internet." + } } } @@ -513,14 +783,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) { val actId = selectedAct?.id ?: return launchBusy(mutation = true) { - actClosure = actsRepository.signCompany( - actId, png, latitude, longitude, accuracyM, manifestation, statement, + offlineQueue.enqueueCompanySignature( + actId = actId, + source = png, + latitude = latitude, + longitude = longitude, + accuracyM = accuracyM, + manifestation = manifestation, + statement = statement, ) - notice = if (manifestation == "DISSENT") { - "Firma del representante registrada en disconformidad." + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (!selectedActCompanySignaturePending) { + actClosure = actsRepository.closure(actId) + notice = if (manifestation == "DISSENT") { + "Firma del representante registrada en disconformidad." + } else { + "Firma del representante de la empresa registrada." + } } else { - "Firma del representante de la empresa registrada." + notice = "Firma guardada en el dispositivo. Quedó pendiente de sincronización y no se perderá si no hay internet." } + result.lastError?.takeIf { result.pending > 0 && !result.retry }?.let { error = it } } } @@ -535,8 +819,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy(mutation = true) { - actClosure = actsRepository.companyOutcome(actId, status, reason) - notice = "Negativa a firmar asentada." + offlineQueue.enqueueCompanyOutcome(actId, status, reason.trim()) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + actClosure = actsRepository.closure(actId) + notice = "Negativa a firmar asentada." + } else { + notice = "Negativa a firmar guardada en el dispositivo. Se sincronizará automáticamente al recuperar internet." + } } } @@ -544,20 +835,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val currentVisit = visit ?: return val actId = selectedAct?.id ?: return launchBusy(mutation = true) { - actClosure = actsRepository.seal(actId) - refreshSelectedActInternal(actId) - loadActsInternal(currentVisit.id, selectDraft = false) - clearFindingState() - notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección." + offlineQueue.enqueueSeal(actId) + if (inspectorSignatureConfigured == false) { + refreshOfflineStateInternal() + notice = "Cierre guardado en el dispositivo. Falta configurar la firma del inspector en Mi perfil del Dashboard; después se sincronizará automáticamente." + return@launchBusy + } + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (!selectedActSealPending) { + refreshSelectedActInternal(actId) + loadActsInternal(currentVisit.id, selectDraft = false) + clearFindingState() + notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección." + } else if (result.retry) { + val localClosedAt = Instant.now().toString() + selectedAct = selectedAct?.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) + acts = acts.map { if (it.id == actId) it.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) else it } + clearFindingState() + notice = "Acta cerrada en el dispositivo. Podés continuar con otra Acta; se sincronizará automáticamente al recuperar internet." + } else { + error = result.lastError ?: "El cierre quedó pendiente de sincronización." + } } } fun closeInspection() { val visitId = visit?.id ?: return + val previousVisit = visit launchBusy(mutation = true) { - visit = actsRepository.closeVisit(visitId) - loadVisitsInternal() - notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." + val closedAt = Instant.now().toString() + offlineQueue.enqueueVisitClose(visitId, closedAt) + visit = visit?.copy(status = "CLOSED", actualClosedAt = closedAt) + val result = offlineQueue.syncAll() + refreshOfflineStateInternal() + if (result.pending == 0) { + visit = repository.visit(visitId) + loadVisitsInternal() + notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." + } else if (result.retry) { + notice = "Cierre de Inspección guardado en el dispositivo. Quedará sincronizado automáticamente cuando vuelva internet." + } else { + visit = previousVisit + error = result.lastError ?: "No se pudo cerrar la Inspección." + } } } @@ -595,6 +916,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { selectedAct = null actClosure = null } + refreshOfflineStateInternal() } private suspend fun refreshSelectedActInternal(actId: String) { @@ -625,6 +947,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) } + private suspend fun refreshInspectorSignatureStatusInternal() { + runCatching { actsRepository.selfSignatureStatus() } + .onSuccess { inspectorSignatureConfigured = it.configured } + } + + private suspend fun refreshOfflineStateInternal() { + pendingSyncCount = offlineQueue.pendingCount() + val actId = selectedAct?.id + if (actId == null) { + selectedActCompanySignaturePending = false + selectedActSealPending = false + } else { + selectedActCompanySignaturePending = offlineQueue.hasPending("COMPANY_SIGNATURE", actId) + || offlineQueue.hasPending("COMPANY_OUTCOME", actId) + selectedActSealPending = offlineQueue.hasPending("ACT_SEAL", actId) + } + } + private fun clearActState() { acts = emptyList() selectedAct = null diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt index 293c173..771e279 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt @@ -5,6 +5,8 @@ import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import com.korexlabs.dhinspeccion.BuildConfig +import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache +import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.MediaType.Companion.toMediaType @@ -25,6 +27,7 @@ import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import java.io.File +import java.io.IOException import java.security.KeyStore import java.time.Instant import java.util.UUID @@ -254,6 +257,7 @@ data class FieldAssetDetail( ) data class CreateFieldInventoryRequest( + val clientGeneratedId: String? = null, val typeId: String, val parentId: String? = null, val familyId: String? = null, @@ -366,6 +370,7 @@ interface DhApi { @Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?, @Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody, @Part("deviceLabel") deviceLabel: okhttp3.RequestBody, + @Part("operationId") operationId: okhttp3.RequestBody?, @Part("exifLatitude") exifLatitude: okhttp3.RequestBody?, @Part("exifLongitude") exifLongitude: okhttp3.RequestBody?, @Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?, @@ -448,8 +453,14 @@ class SecureSessionStore(context: Context) { } class DhRepository(context: Context) { - private val store = SecureSessionStore(context.applicationContext) + private val appContext = context.applicationContext + private val store = SecureSessionStore(appContext) + private val cache = OfflineJsonCache(appContext) private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val visitListAdapter = moshi.adapter(VisitListResponse::class.java) + private val visitAdapter = moshi.adapter(VisitDetail::class.java) + private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java) + private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java) private val api: DhApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(OkHttpClient.Builder().build()) @@ -468,25 +479,47 @@ class DhRepository(context: Context) { store.clear() } - suspend fun visits(): VisitListResponse = authorized { session -> - api.visits("Bearer ${session.accessToken}", session.userId) + suspend fun visits(): VisitListResponse { + val session = store.load() ?: throw IllegalStateException("Sesión no iniciada") + return cached("visits:${session.userId}", visitListAdapter) { + authorized { active -> api.visits("Bearer ${active.accessToken}", active.userId) } + } } - suspend fun visit(id: String): VisitDetail = authorized { session -> - api.visit("Bearer ${session.accessToken}", id) + suspend fun visit(id: String): VisitDetail = cached("visit:$id", visitAdapter) { + authorized { session -> api.visit("Bearer ${session.accessToken}", id) } } - suspend fun startVisit(id: String): VisitDetail = authorized { session -> - api.startVisit("Bearer ${session.accessToken}", id) + suspend fun startVisit(id: String): VisitDetail { + val value = authorized { session -> api.startVisit("Bearer ${session.accessToken}", id) } + cache.put(userCacheKey("visit:$id"), visitAdapter.toJson(value)) + return value } - suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session -> - api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId) + suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null): FieldInventoryListResponse { + val normalizedSearch = search?.trim()?.takeIf { it.isNotBlank() } + val baseKey = "inventory:$visitId:${parentId ?: "root"}" + return try { + val value = authorized { session -> + api.fieldInventory("Bearer ${session.accessToken}", visitId, normalizedSearch, parentId) + } + if (normalizedSearch == null) cache.put(userCacheKey(baseKey), inventoryAdapter.toJson(value)) + value + } catch (error: IOException) { + val cached = cache.get(userCacheKey(baseKey))?.let(inventoryAdapter::fromJson) ?: throw error + if (normalizedSearch == null) cached else cached.copy( + data = cached.data.filter { item -> + item.code.contains(normalizedSearch, ignoreCase = true) + || item.name.contains(normalizedSearch, ignoreCase = true) + }, + ) + } } - suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session -> - api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) - } + suspend fun fieldTypes(visitId: String, parentId: String?): FieldTypeResponse = + cached("field-types:$visitId:${parentId ?: "root"}", fieldTypeAdapter) { + authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) } + } suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session -> api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId) @@ -518,6 +551,7 @@ class DhRepository(context: Context) { longitude: Double, accuracyM: Double?, capturedAt: String = Instant.now().toString(), + operationId: String? = null, ): FieldPhotoResponse = authorized { session -> val text = "text/plain".toMediaType() val body = file.asRequestBody("image/jpeg".toMediaType()) @@ -532,12 +566,27 @@ class DhRepository(context: Context) { accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text), + operationId = operationId?.toRequestBody(text), exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text), ) } + private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key" + + private suspend fun cached( + key: String, + adapter: JsonAdapter, + online: suspend () -> T, + ): T { + return try { + online().also { cache.put(userCacheKey(key), adapter.toJson(it)) } + } catch (error: IOException) { + cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error + } + } + private suspend fun authorized(block: suspend (StoredSession) -> T): T { var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") try { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt index 0f8bf0c..4111eb0 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt @@ -2,6 +2,8 @@ package com.korexlabs.dhinspeccion.data import android.content.Context import com.korexlabs.dhinspeccion.BuildConfig +import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache +import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.MediaType.Companion.toMediaType @@ -22,6 +24,7 @@ import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import java.io.File +import java.io.IOException import java.time.Instant data class FieldFindingAct( @@ -108,6 +111,7 @@ data class FieldFindingOptionsResponse( data class CreateFieldFindingRequest( val actId: String, + val clientGeneratedId: String? = null, val catalogItemId: String? = null, val customTitle: String? = null, val customLegalBasis: String? = null, @@ -161,6 +165,7 @@ private interface FieldFindingsApi { @Part("longitude") longitude: RequestBody, @Part("accuracyM") accuracyM: RequestBody?, @Part("deviceLabel") deviceLabel: RequestBody, + @Part("operationId") operationId: RequestBody?, ): FieldFindingEvidence @POST("auth/mobile/refresh") @@ -172,8 +177,12 @@ private interface FieldFindingsApi { * F3.2 exige que la APK identifique explícitamente el Acta activa. */ class FieldFindingsRepository(context: Context) { - private val store = SecureSessionStore(context.applicationContext) + private val appContext = context.applicationContext + private val store = SecureSessionStore(appContext) + private val cache = OfflineJsonCache(appContext) private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val optionsAdapter = moshi.adapter(FieldFindingOptionsResponse::class.java) + private val evidenceAdapter = moshi.adapter(FieldFindingEvidenceListResponse::class.java) private val api: FieldFindingsApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(OkHttpClient.Builder().build()) @@ -181,10 +190,28 @@ class FieldFindingsRepository(context: Context) { .build() .create(FieldFindingsApi::class.java) - suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse = - authorized { session -> - api.options("Bearer ${session.accessToken}", visitId, assetId, actId) + suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse { + val exactKey = "finding-options:$visitId:$assetId:$actId" + val templateKey = "finding-options-template:$visitId:$assetId" + return try { + val value = authorized { session -> + api.options("Bearer ${session.accessToken}", visitId, assetId, actId) + } + val json = optionsAdapter.toJson(value) + cache.put(userCacheKey(exactKey), json) + cache.put(userCacheKey(templateKey), json) + value + } catch (error: IOException) { + cache.get(userCacheKey(exactKey))?.let(optionsAdapter::fromJson) + ?: cache.get(userCacheKey(templateKey))?.let(optionsAdapter::fromJson)?.copy( + act = FieldFindingAct(id = actId, code = "Acta local pendiente", status = "DRAFT"), + findings = emptyList(), + assetIncludedInAct = true, + canAddAnother = true, + ) + ?: throw error } + } suspend fun create( visitId: String, @@ -194,9 +221,10 @@ class FieldFindingsRepository(context: Context) { api.create("Bearer ${session.accessToken}", visitId, assetId, request) } - suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session -> - api.evidence("Bearer ${session.accessToken}", findingId) - } + suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = + cached("finding-evidence:$findingId", evidenceAdapter) { + authorized { session -> api.evidence("Bearer ${session.accessToken}", findingId) } + } suspend fun uploadObservationPhoto( findingId: String, @@ -207,6 +235,7 @@ class FieldFindingsRepository(context: Context) { title: String? = null, description: String? = null, capturedAt: String = Instant.now().toString(), + operationId: String? = null, ): FieldFindingEvidence = authorized { session -> val text = "text/plain".toMediaType() val part = MultipartBody.Part.createFormData( @@ -227,9 +256,24 @@ class FieldFindingsRepository(context: Context) { longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text), + operationId = operationId?.toRequestBody(text), ) } + private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key" + + private suspend fun cached( + key: String, + adapter: JsonAdapter, + online: suspend () -> T, + ): T { + return try { + online().also { cache.put(userCacheKey(key), adapter.toJson(it)) } + } catch (error: IOException) { + cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error + } + } + private suspend fun authorized(block: suspend (StoredSession) -> T): T { var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") try { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index c7c317c..c2a63b9 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -2,6 +2,8 @@ package com.korexlabs.dhinspeccion.data import android.content.Context import com.korexlabs.dhinspeccion.BuildConfig +import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache +import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okhttp3.MediaType.Companion.toMediaType @@ -24,6 +26,7 @@ import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import java.io.File +import java.io.IOException import java.time.Instant data class MobileActSummary( @@ -90,6 +93,7 @@ data class MobileActListResponse( ) data class CreateMobileActRequest( + val clientGeneratedId: String? = null, val occurredAt: String, val title: String, val summary: String, @@ -194,6 +198,14 @@ data class MobileActClosure( val consents: MobileClosureConsents = MobileClosureConsents(), ) +data class MobileProfileSignatureStatus( + val configured: Boolean = false, + val mimeType: String? = null, + val sizeBytes: Int? = null, + val imageSha256: String? = null, + val updatedAt: String? = null, +) + data class MobileCompanyOutcomeRequest( val status: String, val reason: String, @@ -201,7 +213,7 @@ data class MobileCompanyOutcomeRequest( data class MobileSealActRequest( val clientClosedAt: String = Instant.now().toString(), - val uploadMode: String = "ONLINE", + val uploadMode: String = "IMMEDIATE", ) data class MobileCloseVisitRequest( @@ -236,6 +248,11 @@ private interface MobileActsApi { @Body request: UpdateMobileActRequest, ): MobileActDetail + @GET("users/self/signature") + suspend fun selfSignature( + @Header("Authorization") authorization: String, + ): MobileProfileSignatureStatus + @GET("inspection-acts/{actId}/closure") suspend fun closure( @Header("Authorization") authorization: String, @@ -312,8 +329,14 @@ private interface MobileActsApi { } class MobileActsRepository(context: Context) { - private val store = SecureSessionStore(context.applicationContext) + private val appContext = context.applicationContext + private val store = SecureSessionStore(appContext) + private val cache = OfflineJsonCache(appContext) private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val actListAdapter = moshi.adapter(MobileActListResponse::class.java) + private val actAdapter = moshi.adapter(MobileActDetail::class.java) + private val closureAdapter = moshi.adapter(MobileActClosure::class.java) + private val signatureStatusAdapter = moshi.adapter(MobileProfileSignatureStatus::class.java) private val api: MobileActsApi = Retrofit.Builder() .baseUrl(BuildConfig.API_BASE_URL) .client(OkHttpClient.Builder().build()) @@ -321,28 +344,36 @@ class MobileActsRepository(context: Context) { .build() .create(MobileActsApi::class.java) - suspend fun list(visitId: String): MobileActListResponse = authorized { session -> - api.listActs("Bearer ${session.accessToken}", visitId) - } + suspend fun list(visitId: String): MobileActListResponse = + cached("acts:$visitId", actListAdapter) { + authorized { session -> api.listActs("Bearer ${session.accessToken}", visitId) } + } - suspend fun get(actId: String): MobileActDetail = authorized { session -> - api.act("Bearer ${session.accessToken}", actId) + suspend fun get(actId: String): MobileActDetail = cached("act:$actId", actAdapter) { + authorized { session -> api.act("Bearer ${session.accessToken}", actId) } } suspend fun create( visitId: String, visitCode: String, - ): MobileActDetail = authorized { session -> - api.createAct( - "Bearer ${session.accessToken}", - visitId, - CreateMobileActRequest( - occurredAt = Instant.now().toString(), - title = "Acta de inspección $visitCode", - summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", - assetIds = emptyList(), - ), - ) + clientGeneratedId: String? = null, + occurredAt: String = Instant.now().toString(), + ): MobileActDetail { + val created = authorized { session -> + api.createAct( + "Bearer ${session.accessToken}", + visitId, + CreateMobileActRequest( + clientGeneratedId = clientGeneratedId, + occurredAt = occurredAt, + title = "Acta de inspección $visitCode", + summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.", + assetIds = emptyList(), + ), + ) + } + cache.put(userCacheKey("act:${created.id}"), actAdapter.toJson(created)) + return created } suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail { @@ -354,16 +385,25 @@ class MobileActsRepository(context: Context) { } } - suspend fun closure(actId: String): MobileActClosure = authorized { session -> - api.closure("Bearer ${session.accessToken}", actId) + suspend fun closure(actId: String): MobileActClosure = cached("act-closure:$actId", closureAdapter) { + authorized { session -> api.closure("Bearer ${session.accessToken}", actId) } } - suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session -> - api.responsible("Bearer ${session.accessToken}", actId, request) + suspend fun selfSignatureStatus(): MobileProfileSignatureStatus = + cached("self-signature-status", signatureStatusAdapter) { + authorized { session -> api.selfSignature("Bearer ${session.accessToken}") } + } + + suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure { + val value = authorized { session -> api.responsible("Bearer ${session.accessToken}", actId, request) } + cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value)) + return value } - suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session -> - api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) + suspend fun lock(actId: String, urgency: String): MobileActClosure { + val value = authorized { session -> api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) } + cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value)) + return value } suspend fun signInspector( @@ -382,6 +422,7 @@ class MobileActsRepository(context: Context) { accuracyM: Double?, manifestation: String = "CONFORMITY", statement: String? = null, + clientSignedAt: String = Instant.now().toString(), ): MobileActClosure = signature( actId = actId, png = png, @@ -391,22 +432,38 @@ class MobileActsRepository(context: Context) { company = true, manifestation = manifestation, statement = statement, + clientSignedAt = clientSignedAt, ) - suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session -> - api.companyOutcome( - "Bearer ${session.accessToken}", - actId, - MobileCompanyOutcomeRequest(status, reason.trim()), - ) + suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure { + val value = authorized { session -> + api.companyOutcome( + "Bearer ${session.accessToken}", + actId, + MobileCompanyOutcomeRequest(status, reason.trim()), + ) + } + cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value)) + return value } - suspend fun seal(actId: String): MobileActClosure = authorized { session -> - api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest()) + suspend fun seal( + actId: String, + uploadMode: String = "IMMEDIATE", + clientClosedAt: String = Instant.now().toString(), + ): MobileActClosure { + val value = authorized { session -> + api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest(clientClosedAt, uploadMode)) + } + cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value)) + return value } - suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> - api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest()) + suspend fun closeVisit( + visitId: String, + clientClosedAt: String = Instant.now().toString(), + ): VisitDetail = authorized { session -> + api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest(clientClosedAt)) } private suspend fun signature( @@ -418,6 +475,7 @@ class MobileActsRepository(context: Context) { company: Boolean, manifestation: String? = null, statement: String? = null, + clientSignedAt: String = Instant.now().toString(), ): MobileActClosure = authorized { session -> val text = "text/plain".toMediaType() val file = MultipartBody.Part.createFormData( @@ -426,7 +484,7 @@ class MobileActsRepository(context: Context) { png.asRequestBody("image/png".toMediaType()), ) val consent = "true".toRequestBody(text) - val signedAt = Instant.now().toString().toRequestBody(text) + val signedAt = clientSignedAt.toRequestBody(text) val device = "DH Android".toRequestBody(text) val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text) val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text) @@ -446,6 +504,20 @@ class MobileActsRepository(context: Context) { } } + private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key" + + private suspend fun cached( + key: String, + adapter: JsonAdapter, + online: suspend () -> T, + ): T { + return try { + online().also { cache.put(userCacheKey(key), adapter.toJson(it)) } + } catch (error: IOException) { + cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error + } + } + private suspend fun authorized(block: suspend (StoredSession) -> T): T { var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") try { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt new file mode 100644 index 0000000..37dfbf4 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt @@ -0,0 +1,622 @@ +package com.korexlabs.dhinspeccion.data.offline + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest +import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest +import com.korexlabs.dhinspeccion.data.DhRepository +import com.korexlabs.dhinspeccion.data.FieldFindingsRepository +import com.korexlabs.dhinspeccion.data.MobileActsRepository +import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest +import com.korexlabs.dhinspeccion.data.SecureSessionStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONObject +import retrofit2.HttpException +import java.io.File +import java.io.IOException +import java.time.Instant +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong + +@Entity(tableName = "pending_mobile_operations") +data class PendingMobileOperation( + @PrimaryKey val id: String, + val ownerUserId: String, + val type: String, + val resourceId: String, + val payloadJson: String, + val filePath: String? = null, + val state: String = "PENDING", + val attempts: Int = 0, + val lastError: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), +) + +@Dao +interface PendingMobileOperationDao { + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(value: PendingMobileOperation) + + @Query("SELECT * FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR') ORDER BY createdAt ASC") + suspend fun pending(ownerUserId: String): List + + @Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR','SYNCING')") + suspend fun pendingCount(ownerUserId: String): Int + + @Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND resourceId=:resourceId AND type=:type AND state IN ('PENDING','ERROR','SYNCING')") + suspend fun hasPending(ownerUserId: String, type: String, resourceId: String): Int + + @Query("UPDATE pending_mobile_operations SET state=:state, attempts=:attempts, lastError=:lastError, updatedAt=:updatedAt WHERE id=:id") + suspend fun mark(id: String, state: String, attempts: Int, lastError: String?, updatedAt: Long = System.currentTimeMillis()) + + @Query("DELETE FROM pending_mobile_operations WHERE id=:id") + suspend fun delete(id: String) +} + + +@Entity(tableName = "mobile_offline_cache") +data class OfflineCacheEntry( + @PrimaryKey val cacheKey: String, + val json: String, + val updatedAt: Long = System.currentTimeMillis(), +) + +@Dao +interface OfflineCacheDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun put(value: OfflineCacheEntry) + + @Query("SELECT * FROM mobile_offline_cache WHERE cacheKey=:key LIMIT 1") + suspend fun get(key: String): OfflineCacheEntry? + + @Query("DELETE FROM mobile_offline_cache WHERE cacheKey=:key") + suspend fun delete(key: String) +} + +@Database(entities = [PendingMobileOperation::class, OfflineCacheEntry::class], version = 1, exportSchema = false) +abstract class OfflineQueueDatabase : RoomDatabase() { + abstract fun operations(): PendingMobileOperationDao + abstract fun cache(): OfflineCacheDao + + companion object { + @Volatile private var instance: OfflineQueueDatabase? = null + + fun get(context: Context): OfflineQueueDatabase = instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + OfflineQueueDatabase::class.java, + "dh_mobile_offline_queue.db", + ).build().also { instance = it } + } + } +} + + +class OfflineJsonCache(context: Context) { + private val dao = OfflineQueueDatabase.get(context).cache() + + suspend fun put(key: String, json: String) { + dao.put(OfflineCacheEntry(key, json)) + } + + suspend fun get(key: String): String? = dao.get(key)?.json + + suspend fun delete(key: String) = dao.delete(key) +} + +class OfflineMutationQueue(private val context: Context) { + private val dao = OfflineQueueDatabase.get(context).operations() + private val sessions = SecureSessionStore(context.applicationContext) + private val mediaRoot = File(context.filesDir, "offline-media").apply { mkdirs() } + + suspend fun pendingCount(): Int = currentOwnerOrNull()?.let { dao.pendingCount(it) } ?: 0 + + suspend fun hasPending(type: String, resourceId: String): Boolean = + currentOwnerOrNull()?.let { dao.hasPending(it, type, resourceId) > 0 } ?: false + + suspend fun enqueueFindingCreate( + findingId: String, + visitId: String, + assetId: String, + request: CreateFieldFindingRequest, + ): String = enqueueJson( + type = "FINDING_CREATE", + resourceId = findingId, + payload = JSONObject() + .put("visitId", visitId) + .put("assetId", assetId) + .put("actId", request.actId) + .put("clientGeneratedId", request.clientGeneratedId ?: findingId) + .put("catalogItemId", request.catalogItemId) + .put("customTitle", request.customTitle) + .put("customLegalBasis", request.customLegalBasis) + .put("description", request.description) + .put("severity", request.severity) + .toString(), + ) + + suspend fun enqueueFindingPhoto( + findingId: String, + source: File, + latitude: Double, + longitude: Double, + accuracyM: Double?, + title: String?, + description: String?, + capturedAt: String = Instant.now().toString(), + ): String = persistWithFile("FINDING_PHOTO", findingId, source, "jpg") { id -> + JSONObject() + .put("operationId", id) + .put("latitude", latitude) + .put("longitude", longitude) + .put("accuracyM", accuracyM) + .put("title", title) + .put("description", description) + .put("capturedAt", capturedAt) + .toString() + } + + suspend fun enqueueVisitStart(visitId: String): String = enqueueJson( + type = "VISIT_START", + resourceId = visitId, + payload = JSONObject().toString(), + ) + + suspend fun enqueueVisitClose( + visitId: String, + clientClosedAt: String = Instant.now().toString(), + ): String = enqueueJson( + type = "VISIT_CLOSE", + resourceId = visitId, + payload = JSONObject().put("clientClosedAt", clientClosedAt).toString(), + ) + + suspend fun enqueueFieldAssetCreate( + assetId: String, + visitId: String, + request: CreateFieldInventoryRequest, + ): String = enqueueJson( + type = "FIELD_ASSET_CREATE", + resourceId = assetId, + payload = JSONObject() + .put("visitId", visitId) + .put("typeId", request.typeId) + .put("parentId", request.parentId) + .put("familyId", request.familyId) + .put("code", request.code) + .put("name", request.name) + .put("commonName", request.commonName) + .put("description", request.description) + .put("discoveryNotes", request.discoveryNotes) + .put("attributes", JSONObject(request.attributes)) + .put("deviceLatitude", request.deviceLatitude) + .put("deviceLongitude", request.deviceLongitude) + .put("deviceAccuracyM", request.deviceAccuracyM) + .put("deviceCapturedAt", request.deviceCapturedAt) + .put("deviceLabel", request.deviceLabel) + .toString(), + ) + + suspend fun enqueueFieldAssetPhoto( + assetId: String, + visitId: String, + source: File, + latitude: Double, + longitude: Double, + accuracyM: Double?, + capturedAt: String = Instant.now().toString(), + ): String = persistWithFile("FIELD_ASSET_PHOTO", assetId, source, "jpg") { id -> + JSONObject() + .put("visitId", visitId) + .put("operationId", id) + .put("latitude", latitude) + .put("longitude", longitude) + .put("accuracyM", accuracyM) + .put("capturedAt", capturedAt) + .toString() + } + + suspend fun enqueueFieldAssetSelect(visitId: String, assetId: String): String = enqueueJson( + type = "FIELD_ASSET_SELECT", + resourceId = assetId, + payload = JSONObject().put("visitId", visitId).toString(), + ) + + suspend fun enqueueActCreate( + actId: String, + visitId: String, + visitCode: String, + occurredAt: String, + ): String = enqueueJson( + type = "ACT_CREATE", + resourceId = actId, + payload = JSONObject() + .put("visitId", visitId) + .put("visitCode", visitCode) + .put("occurredAt", occurredAt) + .toString(), + ) + + suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson( + type = "ACT_ASSET_ENSURE", + resourceId = actId, + payload = JSONObject().put("assetId", assetId).toString(), + ) + + suspend fun enqueueResponsible(actId: String, request: MobileResponsibleRequest): String = enqueueJson( + type = "ACT_RESPONSIBLE", + resourceId = actId, + payload = JSONObject() + .put("attendanceStatus", request.attendanceStatus) + .put("fullName", request.fullName) + .put("documentType", request.documentType) + .put("documentNumber", request.documentNumber) + .put("position", request.position) + .put("email", request.email) + .put("phone", request.phone) + .put("absenceReason", request.absenceReason) + .toString(), + ) + + suspend fun enqueueActLock(actId: String, urgency: String): String = enqueueJson( + type = "ACT_LOCK", + resourceId = actId, + payload = JSONObject().put("urgency", urgency).toString(), + ) + + suspend fun enqueueCompanyOutcome(actId: String, status: String, reason: String): String = enqueueJson( + type = "COMPANY_OUTCOME", + resourceId = actId, + payload = JSONObject().put("status", status).put("reason", reason).toString(), + ) + + suspend fun enqueueCompanySignature( + actId: String, + source: File, + latitude: Double?, + longitude: Double?, + accuracyM: Double?, + manifestation: String, + statement: String?, + clientSignedAt: String = Instant.now().toString(), + ): String = persistWithFile("COMPANY_SIGNATURE", actId, source, "png") { _ -> + JSONObject() + .put("latitude", latitude) + .put("longitude", longitude) + .put("accuracyM", accuracyM) + .put("manifestation", manifestation) + .put("statement", statement) + .put("clientSignedAt", clientSignedAt) + .toString() + } + + suspend fun enqueueSeal( + actId: String, + clientClosedAt: String = Instant.now().toString(), + ): String { + val id = UUID.randomUUID().toString() + dao.insert( + PendingMobileOperation( + id = id, + ownerUserId = currentOwner(), + type = "ACT_SEAL", + resourceId = actId, + payloadJson = JSONObject().put("clientClosedAt", clientClosedAt).toString(), + createdAt = nextCreatedAt(), + ), + ) + schedule(context) + return id + } + + private suspend fun enqueueJson(type: String, resourceId: String, payload: String): String { + val id = UUID.randomUUID().toString() + dao.insert(PendingMobileOperation( + id = id, + ownerUserId = currentOwner(), + type = type, + resourceId = resourceId, + payloadJson = payload, + createdAt = nextCreatedAt(), + )) + schedule(context) + return id + } + + private suspend fun persistWithFile( + type: String, + resourceId: String, + source: File, + extension: String, + payload: (String) -> String, + ): String = withContext(Dispatchers.IO) { + val id = UUID.randomUUID().toString() + val target = File(mediaRoot, "$id.$extension") + source.copyTo(target, overwrite = false) + dao.insert( + PendingMobileOperation( + id = id, + ownerUserId = currentOwner(), + type = type, + resourceId = resourceId, + payloadJson = payload(id), + filePath = target.absolutePath, + createdAt = nextCreatedAt(), + ), + ) + source.delete() + schedule(context) + id + } + + suspend fun syncAll(): SyncResult = syncMutex.withLock { + val ownerUserId = currentOwnerOrNull() ?: return@withLock SyncResult(0, 0, retry = false) + var synced = 0 + var lastError: String? = null + for (operation in dao.pending(ownerUserId)) { + val attempt = operation.attempts + 1 + dao.mark(operation.id, "SYNCING", attempt, null) + try { + syncOne(operation) + operation.filePath?.let { File(it).delete() } + dao.delete(operation.id) + synced += 1 + } catch (error: IOException) { + dao.mark(operation.id, "PENDING", attempt, error.message) + return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = true, lastError = error.message) + } catch (error: HttpException) { + if (isAlreadyApplied(operation, error)) { + operation.filePath?.let { File(it).delete() } + dao.delete(operation.id) + synced += 1 + } else { + lastError = httpError(error) + dao.mark(operation.id, "ERROR", attempt, lastError) + return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError) + } + } catch (error: Throwable) { + lastError = error.message ?: error::class.java.simpleName + dao.mark(operation.id, "ERROR", attempt, lastError) + return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError) + } + } + SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError) + } + + private suspend fun syncOne(operation: PendingMobileOperation) { + val payload = JSONObject(operation.payloadJson) + when (operation.type) { + "VISIT_START" -> DhRepository(context).startVisit(operation.resourceId) + "VISIT_CLOSE" -> MobileActsRepository(context).closeVisit( + operation.resourceId, + payload.getString("clientClosedAt"), + ) + "FIELD_ASSET_CREATE" -> DhRepository(context).createFieldAsset( + payload.getString("visitId"), + CreateFieldInventoryRequest( + clientGeneratedId = operation.resourceId, + typeId = payload.getString("typeId"), + parentId = payload.optNullableString("parentId"), + familyId = payload.optNullableString("familyId"), + code = payload.optNullableString("code"), + name = payload.getString("name"), + commonName = payload.optNullableString("commonName"), + description = payload.optNullableString("description"), + discoveryNotes = payload.optNullableString("discoveryNotes"), + attributes = jsonObjectToMap(payload.getJSONObject("attributes")), + deviceLatitude = payload.getDouble("deviceLatitude"), + deviceLongitude = payload.getDouble("deviceLongitude"), + deviceAccuracyM = payload.optDoubleOrNull("deviceAccuracyM"), + deviceCapturedAt = payload.getString("deviceCapturedAt"), + deviceLabel = payload.optString("deviceLabel", "DH Android"), + ), + ) + "FIELD_ASSET_PHOTO" -> DhRepository(context).uploadFieldPhoto( + visitId = payload.getString("visitId"), + assetId = operation.resourceId, + file = requiredFile(operation), + latitude = payload.getDouble("latitude"), + longitude = payload.getDouble("longitude"), + accuracyM = payload.optDoubleOrNull("accuracyM"), + capturedAt = payload.getString("capturedAt"), + operationId = payload.getString("operationId"), + ) + "FIELD_ASSET_SELECT" -> DhRepository(context).selectFieldAsset( + payload.getString("visitId"), + operation.resourceId, + ) + "ACT_CREATE" -> MobileActsRepository(context).create( + visitId = payload.getString("visitId"), + visitCode = payload.getString("visitCode"), + clientGeneratedId = operation.resourceId, + occurredAt = payload.getString("occurredAt"), + ) + "ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset( + operation.resourceId, + payload.getString("assetId"), + ) + "FINDING_CREATE" -> FieldFindingsRepository(context).create( + payload.getString("visitId"), + payload.getString("assetId"), + CreateFieldFindingRequest( + actId = payload.getString("actId"), + clientGeneratedId = payload.getString("clientGeneratedId"), + catalogItemId = payload.optNullableString("catalogItemId"), + customTitle = payload.optNullableString("customTitle"), + customLegalBasis = payload.optNullableString("customLegalBasis"), + description = payload.getString("description"), + severity = if (!payload.has("severity") || payload.isNull("severity")) null else payload.getInt("severity"), + ), + ) + "ACT_RESPONSIBLE" -> MobileActsRepository(context).setResponsible( + operation.resourceId, + MobileResponsibleRequest( + attendanceStatus = payload.getString("attendanceStatus"), + fullName = payload.optNullableString("fullName"), + documentType = payload.optNullableString("documentType"), + documentNumber = payload.optNullableString("documentNumber"), + position = payload.optNullableString("position"), + email = payload.optNullableString("email"), + phone = payload.optNullableString("phone"), + absenceReason = payload.optNullableString("absenceReason"), + ), + ) + "ACT_LOCK" -> MobileActsRepository(context).lock( + operation.resourceId, + payload.getString("urgency"), + ) + "COMPANY_OUTCOME" -> MobileActsRepository(context).companyOutcome( + operation.resourceId, + payload.getString("status"), + payload.getString("reason"), + ) + "FINDING_PHOTO" -> { + val file = requiredFile(operation) + FieldFindingsRepository(context).uploadObservationPhoto( + findingId = operation.resourceId, + file = file, + latitude = payload.getDouble("latitude"), + longitude = payload.getDouble("longitude"), + accuracyM = payload.optDoubleOrNull("accuracyM"), + title = payload.optNullableString("title"), + description = payload.optNullableString("description"), + capturedAt = payload.getString("capturedAt"), + operationId = payload.getString("operationId"), + ) + } + "COMPANY_SIGNATURE" -> { + val file = requiredFile(operation) + MobileActsRepository(context).signCompany( + actId = operation.resourceId, + png = file, + latitude = payload.optDoubleOrNull("latitude"), + longitude = payload.optDoubleOrNull("longitude"), + accuracyM = payload.optDoubleOrNull("accuracyM"), + manifestation = payload.optString("manifestation", "CONFORMITY"), + statement = payload.optNullableString("statement"), + clientSignedAt = payload.getString("clientSignedAt"), + ) + } + "ACT_SEAL" -> MobileActsRepository(context).seal( + actId = operation.resourceId, + uploadMode = "DEFERRED", + clientClosedAt = payload.getString("clientClosedAt"), + ) + else -> error("Operación offline desconocida: ${operation.type}") + } + } + + private suspend fun isAlreadyApplied(operation: PendingMobileOperation, error: HttpException): Boolean { + if (error.code() != 409) return false + return when (operation.type) { + "VISIT_START" -> runCatching { + DhRepository(context).visit(operation.resourceId).status in setOf("IN_PROGRESS", "CLOSED") + }.getOrDefault(false) + "VISIT_CLOSE" -> runCatching { + DhRepository(context).visit(operation.resourceId).status == "CLOSED" + }.getOrDefault(false) + "ACT_LOCK" -> runCatching { + MobileActsRepository(context).get(operation.resourceId).status != "DRAFT" + }.getOrDefault(false) + "COMPANY_OUTCOME", "COMPANY_SIGNATURE" -> runCatching { + MobileActsRepository(context).closure(operation.resourceId).signatures.any { + it.signerType == "COMPANY_RESPONSIBLE" && (it.status == "SIGNED" || it.status == "REFUSED") + } + }.getOrDefault(false) + "ACT_SEAL" -> runCatching { + MobileActsRepository(context).get(operation.resourceId).status == "SEALED" + }.getOrDefault(false) + else -> false + } + } + + private fun requiredFile(operation: PendingMobileOperation): File { + val path = operation.filePath ?: error("La operación ${operation.id} no tiene archivo local") + return File(path).also { require(it.isFile) { "Archivo local pendiente inexistente" } } + } + + private fun currentOwnerOrNull(): String? = sessions.load()?.userId + + private fun currentOwner(): String = currentOwnerOrNull() + ?: throw IllegalStateException("Sesión no iniciada") + + data class SyncResult(val synced: Int, val pending: Int, val retry: Boolean, val lastError: String? = null) + + companion object { + private const val UNIQUE_WORK = "dh-mobile-offline-sync" + private val syncMutex = Mutex() + private val queueClock = AtomicLong(System.currentTimeMillis()) + + private fun nextCreatedAt(): Long = queueClock.updateAndGet { previous -> + maxOf(System.currentTimeMillis(), previous + 1) + } + + fun schedule(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + WorkManager.getInstance(context.applicationContext) + .enqueueUniqueWork(UNIQUE_WORK, ExistingWorkPolicy.KEEP, request) + } + } +} + +class OfflineSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val result = OfflineMutationQueue(applicationContext).syncAll() + return if (result.retry) Result.retry() else Result.success() + } +} + +private fun httpError(error: HttpException): String { + val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull().orEmpty() + return runCatching { JSONObject(body).optString("message") }.getOrNull() + ?.takeIf { it.isNotBlank() } + ?: "No se pudo sincronizar (HTTP ${error.code()})" +} + +private fun jsonObjectToMap(value: JSONObject): Map = buildMap { + val keys = value.keys() + while (keys.hasNext()) { + val key = keys.next() + put(key, jsonValue(value.opt(key))) + } +} + +private fun jsonValue(value: Any?): Any? = when (value) { + null, JSONObject.NULL -> null + is JSONObject -> jsonObjectToMap(value) + is org.json.JSONArray -> List(value.length()) { index -> jsonValue(value.opt(index)) } + else -> value +} + +private fun JSONObject.optDoubleOrNull(name: String): Double? = + if (!has(name) || isNull(name)) null else getDouble(name) + +private fun JSONObject.optNullableString(name: String): String? = + if (!has(name) || isNull(name)) null else optString(name).takeIf { it.isNotBlank() } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt index a77fedf..4b95daa 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt @@ -1,12 +1,14 @@ package com.korexlabs.dhinspeccion.ui import android.Manifest +import android.graphics.BitmapFactory 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.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -40,6 +42,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -48,6 +52,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.exifinterface.media.ExifInterface @@ -93,6 +98,9 @@ fun FieldFindingScreen(model: MainViewModel) { var pendingPhotoFile by remember { mutableStateOf(null) } var pendingPhotoGeo by remember { mutableStateOf(null) } var pendingPhotoFindingId by remember { mutableStateOf(null) } + var reviewPhotoFile by remember { mutableStateOf(null) } + var reviewPhotoGeo by remember { mutableStateOf(null) } + var reviewPhotoFindingId by remember { mutableStateOf(null) } val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success -> val file = pendingPhotoFile @@ -100,14 +108,11 @@ fun FieldFindingScreen(model: MainViewModel) { val findingId = pendingPhotoFindingId if (success && file != null && geo != null && findingId != null) { runCatching { writeFindingExif(file, geo) } - model.uploadFindingPhoto( - findingId = findingId, - file = file, - latitude = geo.latitude, - longitude = geo.longitude, - accuracyM = geo.accuracyM, - title = "Evidencia fotográfica de campo", - ) + reviewPhotoFile = file + reviewPhotoGeo = geo + reviewPhotoFindingId = findingId + } else { + file?.delete() } pendingPhotoFile = null pendingPhotoGeo = null @@ -182,6 +187,79 @@ fun FieldFindingScreen(model: MainViewModel) { } } + val reviewFile = reviewPhotoFile + val reviewGeo = reviewPhotoGeo + val reviewFindingId = reviewPhotoFindingId + if (reviewFile != null && reviewGeo != null && reviewFindingId != null) { + val bitmap = remember(reviewFile.absolutePath, reviewFile.lastModified()) { + runCatching { BitmapFactory.decodeFile(reviewFile.absolutePath)?.asImageBitmap() }.getOrNull() + } + Dialog(onDismissRequest = {}) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Revisar fotografía", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text( + "Todavía no se subió. Verificá la imagen antes de incorporarla al Hallazgo.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = "Vista previa de la fotografía del Hallazgo", + modifier = Modifier.fillMaxWidth().heightIn(min = 220.dp, max = 460.dp), + contentScale = ContentScale.Fit, + ) + } else { + Text("No se pudo generar la vista previa.", color = MaterialTheme.colorScheme.error) + } + Text( + "GPS ${"%.6f".format(reviewGeo.latitude)}, ${"%.6f".format(reviewGeo.longitude)}${reviewGeo.accuracyM?.let { " · ±${"%.1f".format(it)} m" }.orEmpty()}", + style = MaterialTheme.typography.bodySmall, + ) + Button( + onClick = { + model.uploadFindingPhoto( + findingId = reviewFindingId, + file = reviewFile, + latitude = reviewGeo.latitude, + longitude = reviewGeo.longitude, + accuracyM = reviewGeo.accuracyM, + title = "Evidencia fotográfica de campo", + ) + reviewPhotoFile = null + reviewPhotoGeo = null + reviewPhotoFindingId = null + }, + enabled = !model.busy && bitmap != null, + modifier = Modifier.fillMaxWidth(), + ) { Text("Usar esta foto") } + OutlinedButton( + onClick = { + reviewFile.delete() + reviewPhotoFile = null + reviewPhotoGeo = null + reviewPhotoFindingId = null + beginPhoto(reviewFindingId) + }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Volver a tomar") } + OutlinedButton( + onClick = { + reviewFile.delete() + reviewPhotoFile = null + reviewPhotoGeo = null + reviewPhotoFindingId = null + }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Eliminar foto") } + } + } + } + } + Column( modifier = Modifier .fillMaxSize() diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt index 00c4316..68d3a81 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt @@ -76,14 +76,12 @@ fun ModernMobileActsScreen( val scope = rememberCoroutineScope() var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") } - var attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") } var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) } var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") } var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) } var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) } var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) } var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) } - var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) } var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") } var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") } var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") } @@ -138,6 +136,29 @@ fun ModernMobileActsScreen( ModernMessage(model) + if (model.pendingSyncCount > 0) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "${model.pendingSyncCount} cambio${if (model.pendingSyncCount == 1) "" else "s"} guardado${if (model.pendingSyncCount == 1) "" else "s"} en este dispositivo", + fontWeight = FontWeight.Bold, + ) + Text( + "Podés seguir trabajando sin internet. Los cambios se sincronizan automáticamente cuando vuelve la conexión.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + OutlinedButton(onClick = { model.syncPendingNow() }, enabled = !model.busy) { + Text("Sincronizar ahora") + } + } + } + } + if (model.acts.isEmpty()) { ElevatedCard(Modifier.fillMaxWidth()) { Column( @@ -255,37 +276,23 @@ fun ModernMobileActsScreen( Spacer(Modifier.width(8.dp)) Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") }) - FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") }) - } - if (attendance == "PRESENT") { - OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) - if (closure?.responsible == null && closure?.representativeSuggestion != null) { - Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Button( - onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, - enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."), - modifier = Modifier.fillMaxWidth(), - ) { Text("Guardar representante") } - } else { - OutlinedTextField( - absenceReason, - { absenceReason = it }, - label = { Text("Motivo de ausencia *") }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = { model.setCompanyResponsibleAbsent(absenceReason) }, - enabled = !model.busy && absenceReason.trim().length >= 10, - modifier = Modifier.fillMaxWidth(), - ) { Text("Guardar ausencia") } + Text( + "Identificá a la persona que acompaña el recorrido. Al finalizar esta Acta podrá firmar conforme, firmar en disconformidad o negarse a firmar.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + if (closure?.responsible == null && closure?.representativeSuggestion != null) { + Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + Button( + onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) }, + enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."), + modifier = Modifier.fillMaxWidth(), + ) { Text("Guardar representante") } } } @@ -334,7 +341,8 @@ fun ModernMobileActsScreen( "LOCKED" -> { val signatures = closure?.signatures.orEmpty() val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } - val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT" + val companyResolvedOnServer = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT" + val companyResolved = companyResolvedOnServer || model.selectedActCompanySignaturePending ElevatedCard(Modifier.fillMaxWidth()) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -347,24 +355,50 @@ fun ModernMobileActsScreen( "El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) - Text( - "Tu firma de inspector está guardada en el Dashboard y se incorporará automáticamente cuando cierres esta Acta.", - Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - ) + when (model.inspectorSignatureConfigured) { + true -> Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary) + Text( + "Firma del inspector configurada en Mi perfil. Se aplicará automáticamente al cierre definitivo de esta Acta.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + } } + false -> Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error) + Text( + "Falta configurar la firma del inspector en Mi perfil del Dashboard. La firma de la empresa puede guardarse igual; el cierre quedará pendiente hasta completar este requisito.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedButton(onClick = { model.refreshInspectorSignatureStatus() }, enabled = !model.busy) { + Text("Volver a verificar") + } + } + } + null -> Text( + "La firma del inspector se validará con el Dashboard al sincronizar.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } HorizontalDivider() Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold) - if (companyOutcome != null && companyResolved) { + if (companyOutcome != null && companyResolvedOnServer) { val detail = when (companyOutcome.status) { "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad" "REFUSED" -> "Se negó a firmar" @@ -374,26 +408,24 @@ fun ModernMobileActsScreen( SuccessLine(detail) companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } - } else if (closure?.responsible?.attendanceStatus == "ABSENT") { - val recordedAbsence = closure.responsible.absenceReason.orEmpty() + } else if (model.selectedActCompanySignaturePending) { Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary) - Text( - "El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.", - Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - ) - } - Text(recordedAbsence, style = MaterialTheme.typography.bodySmall) - OutlinedButton( - onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) }, - enabled = !model.busy && recordedAbsence.trim().length >= 10, - modifier = Modifier.fillMaxWidth(), - ) { Text("Confirmar ausencia documentada") } + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + SuccessLine("Firma guardada en el dispositivo") + Text( + "Todavía no llegó al servidor. Podés continuar; se enviará automáticamente cuando haya conexión.", + style = MaterialTheme.typography.bodySmall, + ) } } + } else if (closure?.responsible?.attendanceStatus == "ABSENT") { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) { + Text( + "Esta Acta conserva un registro histórico de ausencia. El flujo actual requiere identificar al acompañante antes de pasar el contenido a firma.", + Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + ) + } } else { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") }) @@ -429,14 +461,24 @@ fun ModernMobileActsScreen( } if (companyResolved) { - Button( - onClick = { model.closeSelectedAct() }, - enabled = !model.busy, - modifier = Modifier.fillMaxWidth(), - ) { - Icon(Icons.Filled.CheckCircle, null) - Spacer(Modifier.width(6.dp)) - Text("Aplicar mi firma y cerrar Acta") + if (model.selectedActSealPending) { + Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) { + Text( + "Cierre guardado en este dispositivo · pendiente de sincronización", + Modifier.padding(12.dp), + fontWeight = FontWeight.SemiBold, + ) + } + } else { + Button( + onClick = { model.closeSelectedAct() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.CheckCircle, null) + Spacer(Modifier.width(6.dp)) + Text(if (model.inspectorSignatureConfigured == false) "Guardar cierre pendiente" else "Firmar y cerrar Acta") + } } } else { Text( diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index dc37aab..50158d3 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(37, BuildConfig.VERSION_CODE) - assertEquals("0.19.9-debug", BuildConfig.VERSION_NAME) + assertEquals(38, BuildConfig.VERSION_CODE) + assertEquals("0.19.10-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts index 553a5c0..8cff8ba 100644 --- a/android-app/build.gradle.kts +++ b/android-app/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "8.13.2" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false + id("org.jetbrains.kotlin.kapt") version "2.2.20" apply false } diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index a29708d..e1c2159 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-7", + "version": "0.29.0-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-7", + "version": "0.29.0-8", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index a174943..8b9e3bf 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-7", + "version": "0.29.0-8", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/asset-master/asset-master-bootstrap.ts b/api-v3/src/asset-master/asset-master-bootstrap.ts index 8487a7a..2141ff5 100644 --- a/api-v3/src/asset-master/asset-master-bootstrap.ts +++ b/api-v3/src/asset-master/asset-master-bootstrap.ts @@ -260,7 +260,7 @@ export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [ { code: 'equipo', name: 'Equipo', - description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.', + description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.', canBeRoot: false, operationalRole: AssetTypeOperationalRole.GENERIC, allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'], diff --git a/api-v3/src/asset-master/assets.service.ts b/api-v3/src/asset-master/assets.service.ts index 35ffb14..a2ebb75 100644 --- a/api-v3/src/asset-master/assets.service.ts +++ b/api-v3/src/asset-master/assets.service.ts @@ -756,6 +756,7 @@ export class AssetsService { const definitions = await this.loadDefinitions(manager, type.id); const values = validateAssetAttributeValues(definitions, dto.attributes); const asset = manager.getRepository(Asset).create({ + id: dto.clientGeneratedId, assetTypeId: type.id, parentId: dto.parentId, operationalAreaId: dto.operationalAreaId, diff --git a/api-v3/src/asset-master/dto/create-field-discovery.dto.ts b/api-v3/src/asset-master/dto/create-field-discovery.dto.ts index d063afa..1d771c5 100644 --- a/api-v3/src/asset-master/dto/create-field-discovery.dto.ts +++ b/api-v3/src/asset-master/dto/create-field-discovery.dto.ts @@ -2,6 +2,10 @@ import { Transform } from 'class-transformer'; import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator'; export class CreateFieldDiscoveryDto { + @IsOptional() + @IsUUID('4') + clientGeneratedId?: string; + @IsUUID('4') visitId!: string; diff --git a/api-v3/src/asset-master/inventory-family-catalog.service.ts b/api-v3/src/asset-master/inventory-family-catalog.service.ts index 7d48fe9..8dd19f7 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.service.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.service.ts @@ -98,7 +98,7 @@ export class InventoryFamilyCatalogService { ) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true) RETURNING id `,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>; - if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario'); + if (!inserted) throw new Error('No se pudo crear el tipo de Inventario'); await this.replaceParents(manager, inserted.id, parentIds); const created = await this.family(inserted.id,false,manager); await this.audit.record({ @@ -186,7 +186,7 @@ export class InventoryFamilyCatalogService { } catch (error) { if (this.isUniqueViolation(error)) throw new ConflictException({ code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS', - message:'Ya existe un campo técnico con ese código en la clasificación', + message:'Ya existe un campo técnico con ese código en el tipo', }); throw error; } @@ -328,7 +328,7 @@ export class InventoryFamilyCatalogService { ${lockClause} `),[familyId])) as FamilyRow[]; if (!rows[0]) throw new NotFoundException({ - code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe', + code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo de Inventario no existe', }); return rows[0]; } @@ -343,7 +343,7 @@ export class InventoryFamilyCatalogService { if (level==='INSTALLATION') { if (ids.length) throw new BadRequestException({ code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', - message:'Una clasificación de Instalación no lleva compatibilidades padre', + message:'Un tipo de Instalación no lleva compatibilidades padre', }); return []; } @@ -352,7 +352,7 @@ export class InventoryFamilyCatalogService { message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación', }); if (ownId && ids.includes(ownId)) throw new BadRequestException({ - code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma', + code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo', }); const [count]=(await manager.query(` SELECT COUNT(*)::integer AS total FROM inventory_families @@ -434,7 +434,7 @@ export class InventoryFamilyCatalogService { if (!unique.has(identity)) unique.set(identity,clean); } if (unique.size>100) throw new ConflictException({ - code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información', + code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'El tipo admite hasta 100 campos de información', }); return [...unique.values()]; } diff --git a/api-v3/src/asset-master/inventory-structure.service.ts b/api-v3/src/asset-master/inventory-structure.service.ts index 5d58a7f..44a0738 100644 --- a/api-v3/src/asset-master/inventory-structure.service.ts +++ b/api-v3/src/asset-master/inventory-structure.service.ts @@ -253,7 +253,7 @@ export class InventoryStructureService { dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario', dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, family - ? `Clasificación técnica: ${family.code} · ${family.name}` + ? `Tipo: ${family.code} · ${family.name}` : yacimientoContext ? `Tipo de concesión: ${yacimientoContext.concessionName}` : null, @@ -458,13 +458,13 @@ export class InventoryStructureService { if (!expectedLevel) { if (familyId) throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', - message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica', + message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación', }); return null; } if (!familyId) throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', - message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`, + message: `Elegí el tipo de la ${kind.toLowerCase()}`, }); const rows = (await manager.query(` SELECT family.id,family.code,family.name,family.level, @@ -479,15 +479,15 @@ export class InventoryStructureService { LIMIT 1 `, [familyId])) as FamilyRow[]; const family = rows[0]; - if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' }); + if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación no existe' }); if (family.level !== expectedLevel) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', - message: 'La clasificación técnica no corresponde al nivel seleccionado', + message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado', }); if (kind === 'SUBINSTALACION') { if (!parent?.inventoryFamilyId) throw new BadRequestException({ code:'INVENTORY_PARENT_FAMILY_REQUIRED', - message:'La Instalación padre debe tener una clasificación técnica válida', + message:'La Instalación padre debe tener un Tipo de instalación válido', }); const [compatible]=(await manager.query(` SELECT 1 AS ok FROM inventory_family_parent_rules @@ -496,7 +496,7 @@ export class InventoryStructureService { `,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>; if (!compatible) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', - message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada', + message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada', }); } return family; diff --git a/api-v3/src/asset-master/inventory-technical-values.service.ts b/api-v3/src/asset-master/inventory-technical-values.service.ts index d01f663..d174c12 100644 --- a/api-v3/src/asset-master/inventory-technical-values.service.ts +++ b/api-v3/src/asset-master/inventory-technical-values.service.ts @@ -29,7 +29,7 @@ export class InventoryTechnicalValuesService { for (const [definitionId,raw] of Object.entries(dto.values)) { const definition=definitionById.get(definitionId); if (!definition || !definition.isActive) throw new BadRequestException({ - code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual', + code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen al tipo actual', }); const value=this.normalize(definition,raw); if (value!==undefined) normalized[definitionId]=value; @@ -67,7 +67,7 @@ export class InventoryTechnicalValuesService { const asset=rows[0]; if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'}); if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({ - code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro', + code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene Tipo de instalación/subinstalación y no admite campos técnicos por rubro', }); const definitions=(await manager.query(` SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive", diff --git a/api-v3/src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts b/api-v3/src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts new file mode 100644 index 0000000..e063c19 --- /dev/null +++ b/api-v3/src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F68OfflineEvidenceIdempotency1790128200000 implements MigrationInterface { + name = 'F68OfflineEvidenceIdempotency1790128200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE inspection_finding_evidence + ADD COLUMN client_operation_id uuid + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_inspection_finding_evidence_client_operation + ON inspection_finding_evidence (client_operation_id) + WHERE client_operation_id IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE asset_field_capture_events + ADD COLUMN client_operation_id uuid + `); + await queryRunner.query(` + CREATE UNIQUE INDEX uq_asset_field_capture_events_client_operation + ON asset_field_capture_events (client_operation_id) + WHERE client_operation_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX uq_asset_field_capture_events_client_operation'); + await queryRunner.query('ALTER TABLE asset_field_capture_events DROP COLUMN client_operation_id'); + await queryRunner.query('DROP INDEX uq_inspection_finding_evidence_client_operation'); + await queryRunner.query('ALTER TABLE inspection_finding_evidence DROP COLUMN client_operation_id'); + } +} diff --git a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts index a8c38e6..4e5fafc 100644 --- a/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts +++ b/api-v3/src/inspection-acts/dto/create-inspection-act.dto.ts @@ -12,6 +12,10 @@ import { } from 'class-validator'; export class CreateInspectionActDto { + @IsOptional() + @IsUUID('4') + clientGeneratedId?: string; + @IsISO8601({ strict: true }) occurredAt!: string; diff --git a/api-v3/src/inspection-acts/inspection-acts.controller.ts b/api-v3/src/inspection-acts/inspection-acts.controller.ts index 07d83d8..8dd8551 100644 --- a/api-v3/src/inspection-acts/inspection-acts.controller.ts +++ b/api-v3/src/inspection-acts/inspection-acts.controller.ts @@ -53,6 +53,12 @@ export class InspectionActsController { return this.acts.listGlobal(query); } + @Get(':id/field-media') + @RequirePermissions('inspection_acts.read', 'assets.read_media') + fieldMedia(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.acts.listFieldMedia(id); + } + @Get(':id') @RequirePermissions('inspection_acts.read') get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { diff --git a/api-v3/src/inspection-acts/inspection-acts.service.ts b/api-v3/src/inspection-acts/inspection-acts.service.ts index ac5a29e..84dc13d 100644 --- a/api-v3/src/inspection-acts/inspection-acts.service.ts +++ b/api-v3/src/inspection-acts/inspection-acts.service.ts @@ -278,6 +278,48 @@ export class InspectionActsService { return this.dataSource.transaction((manager) => this.loadView(manager, id)); } + async listFieldMedia(id: string) { + const [act] = await this.dataSource.query( + 'SELECT id FROM inspection_acts WHERE id=$1::uuid', + [id], + ) as Array<{ id: string }>; + if (!act) throw actNotFound(); + const data = await this.dataSource.query(` + SELECT + media.id, + media.asset_id AS "assetId", + asset.code AS "assetCode", + asset.name AS "assetName", + media.kind, + media.original_name AS "originalName", + media.mime_type AS "mimeType", + media.size_bytes::double precision AS "sizeBytes", + media.sha256, + media.title, + media.description, + media.captured_at AS "capturedAt", + media.latitude::double precision AS latitude, + media.longitude::double precision AS longitude, + media.accuracy_m::double precision AS "accuracyM", + media.source, + capture.device_captured_at AS "fieldCapturedAt", + capture.created_at AS "createdAt" + FROM inspection_acts act + INNER JOIN inspection_act_assets link + ON link.act_id=act.id AND link.included=true + INNER JOIN asset_field_capture_events capture + ON capture.visit_id=act.visit_id + AND capture.asset_id=link.asset_id + AND capture.event_type='PHOTO' + INNER JOIN asset_media media + ON media.id=capture.media_id AND media.deleted_at IS NULL + INNER JOIN assets asset ON asset.id=media.asset_id + WHERE act.id=$1::uuid + ORDER BY capture.device_captured_at DESC,capture.created_at DESC,media.id + `, [id]); + return { data }; + } + async create( visitId: string, dto: CreateInspectionActDto, @@ -289,6 +331,22 @@ export class InspectionActsService { const visit = await this.lockVisit(manager, visitId); this.assertVisitOpen(visit); await this.assertActorAssigned(manager, visitId, principal); + if (dto.clientGeneratedId) { + const [existing] = await manager.query(` + SELECT id, visit_id AS "visitId" + FROM inspection_acts + WHERE id=$1 + `, [dto.clientGeneratedId]) as Array<{ id: string; visitId: string }>; + if (existing) { + if (existing.visitId !== visitId) { + throw new ConflictException({ + code: 'INSPECTION_ACT_CLIENT_ID_REUSED', + message: 'El identificador offline del Acta ya fue utilizado en otra inspección', + }); + } + return this.loadView(manager, existing.id); + } + } await this.assertVisitHasNoDraftAct(manager, visitId); await this.assertVisitAssets(manager, visitId, dto.assetIds); const occurredAt = new Date(dto.occurredAt); @@ -305,6 +363,7 @@ export class InspectionActsService { `, [occurredAt])) as Array<{ date_part: string }>; const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`; const act = manager.getRepository(InspectionAct).create({ + id: dto.clientGeneratedId, visitId, actYear, actNumber, diff --git a/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts b/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts index ea4e132..f93bfa3 100644 --- a/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts +++ b/api-v3/src/inspection-findings/dto/create-inspection-evidence.dto.ts @@ -16,6 +16,10 @@ import { } from '../../database/entities'; export class CreateInspectionEvidenceDto { + @IsOptional() + @IsUUID('4') + operationId?: string; + @IsEnum(InspectionEvidenceKind) kind!: InspectionEvidenceKind; diff --git a/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts b/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts index 5f006e4..5177efe 100644 --- a/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts +++ b/api-v3/src/inspection-findings/dto/create-inspection-finding.dto.ts @@ -16,6 +16,10 @@ const optionalText = ({ value }: { value: unknown }) => typeof value === 'string' && value.trim() ? value.trim() : null; export class CreateInspectionFindingDto { + @IsOptional() + @IsUUID('4') + clientGeneratedId?: string; + @IsUUID('4') assetId!: string; diff --git a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts index 058002e..4cfcef4 100644 --- a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts +++ b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts @@ -61,7 +61,7 @@ export class F3FindingCatalogResolverService { inventoryFamily: null, catalogSource: 'INVENTORY_FAMILY' as const, typeConfigured: false, - configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.', + configurationReason: 'El elemento todavía no tiene tipo de instalación/subinstalación activo.', categories: [], items: [], other, @@ -120,7 +120,7 @@ export class F3FindingCatalogResolverService { }, catalogSource: 'INVENTORY_FAMILY' as const, typeConfigured: true, - configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(), + configurationReason: `Hallazgos asociados al tipo ${asset.familyName ?? asset.familyCode ?? ''}`.trim(), categories, items, other, diff --git a/api-v3/src/inspection-findings/inspection-evidence.service.ts b/api-v3/src/inspection-findings/inspection-evidence.service.ts index ecdc6a6..5c5183f 100644 --- a/api-v3/src/inspection-findings/inspection-evidence.service.ts +++ b/api-v3/src/inspection-findings/inspection-evidence.service.ts @@ -153,6 +153,22 @@ export class InspectionEvidenceService { assertMobileInspector(principal); } this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); + if (dto.operationId) { + const [existing] = await this.dataSource.query( + `${this.evidenceSelect()} WHERE evidence.client_operation_id = $1`, + [dto.operationId], + ) as StoredInspectionEvidence[]; + if (existing) { + if (existing.findingId !== findingId) { + throw new ConflictException({ + code: 'INSPECTION_EVIDENCE_OPERATION_REUSED', + message: 'La operación offline ya fue utilizada para otra evidencia', + }); + } + const { storedName: _storedName, ...view } = existing; + return view; + } + } const inspected = inspectInspectionEvidenceFile(file, dto.kind); this.validatePurpose(dto, inspected.mimeType); @@ -210,12 +226,12 @@ export class InspectionEvidenceService { id, finding_id, communication_id, verification_visit_id, kind, purpose, original_name, stored_name, mime_type, size_bytes, sha256, title, description, captured_at, latitude, longitude, accuracy_m, - device_label, source, uploaded_by + device_label, source, uploaded_by, client_operation_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, - $18, $19, $20 + $18, $19, $20, $21 ) `, [ id, @@ -238,6 +254,7 @@ export class InspectionEvidenceService { dto.deviceLabel?.trim() || null, source, principal.userId, + dto.operationId ?? null, ]); const created = await this.loadEvidence(manager, id); await this.audit.record({ diff --git a/api-v3/src/inspection-findings/inspection-findings.service.ts b/api-v3/src/inspection-findings/inspection-findings.service.ts index 53e6693..9ceb2d9 100644 --- a/api-v3/src/inspection-findings/inspection-findings.service.ts +++ b/api-v3/src/inspection-findings/inspection-findings.service.ts @@ -398,6 +398,22 @@ export class InspectionFindingsService { const visit = await this.lockVisit(manager, act.visitId); this.assertActEditable(act, visit); await this.assertActorAssigned(manager, visit.id, principal); + if (dto.clientGeneratedId) { + const [existing] = await manager.query(` + SELECT id, act_id AS "actId", asset_id AS "assetId" + FROM inspection_findings + WHERE id=$1 + `, [dto.clientGeneratedId]) as Array<{ id: string; actId: string; assetId: string }>; + if (existing) { + if (existing.actId !== actId || existing.assetId !== dto.assetId) { + throw new ConflictException({ + code: 'INSPECTION_FINDING_CLIENT_ID_REUSED', + message: 'El identificador offline del Hallazgo ya fue utilizado en otro contexto', + }); + } + return this.loadView(manager, existing.id); + } + } await this.assertActAsset(manager, actId, dto.assetId); const catalog = dto.catalogItemId @@ -418,6 +434,7 @@ export class InspectionFindingsService { }); } const finding = manager.getRepository(InspectionFinding).create({ + id: dto.clientGeneratedId, actId, assetId: dto.assetId, catalogItemId: catalog?.id ?? null, diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index f194323..72bd609 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -28,6 +28,7 @@ import { } from './inspection-report-workflow.service'; import { InspectionReportsService } from './inspection-reports.service'; import { InspectionReportWordService } from './inspection-report-word.service'; +import { InspectionActPdfService } from './inspection-act-pdf.service'; @Controller('inspection-reports') export class InspectionReportsController { @@ -148,3 +149,24 @@ export class InspectionActReportController { return this.reports.generate(actId, principal, request); } } + +@Controller('inspection-acts/:actId/pdf') +export class InspectionActPdfController { + constructor(private readonly pdf: InspectionActPdfService) {} + + @Get() + @RequirePermissions('inspection_acts.read') + async content( + @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, + @Res() response: Response, + ) { + await this.pdf.ensure(actId); + const content = await this.pdf.content(actId); + response.setHeader('Content-Type', content.mimeType); + response.setHeader('Content-Length', String(content.buffer.length)); + response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + return response.send(content.buffer); + } +} diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index 4696531..5b52d69 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -7,7 +7,7 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; import { InspectionReportWorkflowService } from './inspection-report-workflow.service'; import { InspectionReportWordService } from './inspection-report-word.service'; -import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; +import { InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller'; import { InspectionReportsService } from './inspection-reports.service'; import { SmtpDeliveryService } from './smtp-delivery.service'; @@ -16,6 +16,7 @@ import { SmtpDeliveryService } from './smtp-delivery.service'; controllers: [ InspectionReportsController, InspectionActReportController, + InspectionActPdfController, InspectionDeadlineAdminController, DocumentDeliveryController, ], diff --git a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts index 5359dc2..14e753a 100644 --- a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts +++ b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts @@ -23,6 +23,10 @@ export class CreateFieldFindingDto { @IsUUID('4') actId!: string; + @IsOptional() + @IsUUID('4') + clientGeneratedId?: string; + @IsOptional() @Transform(optionalText) @IsUUID('4') diff --git a/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts b/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts index 6ffbca5..1d79f09 100644 --- a/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts +++ b/api-v3/src/inspection-visits/dto/create-field-inventory.dto.ts @@ -14,6 +14,10 @@ import { } from 'class-validator'; export class CreateFieldInventoryDto { + @IsOptional() + @IsUUID('4') + clientGeneratedId?: string; + @IsUUID('4') typeId!: string; diff --git a/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts b/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts index 8101a12..16b23b6 100644 --- a/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts +++ b/api-v3/src/inspection-visits/dto/upload-field-inventory-photo.dto.ts @@ -4,12 +4,17 @@ import { IsNumber, IsOptional, IsString, + IsUUID, Max, MaxLength, Min, } from 'class-validator'; export class UploadFieldInventoryPhotoDto { + @IsOptional() + @IsUUID('4') + operationId?: string; + @IsOptional() @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @IsString() diff --git a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts index 0f9779b..abd52fb 100644 --- a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts +++ b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts @@ -101,13 +101,13 @@ export class F3FieldInventoryStructureService { if (familyRequired && !dto.familyId) { throw new BadRequestException({ code: 'FIELD_INVENTORY_FAMILY_REQUIRED', - message: 'Elegí la familia técnica o la opción Otro / no catalogado', + message: 'Elegí el Tipo de instalación/subinstalación o la opción Otro / no catalogado', }); } if (!familyRequired && dto.familyId) { throw new BadRequestException({ code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED', - message: 'Este nivel estructural no utiliza familia técnica', + message: 'Este nivel estructural no utiliza Tipo de instalación/subinstalación', }); } const family = dto.familyId @@ -116,7 +116,7 @@ export class F3FieldInventoryStructureService { if (dto.familyId && !family) { throw new BadRequestException({ code: 'FIELD_INVENTORY_FAMILY_INVALID', - message: 'La familia técnica no es válida para el padre seleccionado', + message: 'El Tipo de instalación/subinstalación no es válido para el padre seleccionado', }); } @@ -214,7 +214,7 @@ export class F3FieldInventoryStructureService { if (!parent.inventoryFamilyId) { throw new ConflictException({ code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED', - message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones', + message: 'La Instalación debe tener un Tipo de instalación antes de agregar Subinstalaciones', }); } return this.dataSource.query(` diff --git a/api-v3/src/inspection-visits/field-findings.service.ts b/api-v3/src/inspection-visits/field-findings.service.ts index 030baa0..0ad8c12 100644 --- a/api-v3/src/inspection-visits/field-findings.service.ts +++ b/api-v3/src/inspection-visits/field-findings.service.ts @@ -99,6 +99,7 @@ export class FieldFindingsService { }); } const payload: CreateInspectionFindingDto = { + clientGeneratedId: dto.clientGeneratedId, assetId, catalogItemId: dto.catalogItemId ?? null, customTitle: dto.customTitle ?? null, @@ -278,7 +279,7 @@ export class FieldFindingsService { if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) { throw new ConflictException({ code: 'FIELD_FINDING_FAMILY_REQUIRED', - message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', + message: 'La Instalación/Subinstalación debe tener su tipo definido antes de registrar Hallazgos', }); } if (!row.insideScope) { diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts index 4e9c8cf..6d49458 100644 --- a/api-v3/src/inspection-visits/field-inventory.service.ts +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -273,6 +273,23 @@ export class FieldInventoryService { request: RequestWithContext, ) { const context = await this.requireVisitContext(visitId, principal, true); + if (dto.clientGeneratedId) { + const [existing] = await this.dataSource.query(` + SELECT discovery.asset_id AS "assetId", discovery.visit_id AS "visitId" + FROM asset_field_discoveries discovery + WHERE discovery.asset_id=$1::uuid + LIMIT 1 + `, [dto.clientGeneratedId]) as Array<{ assetId: string; visitId: string }>; + if (existing) { + if (existing.visitId !== visitId) { + throw new ConflictException({ + code: 'FIELD_INVENTORY_CLIENT_ID_REUSED', + message: 'El identificador offline del Inventario ya fue utilizado en otra inspección', + }); + } + return this.detail(visitId, existing.assetId, principal); + } + } const parentId = dto.parentId ?? context.scopeAssetId; await this.requireParentInContext(parentId, context); @@ -286,6 +303,7 @@ export class FieldInventoryService { const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt); const discoveryDto: CreateFieldDiscoveryDto = { + clientGeneratedId: dto.clientGeneratedId, visitId, code, name: dto.name, @@ -351,6 +369,28 @@ export class FieldInventoryService { ): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> { const context = await this.requireVisitContext(visitId, principal, true); await this.requireAssetInContext(assetId, context); + if (dto.operationId) { + const [existing] = await this.dataSource.query(` + SELECT media_id AS "mediaId", asset_id AS "assetId" + FROM asset_field_capture_events + WHERE client_operation_id=$1::uuid + LIMIT 1 + `, [dto.operationId]) as Array<{ mediaId: string | null; assetId: string }>; + if (existing) { + if (existing.assetId !== assetId || !existing.mediaId) { + throw new ConflictException({ + code: 'FIELD_PHOTO_OPERATION_REUSED', + message: 'La operación offline de fotografía ya fue utilizada en otro Inventario', + }); + } + const media = (await this.media.list(assetId)).data.find((item) => item.id === existing.mediaId); + if (!media) throw new ConflictException({ + code: 'FIELD_PHOTO_OPERATION_MEDIA_MISSING', + message: 'La fotografía sincronizada no está disponible', + }); + return { media, capture: await this.captureStatus(visitId, assetId) }; + } + } if (!await this.isSelected(visitId, assetId)) { throw new ConflictException({ code: 'FIELD_INVENTORY_NOT_SELECTED', @@ -383,8 +423,8 @@ export class FieldInventoryService { device_latitude, device_longitude, device_accuracy_m, device_captured_at, device_label, exif_latitude, exif_longitude, exif_captured_at, - created_by - ) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12) + created_by, client_operation_id + ) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) `, [ visitId, assetId, @@ -398,6 +438,7 @@ export class FieldInventoryService { dto.exifLongitude ?? null, dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null, principal.userId, + dto.operationId ?? null, ]); return { media: uploaded, capture: await this.captureStatus(visitId, assetId) }; diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index aa84cb9..6152001 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-7'; -export const API_PHASE = 'F6.7'; +export const API_VERSION = '0.29.0-8'; +export const API_PHASE = 'F6.8'; diff --git a/api-v3/test/unit/f4-android-act-contract.test.ts b/api-v3/test/unit/f4-android-act-contract.test.ts index d0f2634..d5a4c1c 100644 --- a/api-v3/test/unit/f4-android-act-contract.test.ts +++ b/api-v3/test/unit/f4-android-act-contract.test.ts @@ -34,9 +34,9 @@ test('F4 Android uses only lock and seal endpoints for the active Act lifecycle' assert.doesNotMatch(mobileActs, /suspend fun closeAct\(/); }); -test('F4 Android ViewModel calls lock and seal directly', () => { - assert.match(viewModel, /actsRepository\.lock\(actId, urgency\)/); - assert.match(viewModel, /actsRepository\.seal\(actId\)/); +test('F6.8 Android ViewModel persists lock and seal through the offline queue', () => { + assert.match(viewModel, /offlineQueue\.enqueueActLock\(actId, urgency\)/); + assert.match(viewModel, /offlineQueue\.enqueueSeal\(actId\)/); assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/); assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/); assert.doesNotMatch(viewModel, /actsRepository\.closeAct\(/); diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 8fa0f83..35ca3ad 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.7 release', () => { - assert.equal(API_PHASE, 'F6.7'); +test('health metadata reports the current F6.8 release', () => { + assert.equal(API_PHASE, 'F6.8'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-7'); + assert.equal(API_VERSION, '0.29.0-8'); }); \ No newline at end of file diff --git a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts index 43adf9b..d4b2022 100644 --- a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts +++ b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts @@ -63,7 +63,7 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/); assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/); assert.match(panel, /Todos para vincular/); - assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/); + assert.match(panel, /Este tipo todavía no tiene Hallazgos asociados/); }); test('F7 Inventory Configuration exposes only simple types, containment and fields', () => { @@ -116,7 +116,7 @@ test('F7 canonical detail uses one short everyday profile and keeps advanced adm assert.match(detail, />FotosCambios { const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); - assert.match(gradle, /versionCode = 37/); - assert.match(gradle, /versionName = "0\.19\.9"/); + assert.match(gradle, /versionCode = 38/); + assert.match(gradle, /versionName = "0\.19\.10"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index cf0a4cf..c11580d 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; assert.equal(visibleVersion, pkg.version); - assert.match(version, /APP_PHASE\s*=\s*'F6\.7 · Cierre y firma por Acta'/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.8/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { @@ -49,7 +49,7 @@ test('F6.1 presentation keeps Android start, Otro and chronological merge flows assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/); assert.match(overview, /"Iniciar inspección"/); assert.match(overview, /Otro \/ no catalogado/); - assert.match(viewModel, /repository\.startVisit\(id\)/); + assert.match(viewModel, /enqueueVisitStart\(id\)|repository\.startVisit\(id\)/); assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/); assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/); }); diff --git a/api-v3/test/unit/f6-7-act-signing-ui.test.ts b/api-v3/test/unit/f6-7-act-signing-ui.test.ts index 77b32ee..303e3da 100644 --- a/api-v3/test/unit/f6-7-act-signing-ui.test.ts +++ b/api-v3/test/unit/f6-7-act-signing-ui.test.ts @@ -36,11 +36,11 @@ test('F6.7 Android goes Draft to Para firmar to company manifestation without in assert.match(labels, /"LOCKED" -> "Para firmar"/); assert.match(android, /Cerrar contenido y pasar a firma/); assert.match(android, /Firma del representante de la empresa \(acompañante\)/); - assert.match(android, /Aplicar mi firma y cerrar Acta/); + assert.match(android, /cerrar Acta/); assert.doesNotMatch(android, /Firmá como inspector\/a/); assert.doesNotMatch(android, /inspectorSigned/); assert.doesNotMatch(android, /Text\(selected\.code/); - assert.match(android, /companyOutcome\?\.status == "ABSENT"/); + assert.match(android, /attendanceStatus == "ABSENT"/); }); test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => { assert.match(profile, /FIRMA DEL INSPECTOR/); diff --git a/api-v3/test/unit/f6-8-offline-document-contract.test.ts b/api-v3/test/unit/f6-8-offline-document-contract.test.ts new file mode 100644 index 0000000..73d7b97 --- /dev/null +++ b/api-v3/test/unit/f6-8-offline-document-contract.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8'); + +test('F6.8 persists field mutations locally and retries only when network is available', () => { + const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt'); + assert.match(queue, /@Database\(entities = \[PendingMobileOperation::class, OfflineCacheEntry::class\]/); + assert.match(queue, /NetworkType\.CONNECTED/); + assert.match(queue, /OneTimeWorkRequestBuilder/); + for (const operation of [ + 'VISIT_START', 'VISIT_CLOSE', 'FIELD_ASSET_CREATE', 'FIELD_ASSET_PHOTO', + 'ACT_CREATE', 'ACT_ASSET_ENSURE', 'FINDING_CREATE', 'FINDING_PHOTO', + 'ACT_RESPONSIBLE', 'ACT_LOCK', 'COMPANY_OUTCOME', 'COMPANY_SIGNATURE', 'ACT_SEAL', + ]) assert.match(queue, new RegExp(`"${operation}"`)); +}); + +test('F6.8 isolates offline work and cached inspection data by authenticated user', () => { + const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt'); + const mobile = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt'); + const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt'); + const findings = android('app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt'); + assert.match(queue, /val ownerUserId: String/); + assert.match(queue, /ownerUserId=:ownerUserId/); + assert.match(queue, /SecureSessionStore/); + assert.match(mobile, /userCacheKey/); + assert.match(acts, /userCacheKey/); + assert.match(findings, /userCacheKey/); +}); + +test('F6.8 gives offline-created objects idempotent client identities', () => { + const actDto = api('src/inspection-acts/dto/create-inspection-act.dto.ts'); + const findingDto = api('src/inspection-findings/dto/create-inspection-finding.dto.ts'); + const inventoryDto = api('src/inspection-visits/dto/create-field-inventory.dto.ts'); + const migration = api('src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts'); + assert.match(actDto, /clientGeneratedId/); + assert.match(findingDto, /clientGeneratedId/); + assert.match(inventoryDto, /clientGeneratedId/); + assert.match(migration, /inspection_finding_evidence/); + assert.match(migration, /asset_field_capture_events/); + assert.equal((migration.match(/client_operation_id/g) ?? []).length >= 6, true); +}); + +test('F6.8 reviews photos before queueing and never sends the retired ONLINE upload mode', () => { + const photo = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt'); + const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt'); + assert.match(photo, /Revisar fotografía/); + assert.match(photo, /Usar esta foto/); + assert.match(photo, /Volver a tomar/); + assert.match(photo, /Eliminar foto/); + assert.doesNotMatch(acts, /uploadMode: String = "ONLINE"/); + assert.match(acts, /uploadMode: String = "IMMEDIATE"/); +}); + +test('F6.8 makes the real Acta PDF and photographic record first-class in Dashboard', () => { + const controller = api('src/inspection-reports/inspection-reports.controller.ts'); + const page = web('src/pages/InspectionActEditorPage.tsx'); + const media = web('src/features/inspections/InspectionActMediaPanel.tsx'); + assert.match(controller, /inspection-acts\/:actId\/pdf/); + assert.match(api('src/inspection-acts/inspection-acts.controller.ts'), /:id\/field-media/); + assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/); + assert.match(page, /Abrir PDF del Acta/); + assert.match(page, /Descargar PDF/); + assert.match(media, /Fotos y Hallazgos/); + assert.match(media, /Evidencia fotográfica/); + assert.match(media, /Fotos tomadas durante esta inspección/); +}); + +test('F6.8 presents technical families to users as installation and subinstallation types', () => { + const config = web('src/pages/AssetTypesPage.tsx'); + const detail = web('src/pages/SimpleInventoryDetailPage.tsx'); + const field = api('src/inspection-visits/f3-field-inventory-structure.service.ts'); + assert.match(config, /Tipos de Instalación/); + assert.match(config, /Tipos de Subinstalación/); + assert.match(detail, /Tipo de instalación \/ subinstalación/); + assert.match(field, /Tipo de instalación\/subinstalación/); + assert.doesNotMatch(config, /clasificación técnica/i); + assert.doesNotMatch(detail, /clasificación técnica/i); +}); diff --git a/docs/AUDITORIA_INTEGRAL_F6_8_2026-09-15.md b/docs/AUDITORIA_INTEGRAL_F6_8_2026-09-15.md new file mode 100644 index 0000000..3bd0bf0 --- /dev/null +++ b/docs/AUDITORIA_INTEGRAL_F6_8_2026-09-15.md @@ -0,0 +1,80 @@ +# Auditoría integral DH V2 · F6.8 · 15/09/2026 + +## Alcance +Revisión del árbol activo API V3, WEB V2 y Android de campo contra el modelo funcional vigente. La auditoría distingue superficie activa, compatibilidad histórica y código fuente retirado/no enrutable para no confundir legado con funcionalidad productiva. + +## 1. Acceso, usuarios, roles y perfil +- El acceso autenticado, permisos y cambio de contraseña siguen aislados por permisos. +- La firma reutilizable del inspector se administra en Mi perfil y se consulta desde Android antes del cierre del Acta. +- Corrección F6.8: Android ya no presume que existe la firma. Si se configura después de haber guardado un cierre pendiente, “Volver a verificar” reintenta automáticamente la sincronización. +- Corrección F6.8: cola y caché offline están separadas por `userId`; un segundo usuario del mismo dispositivo no puede sincronizar ni reutilizar datos locales del anterior. + +## 2. Planificación e Inspecciones +- Flujo activo: oficina planifica; la APK inicia y cierra la ejecución física. +- Contexto vigente: Departamento → Área → Yacimiento; Operadora temporal del Área; el Yacimiento fija el alcance físico. +- La planificación activa no usa fecha de fin prevista. Las referencias `plannedEndAt` encontradas pertenecen al subsistema Survey retirado y no están en rutas activas. +- Corrección F6.8: inicio y cierre de Inspección quedan persistidos localmente si se pierde conectividad y se sincronizan en orden al recuperarla. Un rechazo funcional del servidor revierte el estado optimista local. + +## 3. Actas +- Una Inspección puede contener múltiples Actas, manteniendo como máximo una en borrador simultáneamente. +- La urgencia se define al bloquear/cerrar el contenido del Acta completa; no pertenece individualmente a cada Hallazgo. +- El Acta bloqueada es inmutable y pasa a firma. La firma del inspector proviene del perfil; la persona acompañante firma por cada Acta. +- Flujo activo de acompañante: conforme, disconformidad con motivo o negativa a firmar con motivo. La opción de crear nuevas ausencias fue retirada de la interfaz; `ABSENT` se conserva únicamente para leer registros históricos. +- Corrección crítica F6.8: Android enviaba `uploadMode=ONLINE`, valor inválido para la API. Se reemplazó por `IMMEDIATE` y la cola offline usa `DEFERRED`. +- Corrección F6.8: alta, bloqueo, responsable, manifestación, firma y sellado pueden quedar en cola local sin perderse. + +## 4. Hallazgos y evidencia +- Los Hallazgos pertenecen a un Acta explícita y a un elemento de Inventario incluido en ella. +- El catálogo se resuelve por Tipo de instalación/subinstalación y mantiene OTROS como salida no restrictiva. +- Corrección F6.8: alta de Hallazgo y carga de evidencia usan UUID de cliente para que un reintento offline no duplique registros. +- Corrección F6.8: cada fotografía se revisa antes de incorporarse: usar, volver a tomar o eliminar; conserva GPS y fecha de captura. +- Se mantienen múltiples fotografías por Hallazgo. + +## 5. Inventarios +- Jerarquía activa: Departamento → Área → Yacimiento → Instalación → Subinstalación. +- Corrección de nomenclatura: la antigua expresión visible “clasificación técnica/familia técnica” se presenta como “Tipo de instalación” o “Tipo de subinstalación”. Los nombres físicos internos de tablas/variables se conservan para evitar migraciones destructivas. +- Altas en campo conservan GPS y foto obligatoria antes de habilitar Hallazgos. +- Corrección F6.8: creación, selección, foto y asociación al Acta pueden persistirse offline con identidad estable e idempotencia de servidor. +- Historial, dossier y consulta temporal siguen separados del estado actual para no reescribir historia. + +## 6. Acta documental, fotos y Dashboard +- Desvío corregido: la ficha de Acta mezclaba demasiada información administrativa y el PDF/fotos no eran contenido principal. +- F6.8 agrega “Documento del Acta” con apertura y descarga del PDF real sellado. +- Las fotos de Hallazgos se muestran como miniaturas dentro del Acta. +- Las fotos de Inventario se limitan a eventos de campo de la misma Inspección y a elementos incluidos en esa Acta; no se mezclan fotos históricas de otras visitas. +- La proyección documental anterior queda sólo como referencia colapsada, no como sustituto del PDF oficial. + +## 7. Seguimiento, verificaciones y vencimientos +- El seguimiento administrativo permanece separado de la captura de campo. +- Respuesta de empresa, documentación, fechas informadas y verificación física conservan trazabilidad sin reabrir el Acta. +- La planificación de verificaciones continúa basada en Hallazgos abiertos y no reemplaza el plazo administrativo del Acta. +- No se detectaron rutas activas que vuelvan a asignar la urgencia al Hallazgo. + +## 8. Informes, GEDO y entrega documental +- Flujo activo confirmado: Acta → INF → oficialización GEDO/IF. +- Un INF corresponde a una sola Acta; una Inspección con varias Actas puede producir varios INF. +- El circuito legacy de aprobación/firma final por Director fue retirado de la superficie activa; sus menciones permanecen sólo en migraciones reversibles/historia. +- La entrega documental y SMTP continúan bajo permisos administrativos y trazabilidad. + +## 9. Importaciones y compatibilidad histórica +- El módulo API de importaciones se conserva, pero la antigua pantalla de importación fue retirada deliberadamente de las rutas WEB en F5; no se reactivó durante F6.8 para no mezclar el contrato anterior con el modelo autoritativo actual. +- Las páginas Survey y campos como `plannedEndAt` existen como fuente histórica sin ruta activa; los módulos Survey tampoco están registrados en `AppModule`. +- Se mantienen redirects `/activos` → `/inventarios` y valores históricos de estados de Acta únicamente para compatibilidad de enlaces/datos existentes. + +## 10. Offline, consistencia y seguridad de sincronización +- Room persiste operaciones y contexto descargado; WorkManager reintenta sólo con red disponible. +- Las operaciones dependientes se procesan en orden estable y la cola se detiene ante un error funcional permanente para no ejecutar hijos sobre un padre rechazado. +- Las operaciones críticas usan identificadores de cliente y columnas de idempotencia para evitar duplicados tras cortes/reintentos. +- Los archivos pendientes se almacenan dentro del espacio privado de la aplicación hasta quedar sincronizados. + +## Barreras de aceptación F6.8 +- API: build + suite integral de contratos/unidad. +- WEB: typecheck/build de producción. +- Android: compilación Debug/Release, tests unitarios, APK Debug instalable y lint por variante dentro de los límites del VPS. +- Deploy productivo: backup previo, migraciones, recreación API/WEB, health/versiones y ausencia de migraciones pendientes. + +## Compatibilidad intencional que NO debe eliminarse a ciegas +- Estados/eventos históricos de Acta (`READY`, `CLOSED`, `RECTIFIED`, `ABSENT`) para lectura de expedientes existentes. +- Rutas antiguas `/activos` como redirección de compatibilidad. +- Migraciones históricas y textos de rollback: no se reescriben. +- Fuentes Survey/legacy no enrutadas: no forman parte del producto activo, pero sirven a contratos históricos y rollback. diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index b04bd8f..dc72169 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.23.0-4", + "version": "0.23.0-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.23.0-4", + "version": "0.23.0-5", "dependencies": { "maplibre-gl": "6.4.1", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index b6af6a8..bf3df1e 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.23.0-4", + "version": "0.23.0-5", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/components/Icon.tsx b/web-v2/src/components/Icon.tsx index cb66ed2..fa634ac 100644 --- a/web-v2/src/components/Icon.tsx +++ b/web-v2/src/components/Icon.tsx @@ -1,7 +1,7 @@ export type IconName = | 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert' | 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu' - | 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock'; + | 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock' | 'camera'; export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { const paths: Record = { @@ -26,6 +26,7 @@ export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { upload: <>, mail: <>, lock: <>, + camera: <>, }; return (
; + if (loading) return
; if (error) return {error}; if (unsupported || !technical) return
-
HALLAZGOS APLICABLES

Sin clasificación técnica

Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.

+
HALLAZGOS APLICABLES

Sin tipo asignado

Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones con su tipo definido.

; return
@@ -65,14 +65,14 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
HALLAZGOS APLICABLES

{technical.family.name}

-

Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.

+

Se muestran únicamente los Hallazgos asociados a este tipo. Éste es el mismo catálogo que consume la APK para este objeto.

{items.length} asociados
{canManage &&
-

¿Falta un Hallazgo? Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.

+

¿Falta un Hallazgo? Administrá el tipo en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo.

Configurar Inventarios
} @@ -82,7 +82,7 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri } {items.length === 0 - ?
Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.
+ ?
Este tipo todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.
: visible.length === 0 ?
No hay Hallazgos asociados que coincidan con la búsqueda.
:
{visible.map((item) =>
diff --git a/web-v2/src/features/assets/AssetHierarchyView.tsx b/web-v2/src/features/assets/AssetHierarchyView.tsx index 32c76fc..336a332 100644 --- a/web-v2/src/features/assets/AssetHierarchyView.tsx +++ b/web-v2/src/features/assets/AssetHierarchyView.tsx @@ -46,13 +46,13 @@ const structuredInventorySections = [ { code: 'instalacion', label: 'Instalaciones', - description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.', + description: 'Elementos técnicos ubicados dentro de un Yacimiento y definidos por su tipo de instalación.', icon: 'layers' as const, }, { code: 'subinstalacion', label: 'Subinstalaciones', - description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.', + description: 'Último nivel estructural. Dependen de una Instalación y conservan su tipo de subinstalación.', icon: 'layers' as const, }, ] as const; @@ -198,8 +198,8 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery
1Departamentoraíz territorial
2Áreaoperadoras vinculadas
3Yacimientodentro del Área
-
4Instalaciónclasificación técnica
-
5Subinstalaciónclasificación técnica
+
4Instalacióntipo de instalación
+
5Subinstalacióntipo de subinstalación
@@ -238,7 +238,7 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery

{nextLevelLabel(current?.type.code)}

Jerarquía: Departamento → Área → Yacimiento → Instalación → Subinstalación.

{children.length}
{children.length === 0 - ? + ? :
{children.map((item) => )}
}
; diff --git a/web-v2/src/features/assets/AssetTechnicalDataPanel.tsx b/web-v2/src/features/assets/AssetTechnicalDataPanel.tsx index d3d3274..2c4c9ab 100644 --- a/web-v2/src/features/assets/AssetTechnicalDataPanel.tsx +++ b/web-v2/src/features/assets/AssetTechnicalDataPanel.tsx @@ -26,7 +26,7 @@ export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdi let active=true; setLoading(true); setUnsupported(false); setError(''); getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{ const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??'']; - })))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false)); + })))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene tipo'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false)); return()=>{active=false}; },[assetId]); if(loading)return
; @@ -48,9 +48,9 @@ export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdi }catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)} }; return
-

Información técnica · {data.family.name}

Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.

+

Información técnica · {data.family.name}

Estos campos pertenecen al tipo concreto del elemento, no al nivel genérico.

{error&&{error}}{success&&{success}} - {activeDefinitions.length===0?
Esta clasificación todavía no tiene campos técnicos configurados.
:canEdit?
{activeDefinitions.map((definition)=>{ + {activeDefinitions.length===0?
Este tipo todavía no tiene campos técnicos configurados.
:canEdit?
{activeDefinitions.map((definition)=>{ const value=values[definition.id];const label={definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?obligatorio:opcional}; if(definition.dataType==='BOOLEAN')return ; if(definition.dataType==='SELECT')return ; diff --git a/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx b/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx index f3e68d9..c6e23f9 100644 --- a/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx +++ b/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx @@ -27,7 +27,7 @@ export function FindingCatalogTypeApplicabilityPanel() { const [catalog, setCatalog] = useState(EMPTY_CATALOG); const [familyId, setFamilyId] = useState(''); const [enabled, setEnabled] = useState>(new Set()); - const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario'); + const [reason, setReason] = useState('Actualización de Hallazgos asociados al tipo de Inventario'); const [search, setSearch] = useState(''); const [viewMode, setViewMode] = useState('ASSOCIATED'); const [loading, setLoading] = useState(true); @@ -77,15 +77,15 @@ export function FindingCatalogTypeApplicabilityPanel() { }; if (loading) return
; - if (families.length === 0) return No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.; + if (families.length === 0) return No hay tipos de Instalación/Subinstalación disponibles. Crealos primero en Configuración de Inventarios.; return
-
APLICABILIDAD POR CLASIFICACIÓN

Qué Hallazgos verá el inspector

Al elegir una clasificación se muestran sólo sus Hallazgos asociados. “Todos para vincular” se usa exclusivamente para modificar la relación.

{savedIds.size} vinculados
+
APLICABILIDAD POR TIPO

Qué Hallazgos verá el inspector

Al elegir un tipo se muestran sólo sus Hallazgos asociados. “Todos para vincular” se usa exclusivamente para modificar la relación.

{savedIds.size} vinculados
{error && {error}}{success && {success}} -
+
{selectedFamily &&

{selectedFamily.name}: {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.

}
- {viewMode === 'ASSOCIATED' && visible.length === 0 ?
{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}
{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}
: <>{viewMode === 'ALL' &&
}
{visible.map((item) => viewMode === 'ALL' ? :
{item.title}{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}
)}
} + {viewMode === 'ASSOCIATED' && visible.length === 0 ?
{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Este tipo todavía no tiene Hallazgos asociados.'}
{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}
: <>{viewMode === 'ALL' &&
}
{visible.map((item) => viewMode === 'ALL' ? :
{item.title}{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}
)}
} {viewMode === 'ALL' && <>