Compare commits

..
Author SHA1 Message Date
admin 23880521d9 fix(api): align health version with package metadata
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 3m16s
DH V2 CI / Docker / scripts contract (push) Successful in 50s
2026-09-14 10:54:14 -03:00
admin 637c28dc2f fix(web): align visible version with 0.23.0-2
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m1s
2026-09-14 10:37:58 -03:00
admin fc0c62c70a docs(android): pin 0.19.2 server compatibility
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m34s
2026-09-14 10:31:54 -03:00
admin 3ab10b37ea chore(web): bump version for deferred Acta urgency
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 26s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 20s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
2026-09-14 10:30:15 -03:00
admin c6475c851e fix(api): clear legacy draft urgency during migration
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m17s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 32s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 19s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 28s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 50s
DH V2 CI / API · typecheck, tests, build (push) Canceled after 0s
DH V2 CI / WEB · typecheck, build (push) Canceled after 0s
DH V2 CI / Docker / scripts contract (push) Canceled after 0s
Production dependency audit / API · production dependencies (push) Canceled after 0s
Production dependency audit / WEB · production dependencies (push) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Canceled after 3m58s
2026-09-14 10:13:02 -03:00
admin b757bf7729 ci(gitea): run planning smoke API requests inside container
DH V2 CI / API · typecheck, tests, build (pull_request) Canceled after 0s
DH V2 CI / WEB · typecheck, build (pull_request) Canceled after 0s
DH V2 CI / Docker / scripts contract (pull_request) Canceled after 0s
Production dependency audit / API · production dependencies (pull_request) Canceled after 0s
Production dependency audit / WEB · production dependencies (pull_request) Canceled after 0s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Canceled after 4m11s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Successful in 27s
2026-09-14 10:09:29 -03:00
admin e30e5d90ea fix(android): align Acta flow and defer urgency to close
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m31s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m30s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 33s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Inspection planning smoke / F6.1 · real inspection create (pull_request) Failing after 1m33s
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 9s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 1m5s
2026-09-14 09:27:30 -03:00
admin d44ae049f3 test(android): align 0.19.1 release contract and record field review
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m11s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m12s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 30s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 48s
2026-09-14 00:04:01 -03:00
admin 78e92970ff fix(android): align GPS and technical inventory field workflow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m12s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m9s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 25s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
2026-09-13 23:48:55 -03:00
admin 7fcb1d42b6 fix(android): harden field session and writes for 0.19.1 candidate
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m25s
2026-09-13 23:35:08 -03:00
admin e55684bf4f fix(web): compact installation parent checkbox options
DH V2 CI / API · typecheck, tests, build (push) Successful in 29s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 58s
2026-09-13 18:37:25 -03:00
admin ddf3b158e7 deploy: serialize Gitea production deployments
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 17:11:51 -03:00
admin 8564f1933c deploy: add native Gitea production workflow
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 16:56:54 -03:00
admin ea890cb807 ci(gitea): make API health probe DinD-compatible
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-13 16:40:46 -03:00
admin ce27cd01e9 ci(gitea): pin setup-gradle v4.4.3 for act compatibility
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m39s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / Docker / scripts contract (push) Failing after 1m38s
2026-09-13 13:03:21 -03:00
admin a155d9e075 Merge PR #59 · reparar Actividad global de Inventarios
Corrige la consulta SQL común del expediente de Actividad y aísla las facetas auxiliares para evitar errores globales.
2026-09-12 12:23:15 -03:00
admin e89d815868 test(api): actualizar contrato de Actividad territorial 2026-09-12 12:14:57 -03:00
admin ff0b6d83db test(api): cubrir fallo global de actividad 2026-09-12 12:11:55 -03:00
admin d894391d97 fix(api): registrar servicio de actividad resiliente 2026-09-12 12:11:40 -03:00
admin c79537def9 fix(api): usar dossier de actividad resiliente 2026-09-12 12:11:27 -03:00
admin 83c9e2ca3e fix(api): aislar y reparar consultas de actividad de inventario 2026-09-12 12:11:02 -03:00
admin 9254583154 Merge PR #58 · reparar Actividad de Yacimiento
Desacopla el dossier territorial del subsistema de fusión y evita que merge-status bloquee la pestaña Actividad.
2026-09-12 11:40:55 -03:00
admin 0b21e6bc50 test(f7): cubrir Actividad de Yacimiento sin dependencia de merge 2026-09-12 11:33:59 -03:00
admin 350e26566f fix(web): no bloquear Actividad por estado de fusión 2026-09-12 11:33:45 -03:00
admin ba79fbf9ae fix(api): desacoplar dossier territorial de merge 2026-09-12 11:32:50 -03:00
admin 77def3b511 Merge PR #56 · simplificación integral de Inventarios
F7 · modelo simple y experiencia cotidiana de Inventarios
2026-09-11 22:20:02 -03:00
61 changed files with 1745 additions and 331 deletions
+10 -2
View File
@@ -48,7 +48,7 @@ jobs:
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13 - name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4 uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with: with:
gradle-version: '8.13' gradle-version: '8.13'
@@ -63,7 +63,7 @@ jobs:
- name: Android lint - name: Android lint
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:lintDebug run: gradle --no-daemon :app:lintDebug :app:lintRelease
- name: Print complete lint failures - name: Print complete lint failures
if: failure() if: failure()
@@ -130,6 +130,14 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Upload unsigned release candidate
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics - name: Upload Android diagnostics
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+6 -1
View File
@@ -309,9 +309,14 @@ jobs:
docker compose --env-file .env.example build api docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api docker compose --env-file .env.example up -d api
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0 api_ready=0
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then if docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1 api_ready=1
break break
fi fi
+5 -4
View File
@@ -1,15 +1,16 @@
# Checklist final Android 0.19.0 # Checklist final Android 0.19.2
La candidata sólo se promueve si el mismo SHA cumple: La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección; - iniciar Inspección;
- crear/abrir Acta; - crear/abrir Acta sin pedir urgencia ni Inventario;
- seleccionar o crear Instalación/Subinstalación; - entrar a **Agregar Hallazgo** y recién allí seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos; - exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado; - registrar Hallazgos sobre el elemento seleccionado;
- cerrar contenido del Acta y mostrar **Pendiente de firma**; - al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
- completar firma del Inspector y manifestación/firma o negativa de la empresa; - completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**; - mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas; - permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no mostrar **Inventario de campo** como acción paralela de la Inspección;
- no exponer estados internos en inglés en la interfaz de campo; - no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde. - Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+39 -9
View File
@@ -1,27 +1,28 @@
# DH Inspección Android · release final de campo 0.19.0 # DH Inspección Android · release final de campo 0.19.2
## Candidata vigente ## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**. - Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.0**. - `versionName`: **0.19.2**.
- `versionCode`: **28**. - `versionCode`: **30**.
- Application ID release: `com.korexlabs.dhinspeccion`. - Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`. - Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`. - API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-2 / WEB 0.23.0-2**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica. La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento operativo validado ## Procedimiento operativo validado
La APK 0.19.0 fija como recorrido principal de campo: La APK 0.19.2 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo. 1. **Iniciar Inspección**. Al iniciarla se 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. 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.
3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada. 3. **Agregar Hallazgos**. Recién desde esta acción se selecciona la Instalación/Subinstalación afectada.
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección. 4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta. 5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos. 6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**. 7. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar Acta y dejar pendiente de firma**. La urgencia queda fijada con el cierre; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**. 8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas. 9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
@@ -57,7 +58,7 @@ La terminología visible se simplifica:
- `LOCKED`**Pendiente de firma**; - `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**. - `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable. Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. La urgencia se decide en ese cierre, nunca al crear el borrador. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina. Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
@@ -95,3 +96,32 @@ Antes de distribuir una APK productiva:
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda; - realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección; - ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida. - registrar el SHA Git y SHA-256 de la APK distribuida.
## Revisión 0.19.1
- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación.
- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector.
- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes.
- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior.
- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado.
- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia.
### Alcance real
Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final.
### Segunda pasada funcional
- Coordenadas normalizadas al contrato API (6 decimales, precisión 3).
- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación.
- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto.
- Se agregan siete pruebas para coordenadas y valores técnicos.
## Revisión 0.19.2 · flujo conceptual corregido
- Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección.
- El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**.
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
+49
View File
@@ -0,0 +1,49 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 28 versionCode = 30
versionName = "0.19.0" versionName = "0.19.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository import com.korexlabs.dhinspeccion.data.DhRepository
@@ -24,11 +25,14 @@ import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) { class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application) private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application) private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application) private val actsRepository = MobileActsRepository(application)
@@ -84,7 +88,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña." error = "Ingresá usuario y contraseña."
return return
} }
launchBusy { launchBusy(mutation = true) {
session = repository.login(identifier, password) session = repository.login(identifier, password)
notice = "Sesión iniciada." notice = "Sesión iniciada."
loadVisitsInternal() loadVisitsInternal()
@@ -113,7 +117,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
@@ -123,6 +129,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun closeVisitView() { fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null inventoryParentId = null
@@ -135,7 +143,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() { fun startVisit() {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
@@ -157,45 +165,31 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun createActForSelectedInventory(urgency: String = "NON_URGENT") { fun createAct() {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") { if (currentVisit.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para crear un Acta." error = "La Inspección debe estar en curso para crear un Acta."
return return
} }
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (acts.any { it.status == "DRAFT" }) { if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente." error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return return
} }
launchBusy { launchBusy(mutation = true) {
val created = actsRepository.create( val created = actsRepository.create(currentVisit.id, currentVisit.code)
currentVisit.id,
asset?.id,
currentVisit.code,
urgency,
)
selectedAct = created selectedAct = created
actClosure = actsRepository.closure(created.id) actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
} }
} }
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
launchBusy { launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data val result = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
} }
} }
@@ -203,6 +197,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -216,7 +211,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección." notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId) reloadCurrentInventory(visitId)
@@ -252,7 +247,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado." error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return return
} }
launchBusy { launchBusy(mutation = true) {
val request = CreateFieldInventoryRequest( val request = CreateFieldInventoryRequest(
typeId = type.id, typeId = type.id,
parentId = parentId, parentId = parentId,
@@ -261,9 +256,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() }, description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = latitude, deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = longitude, deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM, deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(), deviceCapturedAt = Instant.now().toString(),
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) selectedFieldAsset = repository.createFieldAsset(visitId, request)
@@ -283,7 +278,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado." error = "Explicá brevemente por qué se trata de un duplicado."
return return
} }
launchBusy { launchBusy(mutation = true) {
val result = repository.mergeFieldAsset( val result = repository.mergeFieldAsset(
visitId = visitId, visitId = visitId,
assetId = source.id, assetId = source.id,
@@ -310,7 +305,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return val asset = selectedFieldAsset?.asset ?: return
launchBusy { launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto( val response = repository.uploadFieldPhoto(
visitId = visitId, visitId = visitId,
assetId = asset.id, assetId = asset.id,
@@ -345,7 +340,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos." error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return return
} }
launchBusy { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId) selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false) loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id) loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -379,7 +374,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10." error = "La gravedad debe estar entre 1 y 10."
return return
} }
launchBusy { launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId) selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create( val response = findingsRepository.create(
visitId, visitId,
@@ -410,7 +405,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
) { ) {
launchBusy { launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto( findingsRepository.uploadObservationPhoto(
findingId = findingId, findingId = findingId,
file = file, file = file,
@@ -442,7 +437,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Completá nombre, documento y cargo del responsable de la empresa." error = "Completá nombre, documento y cargo del responsable de la empresa."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -465,7 +460,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres." error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible( actClosure = actsRepository.setResponsible(
actId, actId,
MobileResponsibleRequest( MobileResponsibleRequest(
@@ -477,12 +472,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun prepareSelectedAct() { fun prepareSelectedAct(urgency: String) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { if (urgency !in setOf("URGENT", "NON_URGENT")) {
actClosure = actsRepository.lock(actId) error = "Definí la urgencia antes de cerrar el Acta."
return
}
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId, urgency)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación." val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "Acta cerrada como $urgencyLabel y pendiente de firma. Su contenido quedó inmutable."
} }
} }
@@ -497,7 +497,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?, accuracyM: Double?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM) actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta." notice = "Firma del inspector incorporada al Acta."
} }
@@ -512,7 +512,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?, statement: String?,
) { ) {
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.signCompany( actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement, actId, png, latitude, longitude, accuracyM, manifestation, statement,
) )
@@ -534,7 +534,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de al menos 10 caracteres." error = "Indicá un motivo de al menos 10 caracteres."
return return
} }
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason) actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada." notice = "Negativa a firmar asentada."
} }
@@ -543,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeSelectedAct() { fun closeSelectedAct() {
val currentVisit = visit ?: return val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return val actId = selectedAct?.id ?: return
launchBusy { launchBusy(mutation = true) {
actClosure = actsRepository.seal(actId) actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
@@ -554,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeInspection() { fun closeInspection() {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina." notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
@@ -637,17 +637,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap() fieldFindingEvidence = emptyMap()
} }
private fun launchBusy(block: suspend () -> Unit) { private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
viewModelScope.launch { viewModelScope.launch {
busy = true
error = null error = null
try { try {
block() block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
error = DhRepository.humanError(throwable) error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null if (repository.currentSession() == null) session = null
} finally { } finally {
busy = false operations.end()
busy = operations.busy
} }
} }
} }
@@ -0,0 +1,16 @@
package com.korexlabs.dhinspeccion
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
internal class OperationTracker {
private var pending = 0
val busy: Boolean get() = pending > 0
fun begin(mutation: Boolean): Boolean {
if (mutation && busy) return false
pending += 1
return true
}
fun end() {
check(pending > 0)
pending -= 1
}
}
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -451,7 +449,6 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) { class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder() private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -530,13 +527,13 @@ class DhRepository(context: Context) {
visitId = visitId, visitId = visitId,
assetId = assetId, assetId = assetId,
file = part, file = part,
latitude = latitude.toString().toRequestBody(text), latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text), accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text), exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text), exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text),
) )
} }
@@ -548,23 +545,19 @@ class DhRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object { companion object {
fun humanError(error: Throwable): String { fun humanError(error: Throwable): String {
if (error is java.io.IOException) {
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
}
if (error is HttpException) { if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull() val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull() val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
@@ -0,0 +1,14 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -176,7 +174,6 @@ private interface FieldFindingsApi {
*/ */
class FieldFindingsRepository(context: Context) { class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder() private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -227,9 +224,9 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text), latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text), longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text), accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text),
) )
} }
@@ -241,18 +238,11 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -37,7 +35,7 @@ data class MobileActSummary(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -62,7 +60,7 @@ data class MobileActDetail(
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -93,13 +91,16 @@ data class MobileActListResponse(
data class CreateMobileActRequest( data class CreateMobileActRequest(
val occurredAt: String, val occurredAt: String,
val urgency: String,
val title: String, val title: String,
val summary: String, val summary: String,
val observations: String? = null, val observations: String? = null,
val assetIds: List<String>, val assetIds: List<String>,
) )
data class PrepareMobileActRequest(
val urgency: String,
)
data class UpdateMobileActRequest( data class UpdateMobileActRequest(
val assetIds: List<String>, val assetIds: List<String>,
) )
@@ -132,7 +133,7 @@ data class MobileActClosureHeader(
val code: String, val code: String,
val status: String, val status: String,
val visitId: String, val visitId: String,
val urgency: String = "NON_URGENT", val urgency: String? = null,
val deadlineDays: Int? = null, val deadlineDays: Int? = null,
val deadlineDayType: String? = null, val deadlineDayType: String? = null,
val deadlineBasis: String? = null, val deadlineBasis: String? = null,
@@ -251,6 +252,7 @@ private interface MobileActsApi {
suspend fun lock( suspend fun lock(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("actId") actId: String, @Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure ): MobileActClosure
@Multipart @Multipart
@@ -310,7 +312,6 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) { class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder() private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -329,19 +330,16 @@ class MobileActsRepository(context: Context) {
suspend fun create( suspend fun create(
visitId: String, visitId: String,
assetId: String? = null,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session -> ): MobileActDetail = authorized { session ->
api.createAct( api.createAct(
"Bearer ${session.accessToken}", "Bearer ${session.accessToken}",
visitId, visitId,
CreateMobileActRequest( CreateMobileActRequest(
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
urgency = urgency,
title = "Acta de inspección $visitCode", 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.", summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOfNotNull(assetId), assetIds = emptyList(),
), ),
) )
} }
@@ -363,8 +361,8 @@ class MobileActsRepository(context: Context) {
api.responsible("Bearer ${session.accessToken}", actId, request) api.responsible("Bearer ${session.accessToken}", actId, request)
} }
suspend fun lock(actId: String): MobileActClosure = authorized { session -> suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId) api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
} }
suspend fun signInspector( suspend fun signInspector(
@@ -429,9 +427,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text) val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text) val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text) val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text) val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text) val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text) val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
if (company) { if (company) {
api.signCompany( api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt, "Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -454,18 +452,11 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -4,8 +4,6 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -55,7 +53,6 @@ private interface MobileInspectionOpenApi {
class MobileInspectionOpenRepository(context: Context) { class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext) private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder() private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL) .baseUrl(BuildConfig.API_BASE_URL)
@@ -86,18 +83,11 @@ class MobileInspectionOpenRepository(context: Context) {
} catch (error: HttpException) { } catch (error: HttpException) {
if (error.code() != 401) throw error if (error.code() != 401) throw error
} }
session = refresh(session.refreshToken) session = refresh(session)
return block(session) return block(session)
} }
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { private suspend fun refresh(previous: StoredSession): StoredSession =
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
} }
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException
/** One refresh lock for every API client sharing the encrypted session. */
internal object MobileSessionCoordinator {
private val mutex = Mutex()
suspend fun refresh(
previous: StoredSession,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
request: suspend (RefreshRequest) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
if (latest.refreshToken != previous.refreshToken) return@withLock latest
val response = try {
request(RefreshRequest(previous.refreshToken))
} catch (error: HttpException) {
// Network failures and server outages must not discard the saved login.
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
throw error
}
// A logout or another login during the request must never be undone.
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
save(response)
}
}
@@ -0,0 +1,64 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.time.LocalDate
import java.time.OffsetDateTime
internal data class TechnicalDefinition(
val id: String, val code: String, val name: String, val dataType: String,
val isRequired: Boolean = false, val isActive: Boolean = true,
val unit: String? = null, val options: List<String>? = null,
)
internal data class TechnicalValues(
val assetId: String, val family: FieldInventoryFamily,
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
)
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
private interface TechnicalApi {
@GET("assets/{id}/technical-values")
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
@PUT("assets/{id}/technical-values")
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
internal class TechnicalRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
.build().create(TechnicalApi::class.java)
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
val previous = store.load() ?: error("Sesión no iniciada")
try { return block(previous) }
catch (error: HttpException) { if (error.code() != 401) throw error }
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
return block(current)
}
}
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
val value = text.trim()
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
if (value.isBlank()) return null
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
return when (definition.dataType) {
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
else -> value.takeIf { it.length <= 4000 } ?: invalid()
}
}
@@ -75,6 +75,7 @@ fun FieldFindingScreen(model: MainViewModel) {
var severityText by rememberSaveable(asset.id) { mutableStateOf("") } var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") } var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) } var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) } var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) } var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -110,6 +111,7 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId pendingPhotoFindingId = findingId
takePicture.launch(uri) takePicture.launch(uri)
} }
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
} }
} }
@@ -122,6 +124,7 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = requestedFindingId val findingId = requestedFindingId
requestedFindingId = null requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId) if (camera && location && findingId != null) beginPhoto(findingId)
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
} }
fun requestPhoto(findingId: String) { fun requestPhoto(findingId: String) {
@@ -192,6 +195,7 @@ fun FieldFindingScreen(model: MainViewModel) {
} }
} }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let { model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp)) Text(it, Modifier.padding(12.dp))
@@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
@@ -80,7 +84,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
} }
DhTheme { DhTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
when { when {
model.session == null -> EnhancedLoginScreen(model) { model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true passwordLoginInFlight = true
@@ -106,10 +110,10 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable @Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) { private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") } var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") } var password by remember { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) } var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image( Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact), painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
@@ -203,7 +207,7 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() } LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -60,7 +60,7 @@ fun MobileActsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
} }
@@ -137,7 +137,11 @@ fun MobileActsScreen(
} }
model.acts.forEach { act -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente" val urgency = when (act.urgency) {
"URGENT" -> "URGENTE"
"NON_URGENT" -> "No urgente"
else -> "Urgencia pendiente"
}
OutlinedButton( OutlinedButton(
onClick = { model.selectAct(act.id) }, onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -165,23 +169,8 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
Text("Urgencia", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text( Text(
if (newActUrgency == "URGENT") { "La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(
@@ -189,7 +178,7 @@ fun MobileActsScreen(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Button( Button(
onClick = { model.createAct(newActUrgency) }, onClick = { model.createAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") } ) { Text("Crear nueva Acta") }
@@ -203,7 +192,13 @@ fun MobileActsScreen(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status)) Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente") Text(
when (selected.urgency) {
"URGENT" -> "Urgencia: Urgente"
"NON_URGENT" -> "Urgencia: No urgente"
else -> "Urgencia: se define al cerrar el Acta"
},
)
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) } selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -270,10 +265,20 @@ fun MobileActsScreen(
HorizontalDivider() HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.") Text("Al cerrar el Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora la urgencia según lo constatado.")
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { closingUrgency = "NON_URGENT" },
label = { Text(if (closingUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { closingUrgency = "URGENT" },
label = { Text(if (closingUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") } ) { Text("Finalizar y BLOQUEAR Acta") }
} }
@@ -75,7 +75,7 @@ fun ModernMobileActsScreen(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") } var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") } var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) } var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") } var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
@@ -195,7 +195,7 @@ fun ModernMobileActsScreen(
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold) Text(act.code, fontWeight = FontWeight.Bold)
Text( Text(
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}", "${modernActStatusLabel(act.status)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -225,36 +225,13 @@ fun ModernMobileActsScreen(
} }
} }
Text("Urgencia", fontWeight = FontWeight.SemiBold) Text(
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { "La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
FilterChip( style = MaterialTheme.typography.bodySmall,
selected = newActUrgency == "NON_URGENT", color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { newActUrgency = "NON_URGENT" }, )
label = { Text("No urgente") },
)
FilterChip(
selected = newActUrgency == "URGENT",
onClick = { newActUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Button( Button(
onClick = { model.createAct(newActUrgency) }, onClick = { model.createAct() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -278,7 +255,14 @@ fun ModernMobileActsScreen(
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status)) StatusPill(modernActStatusLabel(selected.status))
} }
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant) Text(
when (selected.urgency) {
"URGENT" -> "Urgencia · Urgente"
"NON_URGENT" -> "Urgencia · No urgente"
else -> "Urgencia · Se define al cerrar el Acta"
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall) Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) } selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") { if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
@@ -356,12 +340,34 @@ fun ModernMobileActsScreen(
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
} }
Text( Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.", "Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia según lo constatado en campo.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = closingUrgency == "NON_URGENT",
onClick = { closingUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = closingUrgency == "URGENT",
onClick = { closingUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Text(
when (closingUrgency) {
"URGENT" -> "El plazo urgente se computará desde el Acta según la política vigente."
"NON_URGENT" -> "El plazo no urgente comenzará con la oficialización GEDO."
else -> "Elegí una opción para habilitar el cierre."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct(closingUrgency) },
enabled = !model.busy && closure?.responsible != null, enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") } ) { Text("Cerrar Acta y dejar pendiente de firma") }
} }
@@ -106,7 +106,6 @@ fun ModernVisitRoot(model: MainViewModel) {
ModernVisitScreen.OVERVIEW -> ModernVisitOverview( ModernVisitScreen.OVERVIEW -> ModernVisitOverview(
model = model, model = model,
onActs = { screenName = ModernVisitScreen.ACTS.name }, onActs = { screenName = ModernVisitScreen.ACTS.name },
onInventory = { screenName = ModernVisitScreen.INVENTORY.name },
) )
ModernVisitScreen.ACTS -> ModernMobileActsScreen( ModernVisitScreen.ACTS -> ModernMobileActsScreen(
model = model, model = model,
@@ -124,7 +123,6 @@ fun ModernVisitRoot(model: MainViewModel) {
private fun ModernVisitOverview( private fun ModernVisitOverview(
model: MainViewModel, model: MainViewModel,
onActs: () -> Unit, onActs: () -> Unit,
onInventory: () -> Unit,
) { ) {
val visit = model.visit ?: return val visit = model.visit ?: return
Column( Column(
@@ -183,7 +181,7 @@ private fun ModernVisitOverview(
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Listo para iniciar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text( Text(
"Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.", "Al iniciar se habilitan Actas y Hallazgos.",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Button( Button(
@@ -217,25 +215,6 @@ private fun ModernVisitOverview(
} }
} }
} }
ElevatedCard(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Icon(Icons.Filled.Factory, null, Modifier.padding(11.dp), tint = MaterialTheme.colorScheme.secondary)
}
Column(Modifier.weight(1f)) {
Text("Inventario de campo", fontWeight = FontWeight.Bold)
Text(
"Buscar existente o cargar Instalación / Subinstalación",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} else if (visit.status == "CLOSED") { } else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") } OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
} }
@@ -329,13 +308,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
model.loadFieldTypes(null) model.loadFieldTypes(null)
} }
fun startInstallation() { fun startInstallation(parent: FieldInventoryItem? = null) {
model.clearSelectedFieldAsset() model.clearSelectedFieldAsset()
modeName = ModernInventoryMode.CREATE_INSTALLATION.name modeName = ModernInventoryMode.CREATE_INSTALLATION.name
parentId = null parentId = parent?.id
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm() resetForm()
model.loadFieldTypes(null) model.loadFieldTypes(parent?.id)
} }
fun startSubinstallation() { fun startSubinstallation() {
@@ -349,6 +328,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
fun chooseParent(item: FieldInventoryItem) { fun chooseParent(item: FieldInventoryItem) {
if (modernItemTypeCode(item) == "yacimiento") {
startInstallation(item)
return
}
keyboard?.hide() keyboard?.hide()
focusManager.clearFocus() focusManager.clearFocus()
parentId = item.id parentId = item.id
@@ -385,8 +368,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion") ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false else -> false
} }
} ?: model.fieldTypes.firstOrNull() }
if (model.fieldTypes.none { it.id == selectedTypeId }) { if (selectedTypeId != preferred?.id) {
selectedTypeId = preferred?.id selectedTypeId = preferred?.id
selectedFamilyId = null selectedFamilyId = null
familySearch = "" familySearch = ""
@@ -435,7 +418,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun createWithLocation() { fun createWithLocation() {
val type = selectedType ?: return val type = selectedType ?: return
val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null val effectiveParent = parentId
scope.launch { scope.launch {
runCatching { currentModernGeo(context) } runCatching { currentModernGeo(context) }
.onSuccess { geo -> .onSuccess { geo ->
@@ -529,7 +512,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.PICK_PARENT -> "Elegir Instalación" ModernInventoryMode.PICK_PARENT -> "Elegir Instalación"
ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación" ModernInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación" ModernInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
ModernInventoryMode.BROWSE -> "Inventario de campo" ModernInventoryMode.BROWSE -> "Ubicación del Hallazgo"
}, },
subtitle = visit.scopeAsset?.name ?: visit.code, subtitle = visit.scopeAsset?.name ?: visit.code,
onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() }, onBack = { if (mode == ModernInventoryMode.BROWSE) onBack() else backToBrowse() },
@@ -544,6 +527,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
if (selectedCapture != null) { if (selectedCapture != null) {
if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) {
TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy)
}
ModernCaptureCard( ModernCaptureCard(
item = selectedCapture.asset, item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured, gps = selectedCapture.capture.creationGpsCaptured,
@@ -735,8 +721,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
} }
} }
if (model.fieldTypes.isEmpty() && !model.busy) { if (selectedType == null && !model.busy) {
ModernLocalError("No hay un tipo habilitado para esta ubicación.") {} ModernLocalError("Elegí un Yacimiento en Inventario y tocá Agregar Instalación. No se puede crear una Instalación directamente dentro de un Área.") {}
OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") }
} }
if (model.fieldTypes.size > 1) { if (model.fieldTypes.size > 1) {
@@ -886,7 +873,7 @@ private fun ModernInventoryBrowse(
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") } val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("yacimiento", "instalacion", "subinstalacion") }
Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) { Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) {
@@ -904,7 +891,7 @@ private fun ModernInventoryBrowse(
OutlinedTextField( OutlinedTextField(
value = search, value = search,
onValueChange = onSearchChange, onValueChange = onSearchChange,
label = { Text("Buscar Inventario") }, label = { Text("Buscar Instalación o Subinstalación") },
placeholder = { Text("Nombre, código o dato técnico") }, placeholder = { Text("Nombre, código o dato técnico") },
leadingIcon = { Icon(Icons.Filled.Search, null) }, leadingIcon = { Icon(Icons.Filled.Search, null) },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
@@ -934,7 +921,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Text(item.name, fontWeight = FontWeight.Bold) Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación") StatusPill(when (typeCode) { "subinstalacion" -> "Subinstalación"; "yacimiento" -> "Yacimiento"; else -> "Instalación" })
} }
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) } item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text( Text(
@@ -946,11 +933,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo") Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo")
} }
if (typeCode == "instalacion") { if (typeCode in setOf("yacimiento", "instalacion")) {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null) Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Agregar Subinstalación") Text(if (typeCode == "yacimiento") "Agregar Instalación" else "Agregar Subinstalación")
} }
} }
} }
@@ -0,0 +1,99 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -0,0 +1,24 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import org.junit.Assert.*
import org.junit.Test
class FieldCoordinatesTest {
@Test fun realGpsIsRoundedToApiPrecision() {
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
}
@Test fun boundsAndZeroRemainValid() {
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
}
@Test fun invalidCoordinatesAreRejected() {
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
catch (_: IllegalArgumentException) { }
}
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
catch (_: IllegalArgumentException) { }
}
}
@@ -38,7 +38,7 @@ class FinalFieldFlowContractTest {
@Test @Test
fun structuralSelectionUsesTheServerTypeCode() { fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }")) assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")")) assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
} }
@Test @Test
@@ -0,0 +1,105 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import java.io.IOException
import kotlinx.coroutines.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class MobileSessionCoordinatorTest {
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
private class Store(var value: StoredSession?) {
var clears = 0
fun load() = value
fun clear() { clears++; value = null }
fun save(r: MobileSessionResponse): StoredSession =
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
}
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
val previous = old(); val store = Store(previous); var calls = 0
val results = coroutineScope {
(1..4).map {
async {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
calls++; delay(20); response()
}
}
}.awaitAll()
}
assertEquals(1, calls)
assertTrue(results.all { it.refreshToken == "r2" })
assertEquals(0, store.clears)
}
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
for (failure in listOf(IOException("offline"), http(500), http(429))) {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
fail("Expected request failure")
} catch (caught: Exception) { assertSame(failure, caught) }
assertEquals(previous, store.value)
assertEquals(0, store.clears)
}
}
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
fail("Expected unauthorized")
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
assertNull(store.value)
assertEquals(1, store.clears)
}
@Test fun cancelledRefreshKeepsSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
fail("Expected cancellation")
} catch (_: CancellationException) { }
assertEquals(previous, store.value)
}
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.clear(); response()
}
fail("Must reject a refresh after logout")
} catch (_: IllegalStateException) { }
assertNull(store.value)
}
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
fail("No refresh allowed for another inspector"); response()
}
fail("Must reject account switch")
} catch (_: IllegalStateException) { }
assertEquals("u2", store.value?.userId)
}
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.value = previous.copy(refreshToken = "new-login")
throw http(401)
}
} catch (_: HttpException) { }
assertEquals("new-login", store.value?.refreshToken)
assertEquals(0, store.clears)
}
}
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.*
import org.junit.Test
class OperationTrackerTest {
@Test fun doubleTapCannotQueueASecondWrite() {
val tracker = OperationTracker()
assertTrue(tracker.begin(mutation = true))
assertFalse(tracker.begin(mutation = true))
tracker.end()
assertFalse(tracker.busy)
assertTrue(tracker.begin(mutation = true))
}
@Test fun BusyRemainsUntilEveryReadCompletes() {
val tracker = OperationTracker()
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
tracker.end(); assertTrue(tracker.busy)
assertFalse(tracker.begin(true))
tracker.end(); assertFalse(tracker.busy)
}
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
val tracker = OperationTracker()
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
tracker.end(); assertFalse(tracker.begin(true))
tracker.end(); assertTrue(tracker.begin(true))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test @Test
fun debugBuildKeepsSeparateApplicationIdentity() { fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(28, BuildConfig.VERSION_CODE) assertEquals(30, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME) assertEquals("0.19.2-debug", BuildConfig.VERSION_NAME)
} }
@Test @Test
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-2",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.29.0-2",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -0,0 +1,407 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length
+ media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 500),
warnings: failedFacets,
};
}
}
@@ -32,6 +32,7 @@ import { InventoryTechnicalValuesService } from './inventory-technical-values.se
import { InventoryFunctionService } from './inventory-function.service'; import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller'; import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service'; import { InventoryBrowserService } from './inventory-browser.service';
@@ -66,6 +67,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
InventoryFunctionService, InventoryFunctionService,
InventoryBrowserService, InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
AssetHistoryService, AssetHistoryService,
@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service'; import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>; type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] { function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>(); const seen = new Set<string>();
const result: T[] = []; const result: T[] = [];
@@ -29,11 +32,22 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService { export class MergedInventoryDossierService {
constructor( constructor(
private readonly assets: AssetsService, private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService, private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService, private readonly functions: InventoryFunctionService,
) {} ) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> { async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord; const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord; const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord; const requested = mergeStatus.requested as LooseRecord;
@@ -44,7 +58,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index); .filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord; const dossier = await this.activity.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord; const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null; const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier }; return { assetId, identity, dossier, functionDossier };
@@ -145,6 +159,9 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length; const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return { return {
asset: { asset: {
@@ -194,6 +211,7 @@ export class MergedInventoryDossierService {
media, media,
versions, versions,
timeline: timeline.slice(0, 1000), timeline: timeline.slice(0, 1000),
warnings,
}; };
} }
} }
@@ -71,8 +71,8 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
observations!: string | null; observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT }) @Column({ name: 'urgency', type: 'varchar', length: 24, nullable: true })
urgency!: InspectionActUrgency; urgency!: InspectionActUrgency | null;
@Column({ name: 'deadline_days', type: 'integer', nullable: true }) @Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null; deadlineDays!: number | null;
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F61ActUrgencyAtLock1790113800000 implements MigrationInterface {
name = 'F61ActUrgencyAtLock1790113800000';
public async up(q: QueryRunner): Promise<void> {
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency DROP DEFAULT`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency DROP NOT NULL`);
await q.query(`UPDATE inspection_acts SET urgency=NULL WHERE status='DRAFT'`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`UPDATE inspection_acts SET urgency='NON_URGENT' WHERE urgency IS NULL`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency SET NOT NULL`);
await q.query(`ALTER TABLE inspection_acts ALTER COLUMN urgency SET DEFAULT 'NON_URGENT'`);
}
}
@@ -3,7 +3,6 @@ import {
ArrayMaxSize, ArrayMaxSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -11,15 +10,11 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class CreateInspectionActDto { export class CreateInspectionActDto {
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt!: string; occurredAt!: string;
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -4,7 +4,6 @@ import {
ArrayMinSize, ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
@@ -12,17 +11,12 @@ import {
MaxLength, MaxLength,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class UpdateInspectionActDto { export class UpdateInspectionActDto {
@IsOptional() @IsOptional()
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
occurredAt?: string; occurredAt?: string;
@IsOptional()
@IsEnum(InspectionActUrgency)
urgency?: InspectionActUrgency;
@IsOptional() @IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString() @IsString()
@@ -74,7 +74,7 @@ export interface InspectionActListItem {
title: string; title: string;
summary: string; summary: string;
observations: string | null; observations: string | null;
urgency: InspectionActUrgency; urgency: InspectionActUrgency | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null; deadlineBasis: InspectionDeadlineBasis | null;
@@ -314,7 +314,7 @@ export class InspectionActsService {
title: dto.title, title: dto.title,
summary: dto.summary, summary: dto.summary,
observations: dto.observations ?? null, observations: dto.observations ?? null,
urgency: dto.urgency, urgency: null,
deadlineDays: null, deadlineDays: null,
deadlineDayType: null, deadlineDayType: null,
deadlineBasis: null, deadlineBasis: null,
@@ -379,7 +379,6 @@ export class InspectionActsService {
await this.assertVisitAssets(manager, visit.id, nextAssetIds); await this.assertVisitAssets(manager, visit.id, nextAssetIds);
const before = await this.loadView(manager, id); const before = await this.loadView(manager, id);
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
if (dto.urgency !== undefined) act.urgency = dto.urgency;
if (dto.title !== undefined) act.title = dto.title; if (dto.title !== undefined) act.title = dto.title;
if (dto.summary !== undefined) act.summary = dto.summary; if (dto.summary !== undefined) act.summary = dto.summary;
if (dto.observations !== undefined) act.observations = dto.observations; if (dto.observations !== undefined) act.observations = dto.observations;
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class PrepareInspectionActDto {
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
}
@@ -17,6 +17,7 @@ import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto';
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
import { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
@@ -52,10 +53,11 @@ export class InspectionClosingController {
@RequirePermissions('inspection_closure.prepare') @RequirePermissions('inspection_closure.prepare')
lock( lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: PrepareInspectionActDto,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
return this.closing.prepare(actId, principal, request); return this.closing.prepare(actId, dto, principal, request);
} }
@Post('signatures/inspector') @Post('signatures/inspector')
@@ -35,6 +35,7 @@ import { assertMobileInspector } from '../inspection-operations/mobile-inspector
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
import { sha256CanonicalJson } from './canonical-json'; import { sha256CanonicalJson } from './canonical-json';
import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import type { PrepareInspectionActDto } from './dto/prepare-inspection-act.dto';
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
@@ -130,7 +131,7 @@ export interface InspectionClosureView {
code: string; code: string;
status: InspectionActStatus; status: InspectionActStatus;
visitId: string; visitId: string;
urgency: InspectionActUrgency; urgency: InspectionActUrgency | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null; deadlineBasis: InspectionDeadlineBasis | null;
@@ -268,6 +269,7 @@ export class InspectionClosingService {
/** Finaliza el contenido: desde este punto el Acta queda inmutable. */ /** Finaliza el contenido: desde este punto el Acta queda inmutable. */
async prepare( async prepare(
actId: string, actId: string,
dto: PrepareInspectionActDto,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionClosureView> { ): Promise<InspectionClosureView> {
@@ -285,16 +287,16 @@ export class InspectionClosingService {
} }
const policy = await this.deadlinePolicy(manager); const policy = await this.deadlinePolicy(manager);
const deadlineDays = act.urgency === InspectionActUrgency.URGENT const deadlineDays = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDays ? policy.urgentDays
: policy.nonUrgentDays; : policy.nonUrgentDays;
const deadlineDayType = act.urgency === InspectionActUrgency.URGENT const deadlineDayType = dto.urgency === InspectionActUrgency.URGENT
? policy.urgentDayType ? policy.urgentDayType
: policy.nonUrgentDayType; : policy.nonUrgentDayType;
const deadlineBasis = act.urgency === InspectionActUrgency.URGENT const deadlineBasis = dto.urgency === InspectionActUrgency.URGENT
? InspectionDeadlineBasis.ACT_DATE ? InspectionDeadlineBasis.ACT_DATE
: InspectionDeadlineBasis.GEDO_DATE; : InspectionDeadlineBasis.GEDO_DATE;
const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT const deadlineBaseAt = dto.urgency === InspectionActUrgency.URGENT
? act.occurredAt ? act.occurredAt
: null; : null;
const deadlineAt = deadlineBaseAt const deadlineAt = deadlineBaseAt
@@ -305,20 +307,22 @@ export class InspectionClosingService {
const [updated] = (await manager.query(` const [updated] = (await manager.query(`
UPDATE inspection_acts UPDATE inspection_acts
SET status='LOCKED', SET status='LOCKED',
deadline_days=$2, urgency=$2,
deadline_day_type=$3, deadline_days=$3,
deadline_basis=$4, deadline_day_type=$4,
deadline_base_at=$5, deadline_basis=$5,
deadline_at=$6, deadline_base_at=$6,
locked_at=$7, deadline_at=$7,
locked_by=$8, locked_at=$8,
locked_by=$9,
current_version=current_version+1, current_version=current_version+1,
updated_by=$8, updated_by=$9,
updated_at=$7 updated_at=$8
WHERE id=$1 WHERE id=$1
RETURNING current_version AS "versionNumber" RETURNING current_version AS "versionNumber"
`, [ `, [
actId, actId,
dto.urgency,
deadlineDays, deadlineDays,
deadlineDayType, deadlineDayType,
deadlineBasis, deadlineBasis,
@@ -1009,7 +1013,7 @@ export class InspectionClosingService {
WHERE act.id=$1 WHERE act.id=$1
`, [actId]) as Array<{ `, [actId]) as Array<{
id: string; code: string; status: InspectionActStatus; visitId: string; id: string; code: string; status: InspectionActStatus; visitId: string;
urgency: InspectionActUrgency; deadlineDays: number | null; urgency: InspectionActUrgency | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null;
lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; lockedSha256: string | null; sealedAt: Date | null; currentVersion: number;
+1 -1
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-1'; export const API_VERSION = '0.29.0-2';
export const API_PHASE = 'F6.1'; export const API_PHASE = 'F6.1';
@@ -20,7 +20,7 @@ test('F4 WEB has an explicit LOCKED and SEALED act contract', () => {
assert.match(f4Api, /lockedAt: string \| null/); assert.match(f4Api, /lockedAt: string \| null/);
assert.match(f4Api, /lockedSha256: string \| null/); assert.match(f4Api, /lockedSha256: string \| null/);
assert.match(f4Api, /sealedAt: string \| null/); assert.match(f4Api, /sealedAt: string \| null/);
assert.match(f4Api, /urgency: InspectionActUrgencyF4/); assert.match(f4Api, /urgency: InspectionActUrgencyF4 \| null/);
assert.match(f4Api, /deadlineAt: string \| null/); assert.match(f4Api, /deadlineAt: string \| null/);
}); });
@@ -35,7 +35,7 @@ test('F4 Android uses only lock and seal endpoints for the active Act lifecycle'
}); });
test('F4 Android ViewModel calls lock and seal directly', () => { test('F4 Android ViewModel calls lock and seal directly', () => {
assert.match(viewModel, /actsRepository\.lock\(actId\)/); assert.match(viewModel, /actsRepository\.lock\(actId, urgency\)/);
assert.match(viewModel, /actsRepository\.seal\(actId\)/); assert.match(viewModel, /actsRepository\.seal\(actId\)/);
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/); assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/); assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
+5 -1
View File
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.1 release', () => { test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1'); assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1'); 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-2');
}); });
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 28/); assert.match(gradle, /versionCode = 30/);
assert.match(gradle, /versionName = "0\.19\.0"/); assert.match(gradle, /versionName = "0\.19\.2"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
@@ -20,7 +20,7 @@ test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are adde
assert.doesNotMatch(dto, /ArrayMinSize\(1\)/); assert.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/); assert.match(service, /if \(assetIds\.length === 0\) return/);
assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/); assert.match(mobile, /assetIds = emptyList\(\)/);
}); });
test('F6.3 every Installation and Subinstallation receives the common field card', () => { test('F6.3 every Installation and Subinstallation receives the common field card', () => {
@@ -35,15 +35,31 @@ test('F6.3 every Installation and Subinstallation receives the common field card
}); });
test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => { test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => {
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt'); const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt'); const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt'); const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/); assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/); assert.doesNotMatch(root, /Text\("Inventario de campo"/);
assert.match(root, /Buscar instalación o subinstalación/); assert.match(acts, /Text\("Agregar Hallazgo"\)/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/); assert.match(acts, /model\.createAct\(\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/); assert.match(acts, /model\.prepareSelectedAct\(closingUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/); assert.match(vm, /fun createAct\(\)/);
assert.match(vm, /fun prepareSelectedAct\(urgency: String\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/); assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
}); });
test('F6.1 Acta urgency is null while drafting and is persisted atomically at lock', () => {
const createDto = source('src/inspection-acts/dto/create-inspection-act.dto.ts');
const prepareDto = source('src/inspection-closing/dto/prepare-inspection-act.dto.ts');
const actsService = source('src/inspection-acts/inspection-acts.service.ts');
const closingService = source('src/inspection-closing/inspection-closing.service.ts');
const migration = source('src/database/migrations/1790113800000-f6-1-act-urgency-at-lock.ts');
assert.doesNotMatch(createDto, /urgency/);
assert.match(prepareDto, /urgency!: InspectionActUrgency/);
assert.match(actsService, /urgency: null/);
assert.match(closingService, /urgency=\$2/);
assert.match(migration, /ALTER COLUMN urgency DROP NOT NULL/);
assert.match(migration, /SET urgency=NULL WHERE status='DRAFT'/);
});
@@ -0,0 +1,47 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { ActivityDossierService } from '../../src/asset-master/activity-dossier.service';
test('F7 Actividad no usa DISTINCT con ORDER BY COALESCE en visitas', () => {
const source = readFileSync('src/asset-master/activity-dossier.service.ts', 'utf8');
assert.doesNotMatch(source, /SELECT\s+DISTINCT\s+visit\.id/i);
assert.match(source, /ORDER BY COALESCE\(visit\.actual_started_at, visit\.planned_start_at, visit\.created_at\) DESC/);
assert.match(source, /EXISTS \(\s*SELECT 1\s*FROM inspection_visit_assets/s);
});
test('F7 una faceta auxiliar defectuosa no derriba todo el expediente', async () => {
const dataSource = {
query: async (sql: string) => {
if (sql.includes('FROM assets\n WHERE id = $1')) {
return [{
id: 'bcba740f-cc84-48b3-8d06-88a9c439e25c',
code: 'YAC-0005',
name: 'Agua Botada',
commonName: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
}];
}
if (sql.includes('inspection_finding_communications')) {
throw new Error('simulated optional facet failure');
}
return [];
},
};
const service = new ActivityDossierService(dataSource as never);
const dossier = await service.dossier('bcba740f-cc84-48b3-8d06-88a9c439e25c') as {
asset: { code: string; name: string };
counters: { inspections: number; findings: number };
warnings: string[];
timeline: unknown[];
};
assert.equal(dossier.asset.code, 'YAC-0005');
assert.equal(dossier.asset.name, 'Agua Botada');
assert.equal(dossier.counters.inspections, 0);
assert.equal(dossier.counters.findings, 0);
assert.deepEqual(dossier.warnings, ['communications']);
assert.deepEqual(dossier.timeline, []);
});
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const mergedDossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const dossierPanel = readFileSync('../web-v2/src/features/assets/AssetDossierPanel.tsx', 'utf8');
const simpleDetail = readFileSync('../web-v2/src/pages/SimpleInventoryDetailPage.tsx', 'utf8');
test('F7 mantiene Actividad disponible para Yacimiento', () => {
assert.match(simpleDetail, /kind === 'YACIMIENTO'/);
assert.match(simpleDetail, /setTab\('activity'\)/);
assert.match(simpleDetail, /AssetDossierPanel assetId=\{asset\.id\}/);
});
test('F7 no hace depender el dossier territorial del subsistema de fusión', () => {
assert.match(mergedDossier, /MERGEABLE_DOSSIER_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(mergedDossier, /if \(!MERGEABLE_DOSSIER_TYPES\.has\(requestedTypeCode\)\)/);
assert.match(mergedDossier, /private readonly activity: ActivityDossierService/);
assert.match(mergedDossier, /return await this\.activity\.dossier\(requestedAssetId\)/);
});
test('F7 la consulta auxiliar de merge nunca bloquea la carga de Actividad', () => {
assert.match(dossierPanel, /getInventoryMergeStatus\(assetId\)\.catch\(\(\) => null\)/);
assert.match(dossierPanel, /getAssetDossier\(assetId\)/);
});
+1 -5
View File
@@ -5,21 +5,18 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto'; import { CancelInspectionActDto } from '../../src/inspection-acts/dto/cancel-inspection-act.dto';
import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-inspection-act.dto'; import { CreateInspectionActDto } from '../../src/inspection-acts/dto/create-inspection-act.dto';
import { InspectionActUrgency } from '../../src/database/entities';
const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; const ASSET_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
test('inspection act DTO accepts urgency, content and selected visit assets', async () => { test('inspection act DTO accepts content and selected visit assets without deciding urgency', async () => {
const dto = plainToInstance(CreateInspectionActDto, { const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z', occurredAt: '2026-08-14T15:30:00.000Z',
urgency: InspectionActUrgency.NON_URGENT,
title: ' Verificación de instalación ', title: ' Verificación de instalación ',
summary: ' Se inspeccionó el activo seleccionado. ', summary: ' Se inspeccionó el activo seleccionado. ',
observations: ' Sin novedades. ', observations: ' Sin novedades. ',
assetIds: [ASSET_ID], assetIds: [ASSET_ID],
}); });
assert.deepEqual(await validate(dto), []); assert.deepEqual(await validate(dto), []);
assert.equal(dto.urgency, InspectionActUrgency.NON_URGENT);
assert.equal(dto.title, 'Verificación de instalación'); assert.equal(dto.title, 'Verificación de instalación');
assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.'); assert.equal(dto.summary, 'Se inspeccionó el activo seleccionado.');
assert.equal(dto.observations, 'Sin novedades.'); assert.equal(dto.observations, 'Sin novedades.');
@@ -28,7 +25,6 @@ test('inspection act DTO accepts urgency, content and selected visit assets', as
test('inspection act DTO rejects duplicate assets', async () => { test('inspection act DTO rejects duplicate assets', async () => {
const dto = plainToInstance(CreateInspectionActDto, { const dto = plainToInstance(CreateInspectionActDto, {
occurredAt: '2026-08-14T15:30:00.000Z', occurredAt: '2026-08-14T15:30:00.000Z',
urgency: InspectionActUrgency.NON_URGENT,
title: 'Acta', title: 'Acta',
summary: 'Contenido', summary: 'Contenido',
assetIds: [ASSET_ID, ASSET_ID], assetIds: [ASSET_ID, ASSET_ID],
@@ -5,6 +5,8 @@ import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto'; import { CreateCompanyOutcomeDto } from '../../src/inspection-closing/dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from '../../src/inspection-closing/dto/create-inspection-signature.dto'; import { CreateInspectionSignatureDto } from '../../src/inspection-closing/dto/create-inspection-signature.dto';
import { PrepareInspectionActDto } from '../../src/inspection-closing/dto/prepare-inspection-act.dto';
import { InspectionActUrgency } from '../../src/database/entities';
import { UpsertInspectionResponsibleDto } from '../../src/inspection-closing/dto/upsert-inspection-responsible.dto'; import { UpsertInspectionResponsibleDto } from '../../src/inspection-closing/dto/upsert-inspection-responsible.dto';
test('D5 present responsible requires identity and position', async () => { test('D5 present responsible requires identity and position', async () => {
@@ -54,3 +56,13 @@ test('D5 signature DTO transforms multipart consent and coordinates', async () =
assert.equal(dto.consentAccepted, true); assert.equal(dto.consentAccepted, true);
assert.equal(dto.latitude, -32.889458); assert.equal(dto.latitude, -32.889458);
}); });
test('F6.1 urgency is selected when the Acta is closed, not when it is created', async () => {
const urgent = plainToInstance(PrepareInspectionActDto, { urgency: InspectionActUrgency.URGENT });
const nonUrgent = plainToInstance(PrepareInspectionActDto, { urgency: InspectionActUrgency.NON_URGENT });
const invalid = plainToInstance(PrepareInspectionActDto, { urgency: 'UNKNOWN' });
assert.deepEqual(await validate(urgent), []);
assert.deepEqual(await validate(nonUrgent), []);
assert.equal((await validate(invalid)).some((error) => error.property === 'urgency'), true);
});
+27 -17
View File
@@ -126,11 +126,20 @@ INSERT INTO auth_sessions(
SQL SQL
docker compose --env-file .env.example up -d api docker compose --env-file .env.example up -d api
# Gitea Actions runs Docker through DinD: the job container's 127.0.0.1 is
# not the Docker daemon host. Probe the API from inside its own container,
# matching the main CI contract instead of relying on the published host port.
api_health() {
docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); if (!r.ok) { console.error(t); process.exit(1); } }).catch(() => process.exit(1))'
}
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null 2>&1 && break if api_health >/dev/null 2>&1; then break; fi
sleep 2 sleep 2
done done
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null api_health >/dev/null
TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE' TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
@@ -164,28 +173,29 @@ CONTEXT="$(docker compose --env-file .env.example exec -T db \
IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT" IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT"
test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID" test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
curl -fsS -H "Authorization: Bearer $TOKEN" \ api_get() {
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \ local request_path="$1"
>/tmp/dhv2-departments.json docker compose --env-file .env.example exec -T \
-e REQUEST_TOKEN="$TOKEN" -e REQUEST_PATH="$request_path" api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}${process.env.REQUEST_PATH}`, {headers:{Authorization:`Bearer ${process.env.REQUEST_TOKEN}`}}).then(async r => { const t=await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(e => { console.error(e); process.exit(1); })'
}
api_get '/api/v3/inspection-visits/planning-context/departments' >/tmp/dhv2-departments.json
grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
curl -fsS -H "Authorization: Bearer $TOKEN" \ api_get "/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" >/tmp/dhv2-areas.json
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
>/tmp/dhv2-areas.json
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
curl -fsS -H "Authorization: Bearer $TOKEN" \ api_get "/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" >/tmp/dhv2-yacimientos.json
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
>/tmp/dhv2-yacimientos.json
grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json
START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')" START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')"
HTTP_CODE="$(curl -sS -o /tmp/dhv2-created.json -w '%{http_code}' \ REQUEST_BODY="{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}"
-X POST \ POST_RESULT="$(docker compose --env-file .env.example exec -T \
-H "Authorization: Bearer $TOKEN" \ -e REQUEST_TOKEN="$TOKEN" -e REQUEST_BODY="$REQUEST_BODY" api \
-H 'Content-Type: application/json' \ node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/inspection-visits`, {method:"POST",headers:{Authorization:`Bearer ${process.env.REQUEST_TOKEN}`,"Content-Type":"application/json"},body:process.env.REQUEST_BODY}).then(async r => process.stdout.write(JSON.stringify({status:r.status,body:await r.text()}))).catch(e => { console.error(e); process.exit(1); })')"
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \ HTTP_CODE="$(printf '%s' "$POST_RESULT" | python -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
http://127.0.0.1:3101/api/v3/inspection-visits)" printf '%s' "$POST_RESULT" | python -c 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' >/tmp/dhv2-created.json
if [ "$HTTP_CODE" != 201 ]; then if [ "$HTTP_CODE" != 201 ]; then
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2 echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
cat /tmp/dhv2-created.json >&2 cat /tmp/dhv2-created.json >&2
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP="/var/www/dhv2.korexlabs.com"
BACKUP_ROOT="/root/DH_V2_BACKUPS"
LOCK="/var/lock/dhv2-deploy.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "Otro deploy de DH V2 ya está en curso. No se realiza ninguna modificación."
exit 0
fi
DEPLOY_REF="${DHV2_DEPLOY_REF:-deploy}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
STAGE="/root/dhv2-gitea-stage-${STAMP}"
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
PHASE="bootstrap"
PREV_SHA=""
TARGET_SHA=""
EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP"
exec > >(tee -a "$LOG") 2>&1
cleanup() {
set +e
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
rm -rf "$STAGE"
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
}
publish_status() {
local rc="${1:-1}"
set +e
local outcome="failure"
[ "$rc" -eq 0 ] && outcome="success"
local current="unknown"
current="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
local status_file log_file status_blob log_blob tree commit
status_file="$(mktemp /tmp/dhv2-status.XXXXXX)"
log_file="$(mktemp /tmp/dhv2-log.XXXXXX)"
{
echo "status=$outcome"
echo "exit_code=$rc"
echo "phase=$PHASE"
echo "timestamp=$(date --iso-8601=seconds)"
echo "deploy_ref=$DEPLOY_REF"
echo "previous_sha=${PREV_SHA:-unknown}"
echo "target_sha=${TARGET_SHA:-unknown}"
echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}"
} > "$status_file"
tail -n 500 "$LOG" > "$log_file" 2>/dev/null || true
status_blob="$(git hash-object -w "$status_file" 2>/dev/null || true)"
log_blob="$(git hash-object -w "$log_file" 2>/dev/null || true)"
if [ -n "$status_blob" ] && [ -n "$log_blob" ]; then
tree="$(printf '100644 blob %s\tdeploy.log\n100644 blob %s\tstatus.txt\n' "$log_blob" "$status_blob" | git mktree 2>/dev/null || true)"
if [ -n "$tree" ]; then
commit="$(printf 'deploy-status: %s · phase %s\n' "$outcome" "$PHASE" | git -c user.name='DH V2 Deploy Bot' -c user.email='deploy@dhv2.local' commit-tree "$tree" 2>/dev/null || true)"
[ -z "$commit" ] || git push --force origin "$commit:refs/heads/deploy-status" >/dev/null 2>&1 || true
fi
fi
rm -f "$status_file" "$log_file"
}
on_exit() {
local rc=$?
trap - EXIT ERR
cleanup
publish_status "$rc"
exit "$rc"
}
trap on_exit EXIT
rollback() {
local rc=$?
trap - ERR
PHASE="rollback"
echo
echo "============================================================"
echo " DH V2 · DEPLOY FALLÓ · ROLLBACK"
echo "============================================================"
cd "$APP"
if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi
echo
echo "Estado actual:"
docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo
if [ -d "$BACKUP" ]; then
echo "Backup PRE disponible en: $BACKUP"
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
else
echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc"
}
trap rollback ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY DESDE GITEA · $DEPLOY_REF"
echo "============================================================"
for cmd in git docker curl tar node; do
command -v "$cmd" >/dev/null || { echo "ERROR: falta $cmd"; false; }
done
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
ORIGIN_URL="$(git remote get-url origin)"
EXPECTED_ORIGIN="https://git.korexlabs.com.ar/admin/dh-inspeccion-v2.git"
if [ "$ORIGIN_URL" != "$EXPECTED_ORIGIN" ]; then
echo "ERROR: origin no apunta al Gitea autorizado."
echo "Actual: $ORIGIN_URL"
echo "Esperado: $EXPECTED_ORIGIN"
false
fi
git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción."
git status --short
false
fi
PREV_SHA="$(git rev-parse HEAD)"
PHASE="fetch"
git fetch origin "$DEPLOY_REF"
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
echo "Actual: $PREV_SHA"
echo "Objetivo: $TARGET_SHA"
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
echo "Producción ya está en el commit autorizado."
PHASE="complete"
exit 0
fi
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
false
fi
PHASE="candidate-preflight"
rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
EXPECTED_WEB_VERSION="$(node -p "require('$STAGE/web-v2/package.json').version")"
echo "API candidata: $EXPECTED_API_VERSION"
echo "WEB candidata: $EXPECTED_WEB_VERSION"
docker compose --env-file "$APP/.env" -f "$STAGE/docker-compose.yml" config >/dev/null
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
echo
echo "========== TEST API CANDIDATA =========="
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
docker run --rm \
-v "$STAGE/api-v3/test:/app/test:ro" \
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
-v "$STAGE/web-v2:/web-v2:ro" \
-v "$STAGE/android-app:/android-app:ro" \
"$API_TEST_IMAGE" npm test </dev/null
echo
echo "========== BUILD WEB CANDIDATA =========="
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
PHASE="backup"
echo
echo "========== BACKUP PRE =========="
install -d -m 700 "$BACKUP"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-before.dump"
tar \
--exclude='./.git' \
--exclude='./.env' \
--exclude='*/node_modules' \
--exclude='*/dist' \
--exclude='*.zip' \
--exclude='*.tar.gz' \
--exclude='*.tgz' \
-czf "$BACKUP/source-before.tar.gz" .
install -m 600 .env "$BACKUP/.env"
git rev-parse HEAD > "$BACKUP/previous.sha"
printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha"
docker compose ps -a > "$BACKUP/docker-before.txt"
(
cd "$BACKUP"
sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="fast-forward"
echo
echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build"
echo
echo "========== BUILD PRODUCCIÓN =========="
docker compose build api migrate web </dev/null
PHASE="migrations"
echo
echo "========== MIGRACIONES =========="
docker compose --profile tools run --rm migrate </dev/null
docker compose --profile tools run --rm migrate npm run migration:show </dev/null | tee "$BACKUP/migrations.txt"
grep -Fq 'Pending migrations: no' "$BACKUP/migrations.txt"
PHASE="recreate"
echo
echo "========== RECREATE API + WEB =========="
docker compose up -d --no-deps --force-recreate api web </dev/null
PHASE="health"
echo
echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
cat "$BACKUP/health.json"
echo
grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json"
grep -Fq '"database":"ok"' "$BACKUP/health.json"
WEB_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8182/)"
[ "$WEB_CODE" = "200" ] || { echo "ERROR: WEB HTTP $WEB_CODE"; false; }
PHASE="verify"
echo
echo "========== VERIFICACIÓN FINAL =========="
docker compose ps -a | tee "$BACKUP/docker-after.txt"
if docker compose ps --status running --services | grep -Fxq api && docker compose ps --status running --services | grep -Fxq web && docker compose ps --status running --services | grep -Fxq db; then
echo "Servicios críticos: OK"
else
echo "ERROR: falta un servicio crítico en ejecución."
false
fi
PHASE="post-backup"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-after.dump"
git rev-parse HEAD > "$BACKUP/deployed.sha"
printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt"
(
cd "$BACKUP"
sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="complete"
trap - ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY OK"
echo "============================================================"
echo "Commit: $TARGET_SHA"
echo "API: $EXPECTED_API_VERSION"
echo "WEB: $EXPECTED_WEB_VERSION"
echo "Backup: $BACKUP"
echo "============================================================"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.23.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.23.0-2",
"dependencies": { "dependencies": {
"maplibre-gl": "6.4.1", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.23.0-1", "version": "0.23.0-2",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.23.0-1'; export const APP_VERSION = '0.23.0-2';
export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado'; export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado';
@@ -81,10 +81,12 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
const load = () => { const load = () => {
setLoading(true); setLoading(true);
setError(''); setError('');
setMergeStatus(null);
Promise.all([ Promise.all([
getAssetDossier(assetId), getAssetDossier(assetId),
getAsset(assetId), getAsset(assetId),
getInventoryMergeStatus(assetId), // La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario.
getInventoryMergeStatus(assetId).catch(() => null),
]) ])
.then(([loadedDossier, loadedAsset, loadedMerge]) => { .then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier); setDossier(loadedDossier as ExtendedDossier);
@@ -90,7 +90,7 @@ export function InspectionClosurePanel({ act }: { act: InspectionActF4 }) {
</div> </div>
<div className="responsible-summary"> <div className="responsible-summary">
<div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}</strong></div> <div><small>Urgencia</small><strong>{closure.act.urgency === 'URGENT' ? 'Urgente' : closure.act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div> <div><small>Plazo configurado</small><strong>{closure.act.deadlineDays ? `${closure.act.deadlineDays} días ${closure.act.deadlineDayType === 'BUSINESS' ? 'hábiles' : 'corridos'}` : 'Pendiente'}</strong></div>
<div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div> <div><small>Inicio del plazo</small><strong>{closure.act.deadlineBaseAt ? formatDate(closure.act.deadlineBaseAt) : 'Pendiente de evento válido'}</strong></div>
<div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div> <div><small>Vencimiento</small><strong>{closure.act.deadlineAt ? formatDate(closure.act.deadlineAt) : 'Todavía no iniciado'}</strong></div>
+2 -2
View File
@@ -53,7 +53,7 @@ export interface InspectionActListItemF4 {
title: string; title: string;
summary: string; summary: string;
observations: string | null; observations: string | null;
urgency: InspectionActUrgencyF4; urgency: InspectionActUrgencyF4 | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayTypeF4 | null; deadlineDayType: InspectionDeadlineDayTypeF4 | null;
deadlineBasis: InspectionDeadlineBasisF4 | null; deadlineBasis: InspectionDeadlineBasisF4 | null;
@@ -107,7 +107,7 @@ export interface InspectionClosureF4 {
code: string; code: string;
status: InspectionActStatusF4; status: InspectionActStatusF4;
visitId: string; visitId: string;
urgency: InspectionActUrgencyF4; urgency: InspectionActUrgencyF4 | null;
deadlineDays: number | null; deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayTypeF4 | null; deadlineDayType: InspectionDeadlineDayTypeF4 | null;
deadlineBasis: InspectionDeadlineBasisF4 | null; deadlineBasis: InspectionDeadlineBasisF4 | null;
@@ -245,8 +245,8 @@ export function AuthoritativeInventoryConfigPage() {
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}> {canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3> <h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label> <label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}> {level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)} {installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option"><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} /><span>{family.name}</span></label>)}
</div></div>} </div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button> <button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>} </div>}
@@ -265,10 +265,10 @@ export function AuthoritativeInventoryConfigPage() {
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}> {selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong> <strong>Puede estar dentro de:</strong>
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}> <div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}> {installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option">
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} /> <input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
{family.name} <span>{family.name}</span>
</label>)} </label>)}
</div> </div>
</div>} </div>}
+1 -1
View File
@@ -63,7 +63,7 @@ export function InspectionActEditorPage() {
<div className="responsible-summary"> <div className="responsible-summary">
<div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div> <div><small>Fecha y hora</small><strong>{formatDate(act.occurredAt)}</strong></div>
<div><small>Título</small><strong>{act.title}</strong></div> <div><small>Título</small><strong>{act.title}</strong></div>
<div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : 'No urgente'}</strong></div> <div><small>Urgencia</small><strong>{act.urgency === 'URGENT' ? 'Urgente' : act.urgency === 'NON_URGENT' ? 'No urgente' : 'Pendiente de cierre'}</strong></div>
<div><small>Hallazgos</small><strong>{act.findingCount}</strong></div> <div><small>Hallazgos</small><strong>{act.findingCount}</strong></div>
</div> </div>
<div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div> <div className="closure-section"><span className="eyebrow">DESCRIPCIÓN DE LO ACTUADO</span><p>{act.summary}</p>{act.observations && <><span className="eyebrow">OBSERVACIONES</span><p>{act.observations}</p></>}</div>
+52
View File
@@ -1296,3 +1296,55 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
/* D5.6.4 · combobox buscable global */ /* D5.6.4 · combobox buscable global */
.searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px} .searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px}
.inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}} .inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}}
/* Parent choices need fixed-size controls even inside a generic form field. */
.inventory-parent-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 8px;
margin: 10px 0 14px;
}
.inventory-parent-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
}
.inventory-parent-option input[type="checkbox"] {
flex: 0 0 17px;
width: 17px;
height: 17px;
min-height: 17px;
padding: 0;
margin: 0;
accent-color: var(--blue);
cursor: inherit;
}
.inventory-parent-option > span {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.inventory-parent-option:has(input:checked) {
border-color: #9db9ed;
background: #eef4ff;
}
.inventory-parent-option:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.inventory-parent-option:has(input:disabled) {
cursor: default;
opacity: .65;
}