Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4f73f16f | ||
|
|
89219b1cbe | ||
|
|
acdf9c8ccd | ||
|
|
726768bb78 | ||
|
|
01bd7c8454 | ||
|
|
6363dcd8cc | ||
|
|
007033e48f | ||
|
|
551ae4c44b | ||
|
|
4f0c367d3e | ||
|
|
ebce71653f | ||
|
|
97a4da535a | ||
|
|
006f9513d0 | ||
|
|
7fa2ff03fa | ||
|
|
5ebff1cd19 | ||
|
|
fa20c60a94 | ||
|
|
9ace5b8e46 | ||
|
|
752bd4d6db | ||
|
|
102b9f4d2f | ||
|
|
10ceedae11 | ||
|
|
0fbb37c153 | ||
|
|
310fd87aeb |
@@ -1,5 +1,5 @@
|
|||||||
name: Android CI / RC
|
name: Android CI / RC
|
||||||
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile.
|
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -43,14 +43,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Android SDK
|
- name: Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
|
||||||
packages: platform-tools
|
|
||||||
|
|
||||||
- name: Android API 36
|
- name: Android API 36
|
||||||
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
|
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
|
||||||
|
|
||||||
- name: Gradle 8.13
|
- name: Gradle 8.13
|
||||||
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
|
uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
with:
|
||||||
gradle-version: '8.13'
|
gradle-version: '8.13'
|
||||||
|
|
||||||
@@ -65,7 +63,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Android lint
|
- name: Android lint
|
||||||
working-directory: android-app
|
working-directory: android-app
|
||||||
run: gradle --no-daemon :app:lintDebug :app:lintRelease
|
run: gradle --no-daemon :app:lintDebug
|
||||||
|
|
||||||
- name: Print complete lint failures
|
- name: Print complete lint failures
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -111,7 +109,7 @@ jobs:
|
|||||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||||
sha256sum "$apk" > "${apk}.sha256"
|
sha256sum "$apk" > "${apk}.sha256"
|
||||||
{
|
{
|
||||||
echo "phase=F6.8"
|
echo "phase=F6.1"
|
||||||
echo "version=$version"
|
echo "version=$version"
|
||||||
echo "versionCode=$code"
|
echo "versionCode=$code"
|
||||||
echo "commit=$GITHUB_SHA"
|
echo "commit=$GITHUB_SHA"
|
||||||
@@ -132,14 +130,6 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload unsigned release candidate
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
|
|
||||||
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Upload Android diagnostics
|
- name: Upload Android diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
- 'feature/**'
|
|
||||||
- 'fix/**'
|
|
||||||
- 'chore/**'
|
|
||||||
- 'release/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -58,10 +52,9 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
contract:
|
contract:
|
||||||
name: Docker / migrations / production images
|
name: Docker / scripts contract
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [api, web]
|
needs: [api, web]
|
||||||
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Validate shell scripts
|
- name: Validate shell scripts
|
||||||
@@ -316,14 +309,9 @@ jobs:
|
|||||||
docker compose --env-file .env.example build api
|
docker compose --env-file .env.example build api
|
||||||
docker compose --env-file .env.example up -d api
|
docker compose --env-file .env.example up -d api
|
||||||
|
|
||||||
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
|
|
||||||
# is not the Docker daemon host. Probe the production API from
|
|
||||||
# inside its own container so this barrier works on GitHub and Gitea.
|
|
||||||
api_ready=0
|
api_ready=0
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
if docker compose --env-file .env.example exec -T api \
|
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
|
||||||
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
|
|
||||||
>/tmp/dhv2-health.json 2>/dev/null; then
|
|
||||||
api_ready=1
|
api_ready=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -354,22 +342,3 @@ jobs:
|
|||||||
docker image rm "$image" >/dev/null 2>&1 || true
|
docker image rm "$image" >/dev/null 2>&1 || true
|
||||||
- name: Build production images
|
- name: Build production images
|
||||||
run: docker compose --env-file .env.example build api migrate web
|
run: docker compose --env-file .env.example build api migrate web
|
||||||
|
|
||||||
promote-deploy:
|
|
||||||
name: Promote verified main to deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [api, web, contract]
|
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Fast-forward deploy to the verified commit
|
|
||||||
run: |
|
|
||||||
set -Eeuo pipefail
|
|
||||||
git fetch origin deploy main
|
|
||||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
|
||||||
git merge-base --is-ancestor origin/deploy "$GITHUB_SHA"
|
|
||||||
git push origin "$GITHUB_SHA:refs/heads/deploy"
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# Checklist final Android 0.19.3
|
|
||||||
|
|
||||||
La candidata sólo se promueve si el mismo SHA cumple:
|
|
||||||
|
|
||||||
- iniciar Inspección;
|
|
||||||
- crear/abrir Acta sin pedir urgencia ni Inventario;
|
|
||||||
- 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;
|
|
||||||
- registrar Hallazgos sobre el elemento seleccionado;
|
|
||||||
- al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
|
|
||||||
- exigir por cada Acta los datos del **Representante de la empresa**: nombres y apellidos, DNI, cargo/función y email;
|
|
||||||
- permitir precargar esos datos desde el Acta anterior de la misma Inspección, pero exigir confirmación y firma nueva en cada Acta;
|
|
||||||
- resolver por Acta **firma en conformidad**, **firma en disconformidad con motivo** o **negativa a firmar con motivo**;
|
|
||||||
- verificar que la urgencia existe sólo a nivel **Acta** y nunca a nivel Hallazgo;
|
|
||||||
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
|
|
||||||
- mostrar el Acta como **Firmada y cerrada**;
|
|
||||||
- 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;
|
|
||||||
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
|
|
||||||
+44
-123
@@ -1,108 +1,58 @@
|
|||||||
# DH Inspección Android · release de campo 0.19.11
|
# DH Inspección Android · release de campo 0.19.0
|
||||||
|
|
||||||
## Candidata vigente
|
## Candidata vigente
|
||||||
|
|
||||||
- `versionName`: **0.19.11**; `versionCode`: **39**.
|
- Fase funcional: **Campo final / Inspección → Acta → Inventario → Hallazgos → Firma**.
|
||||||
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6.
|
- `versionName`: **0.19.0**.
|
||||||
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
|
- `versionCode`: **28**.
|
||||||
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
|
|
||||||
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
|
|
||||||
|
|
||||||
## Referencia de 0.19.10
|
|
||||||
|
|
||||||
### Candidata anterior
|
|
||||||
|
|
||||||
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
|
|
||||||
- `versionName`: **0.19.10**.
|
|
||||||
- `versionCode`: **38**.
|
|
||||||
- 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-8 / WEB 0.23.0-5**.
|
|
||||||
|
|
||||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||||
|
|
||||||
### Ajustes 0.19.10
|
## Procedimiento de campo validado
|
||||||
|
|
||||||
- **Trabajo offline seguro:** Room conserva operaciones pendientes y WorkManager las sincroniza cuando vuelve la red, respetando el orden en que fueron creadas.
|
1. La inspección se **planifica y asigna desde el tablero web**. La APK no crea inspecciones nuevas.
|
||||||
- La cola offline queda separada por usuario autenticado para impedir que una sesión sincronice trabajo de otra persona en el mismo dispositivo.
|
2. En la tablet el inspector abre una inspección asignada y, al llegar al lugar, pulsa **Iniciar inspección**.
|
||||||
- Inspecciones, Actas, Inventario, Hallazgos, fotos, representante, manifestación y cierre conservan una identidad de cliente estable para reintentos idempotentes.
|
3. Con la inspección en curso se crea o selecciona el **Acta** de trabajo. Sólo puede existir un Acta en borrador a la vez; una inspección puede contener varias Actas sucesivas.
|
||||||
- Las listas de trabajo, Inventario, Actas, cierres, Hallazgos y estado de firma usan caché local por usuario para poder consultar contexto ya descargado sin conectividad.
|
4. Los **Hallazgos** se registran exclusivamente sobre **Instalaciones o Subinstalaciones**.
|
||||||
- Antes de incorporar una foto de Hallazgo se muestra **Revisar fotografía** con **Usar esta foto**, **Volver a tomar** y **Eliminar foto**.
|
5. Si el elemento ya existe, se busca y selecciona desde Inventario de campo.
|
||||||
- Corrige el cierre que enviaba `uploadMode=ONLINE`; el contrato vigente usa `IMMEDIATE` o `DEFERRED`.
|
6. Si no existe, se da de alta desde la APK siguiendo la misma estructura que el tablero:
|
||||||
- La APK consulta el estado real de la firma reutilizable del Inspector y no afirma que existe cuando todavía no fue configurada.
|
- Instalación dentro del Yacimiento de la inspección;
|
||||||
- El Dashboard de Actas prioriza el **PDF real del Acta** y muestra directamente las fotografías de Hallazgos y del Inventario relacionado.
|
- Subinstalación dentro de una Instalación seleccionada;
|
||||||
- En toda la interfaz activa, la antigua expresión “clasificación técnica” se presenta como **Tipo de instalación** o **Tipo de subinstalación**.
|
- nombre técnico / identificación obligatorio;
|
||||||
|
- Código DH de campo autogenerado por el servidor;
|
||||||
|
- nombre habitual opcional;
|
||||||
|
- descripción y observaciones de alta opcionales;
|
||||||
|
- clasificación técnica obligatoria cuando el tipo la requiera;
|
||||||
|
- atributos técnicos dinámicos obligatorios según la configuración del tipo;
|
||||||
|
- Área y Operadora heredadas de la inspección planificada;
|
||||||
|
- ubicación del dispositivo y fotografía obligatorias antes de habilitar Hallazgos.
|
||||||
|
7. Sobre el elemento se selecciona un Hallazgo del catálogo contextual o **OTROS** cuando corresponda; se registra descripción, gravedad y evidencia fotográfica si aplica.
|
||||||
|
8. Al terminar el contenido se pulsa **Cerrar Acta y dejar pendiente de firma**. El contenido y los Hallazgos quedan inmutables y el estado visible pasa a **Pendiente de firma**.
|
||||||
|
9. Se registran las firmas y la manifestación de la empresa. La ausencia por sí sola no modifica el contenido cerrado: el Acta permanece pendiente hasta resolver el circuito de firma/manifestación conforme a la regla vigente.
|
||||||
|
10. Una vez resuelta la firma, el Acta queda **Firmada e inmutable**. La inspección sólo puede cerrarse cuando todas sus Actas no canceladas estén finalizadas.
|
||||||
|
|
||||||
- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED.
|
## Castellano y presentación
|
||||||
- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante.
|
|
||||||
- La firma del Inspector se guarda una vez en Mi perfil y se copia de forma inmutable al sellar cada Acta.
|
|
||||||
- Elimina la tarjeta duplicada del Acta seleccionada y simplifica la jerarquía visual inspirada en la referencia Figma.
|
|
||||||
|
|
||||||
- El encabezado de **Mis inspecciones** separa las acciones para que **Salir** nunca se comprima ni se corte en pantallas angostas.
|
La navegación operativa de la APK se presenta en castellano. Los valores técnicos que la API conserva internamente (`DRAFT`, `LOCKED`, `SEALED`, `PLANNED`, etc.) no se muestran al inspector; se traducen a **Borrador**, **Pendiente de firma**, **Firmada**, **Planificada**, **En curso**, etc.
|
||||||
- El alta de Hallazgos corrige la captura de versión que podía transformar el número en `NaN` después del INSERT y devolver error 500.
|
|
||||||
- Un Hallazgo admite **múltiples fotografías con GPS**; después de cada captura queda disponible **Agregar otra foto con GPS** y se muestra el contador.
|
|
||||||
|
|
||||||
## Procedimiento operativo validado
|
También se incorporó la marca oficial de Mendoza provista para esta release en las pantallas de acceso y desbloqueo biométrico.
|
||||||
|
|
||||||
La APK 0.19.10 fija como recorrido principal de campo:
|
## Integridad del alta de Inventario
|
||||||
|
|
||||||
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.
|
El alta móvil no duplica los formularios administrativos del tablero. Conserva los datos necesarios para que el registro tenga identidad y trazabilidad en campo, mientras que los datos administrativos que ya vienen determinados por la inspección (Área y Operadora) se heredan automáticamente.
|
||||||
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**. 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.
|
|
||||||
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.
|
|
||||||
7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente.
|
|
||||||
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar contenido y pasar a firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Para firmar**.
|
|
||||||
9. En **Para firmar**, el acompañante de la empresa registra su manifestación: **conformidad**, **disconformidad con motivo obligatorio**, **negativa a firmar con motivo obligatorio** o **ausencia documentada**. La firma del Inspector no se dibuja en la tablet: se guarda una sola vez desde **Mi perfil** del Dashboard y el servidor copia esa firma de forma inmutable a cada Acta al usar **Aplicar mi firma y cerrar Acta**.
|
|
||||||
10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
|
|
||||||
|
|
||||||
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
|
El servidor continúa siendo la autoridad para:
|
||||||
|
|
||||||
## Alta de Instalaciones y Subinstalaciones
|
- generar el Código DH `CAM-*` cuando el alta nace en campo;
|
||||||
|
- validar la jerarquía Yacimiento → Instalación → Subinstalación;
|
||||||
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
|
- exigir la familia/clasificación técnica compatible;
|
||||||
|
- validar atributos dinámicos configurados en el tablero;
|
||||||
### Instalación
|
- registrar ubicación de creación;
|
||||||
|
- impedir crear Hallazgos sobre un alta de campo que todavía no tenga ubicación y fotografía;
|
||||||
- contexto territorial: Yacimiento/Área de la Inspección;
|
- conservar la trazabilidad si el alta provisional se fusiona posteriormente con un registro existente.
|
||||||
- clasificación técnica compatible;
|
|
||||||
- **Nombre técnico** obligatorio;
|
|
||||||
- Nombre habitual opcional;
|
|
||||||
- Descripción opcional;
|
|
||||||
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
|
|
||||||
- código DH generado por el sistema cuando corresponde;
|
|
||||||
- GPS de la tablet;
|
|
||||||
- fotografía obligatoria antes de crear Hallazgos.
|
|
||||||
|
|
||||||
### Subinstalación
|
|
||||||
|
|
||||||
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
|
|
||||||
|
|
||||||
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
|
|
||||||
|
|
||||||
## Alta de Hallazgos
|
|
||||||
|
|
||||||
El alta de Hallazgos usa un selector desplegable con búsqueda por nombre. La pantalla muestra sólo la información necesaria para decidir, mantiene **Otro / No está en la lista** como salida manual y evita exponer códigos o leyendas internas del catálogo. La fecha de corrección no se solicita al inspector al crear un Hallazgo; los plazos administrativos se gestionan fuera de este alta de campo. Los campos de carga son compactos y el teclado avanza con **Siguiente** entre ellos.
|
|
||||||
|
|
||||||
## Actas y firma
|
|
||||||
|
|
||||||
La terminología visible se simplifica:
|
|
||||||
|
|
||||||
- `DRAFT` → **En elaboración**;
|
|
||||||
- `LOCKED` → **Pendiente de firma**;
|
|
||||||
- `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. 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.
|
|
||||||
|
|
||||||
## Castellano e identidad visual
|
|
||||||
|
|
||||||
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
|
|
||||||
|
|
||||||
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
|
|
||||||
|
|
||||||
## Barrera obligatoria
|
## Barrera obligatoria
|
||||||
|
|
||||||
@@ -110,16 +60,16 @@ Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `And
|
|||||||
|
|
||||||
1. validación de identidad, HTTPS y políticas básicas del manifest;
|
1. validación de identidad, HTTPS y políticas básicas del manifest;
|
||||||
2. Android lint;
|
2. Android lint;
|
||||||
3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
|
3. unit tests Android reales, incluyendo contratos del procedimiento final;
|
||||||
4. `assembleDebug`;
|
4. `assembleDebug`;
|
||||||
5. `assembleRelease` para comprobar que la variante productiva compile;
|
5. `assembleRelease` para comprobar que la variante productiva compile;
|
||||||
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
|
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
|
||||||
|
|
||||||
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
|
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de incorporar la release a `main`.
|
||||||
|
|
||||||
## Firma release
|
## Firma release
|
||||||
|
|
||||||
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
|
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
|
||||||
|
|
||||||
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
|
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
|
||||||
|
|
||||||
@@ -128,36 +78,7 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí
|
|||||||
Antes de distribuir una APK productiva:
|
Antes de distribuir una APK productiva:
|
||||||
|
|
||||||
- todas las barreras de CI del SHA exacto deben estar verdes;
|
- todas las barreras de CI del SHA exacto deben estar verdes;
|
||||||
- comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva;
|
- comprobar certificado/huella de firma contra la versión histórica;
|
||||||
- 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;
|
||||||
- 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 contra producción: ingreso, abrir inspección asignada, iniciar inspección, crear Acta, buscar Instalación/Subinstalación, alta de campo, ubicación/foto, Hallazgo, cerrar Acta a pendiente de firma, firma/finalización 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.3 · 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í.
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# DH Android 0.19.1 · revisión de candidata
|
|
||||||
|
|
||||||
## Idea funcional verificada contra código y API
|
|
||||||
|
|
||||||
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
|
|
||||||
|
|
||||||
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
|
|
||||||
|
|
||||||
## Hallazgos corregidos
|
|
||||||
|
|
||||||
| Problema observado | Corrección |
|
|
||||||
| --- | --- |
|
|
||||||
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
|
|
||||||
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
|
|
||||||
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
|
|
||||||
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
|
|
||||||
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
|
|
||||||
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
|
|
||||||
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
|
|
||||||
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
|
|
||||||
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
|
|
||||||
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
|
|
||||||
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
|
|
||||||
|
|
||||||
## Evidencia automática
|
|
||||||
|
|
||||||
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
|
|
||||||
|
|
||||||
## Límites de esta candidata
|
|
||||||
|
|
||||||
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
|
|
||||||
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
|
|
||||||
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
|
|
||||||
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
|
|
||||||
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
|
|
||||||
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
|
|
||||||
|
|
||||||
## Recorrido de aceptación en tablet
|
|
||||||
|
|
||||||
1. Ingresar con inspector y cargar inspecciones asignadas.
|
|
||||||
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
|
|
||||||
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
|
|
||||||
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
|
|
||||||
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
|
|
||||||
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
|
|
||||||
7. Registrar firmas o negativa de empresa; sellar Acta.
|
|
||||||
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
|
|
||||||
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
|
|
||||||
10. Verificar en Dashboard los mismos registros y documentos.
|
|
||||||
@@ -2,20 +2,18 @@ plugins {
|
|||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("org.jetbrains.kotlin.kapt")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.korexlabs.dhinspeccion"
|
namespace = "com.korexlabs.dhinspeccion"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
buildToolsVersion = "36.0.0"
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.korexlabs.dhinspeccion"
|
applicationId = "com.korexlabs.dhinspeccion"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 39
|
versionCode = 28
|
||||||
versionName = "0.19.11"
|
versionName = "0.19.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
@@ -75,10 +73,6 @@ dependencies {
|
|||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("com.google.android.gms:play-services-location:21.3.0")
|
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||||
implementation("androidx.exifinterface:exifinterface:1.4.1")
|
implementation("androidx.exifinterface:exifinterface:1.4.1")
|
||||||
implementation("androidx.room:room-runtime:2.7.2")
|
|
||||||
implementation("androidx.room:room-ktx:2.7.2")
|
|
||||||
kapt("androidx.room:room-compiler:2.7.2")
|
|
||||||
implementation("androidx.work:work-runtime-ktx:2.10.3")
|
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.korexlabs.dhinspeccion.data.AssetSummary
|
|
||||||
import com.korexlabs.dhinspeccion.data.FieldCoordinates
|
|
||||||
import com.korexlabs.dhinspeccion.data.CaptureStatus
|
|
||||||
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
|
||||||
@@ -20,29 +17,21 @@ import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
|||||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||||
import com.korexlabs.dhinspeccion.data.FieldType
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActClosure
|
import com.korexlabs.dhinspeccion.data.MobileActClosure
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActDetail
|
import com.korexlabs.dhinspeccion.data.MobileActDetail
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActSummary
|
import com.korexlabs.dhinspeccion.data.MobileActSummary
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActsRepository
|
import com.korexlabs.dhinspeccion.data.MobileActsRepository
|
||||||
import com.korexlabs.dhinspeccion.data.MobileVisitClosureHeader
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobileResponsible
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
|
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
|
||||||
import com.korexlabs.dhinspeccion.data.offline.OfflineMutationQueue
|
|
||||||
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)
|
||||||
private val offlineQueue = OfflineMutationQueue(application)
|
|
||||||
|
|
||||||
var session: StoredSession? by mutableStateOf(repository.currentSession())
|
var session: StoredSession? by mutableStateOf(repository.currentSession())
|
||||||
private set
|
private set
|
||||||
@@ -52,14 +41,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private set
|
private set
|
||||||
var notice: String? by mutableStateOf(null)
|
var notice: String? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
var pendingSyncCount by mutableStateOf(0)
|
|
||||||
private set
|
|
||||||
var inspectorSignatureConfigured: Boolean? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
var selectedActCompanySignaturePending by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
var selectedActSealPending by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var visits: List<VisitSummary> by mutableStateOf(emptyList())
|
var visits: List<VisitSummary> by mutableStateOf(emptyList())
|
||||||
private set
|
private set
|
||||||
@@ -90,8 +71,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
OfflineMutationQueue.schedule(application)
|
|
||||||
viewModelScope.launch { refreshOfflineStateInternal() }
|
|
||||||
if (session != null) loadVisits()
|
if (session != null) loadVisits()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +84,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
error = "Ingresá usuario y contraseña."
|
error = "Ingresá usuario y contraseña."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
session = repository.login(identifier, password)
|
session = repository.login(identifier, password)
|
||||||
notice = "Sesión iniciada."
|
notice = "Sesión iniciada."
|
||||||
loadVisitsInternal()
|
loadVisitsInternal()
|
||||||
@@ -134,9 +113,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openVisit(id: String) = launchBusy {
|
fun openVisit(id: String) = launchBusy {
|
||||||
val loadedVisit = repository.visit(id)
|
visit = repository.visit(id)
|
||||||
clearActState()
|
|
||||||
visit = loadedVisit
|
|
||||||
inventory = emptyList()
|
inventory = emptyList()
|
||||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||||
fieldTypes = emptyList()
|
fieldTypes = emptyList()
|
||||||
@@ -146,8 +123,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun closeVisitView() {
|
fun closeVisitView() {
|
||||||
if (busy) return
|
|
||||||
inventorySearchGeneration += 1
|
|
||||||
visit = null
|
visit = null
|
||||||
inventory = emptyList()
|
inventory = emptyList()
|
||||||
inventoryParentId = null
|
inventoryParentId = null
|
||||||
@@ -160,139 +135,67 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
fun startVisit() {
|
fun startVisit() {
|
||||||
val id = visit?.id ?: return
|
val id = visit?.id ?: return
|
||||||
val previousVisit = visit
|
launchBusy {
|
||||||
launchBusy(mutation = true) {
|
visit = repository.startVisit(id)
|
||||||
offlineQueue.enqueueVisitStart(id)
|
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||||
val localStartedAt = Instant.now().toString()
|
notice = "Inspección iniciada."
|
||||||
visit = visit?.copy(status = "IN_PROGRESS", actualStartedAt = localStartedAt)
|
loadActsInternal(id, selectDraft = true)
|
||||||
val result = offlineQueue.syncAll()
|
loadVisitsInternal()
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
visit = repository.visit(id)
|
|
||||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
|
||||||
notice = "Inspección iniciada."
|
|
||||||
loadActsInternal(id, selectDraft = true)
|
|
||||||
loadVisitsInternal()
|
|
||||||
} else if (result.retry) {
|
|
||||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
|
||||||
notice = "Inspección iniciada en el dispositivo. Podés trabajar sin conexión; se sincronizará automáticamente."
|
|
||||||
} else {
|
|
||||||
visit = previousVisit
|
|
||||||
error = result.lastError ?: "No se pudo iniciar la Inspección."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadActs() {
|
fun reloadActs() {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
launchBusy {
|
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
|
||||||
loadActsInternal(visitId, selectDraft = selectedAct == null)
|
|
||||||
refreshInspectorSignatureStatusInternal()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun syncPendingNow() {
|
|
||||||
launchBusy {
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
selectedAct?.id?.let { actId ->
|
|
||||||
runCatching { refreshSelectedActInternal(actId) }
|
|
||||||
}
|
|
||||||
if (result.pending == 0) {
|
|
||||||
notice = if (result.synced > 0) "Sincronización completa. ${result.synced} cambio${if (result.synced == 1) "" else "s"} enviado${if (result.synced == 1) "" else "s"}." else "No hay cambios pendientes de sincronización."
|
|
||||||
} else if (result.retry) {
|
|
||||||
notice = "Sin conexión. Los cambios siguen guardados en este dispositivo."
|
|
||||||
} else {
|
|
||||||
error = result.lastError ?: "Quedan cambios pendientes de sincronización."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshInspectorSignatureStatus() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
refreshInspectorSignatureStatusInternal()
|
|
||||||
if (inspectorSignatureConfigured == true && pendingSyncCount > 0) {
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
val actId = selectedAct?.id
|
|
||||||
if (actId != null && !selectedActSealPending) {
|
|
||||||
runCatching { refreshSelectedActInternal(actId) }
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
result.pending == 0 -> notice = "Firma verificada y cambios pendientes sincronizados."
|
|
||||||
result.retry -> notice = "Firma verificada. Los cambios siguen guardados hasta recuperar conexión."
|
|
||||||
else -> error = result.lastError ?: "Quedan cambios pendientes de sincronización."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectAct(actId: String) {
|
fun selectAct(actId: String) {
|
||||||
launchBusy {
|
launchBusy {
|
||||||
selectedAct = actsRepository.get(actId)
|
selectedAct = actsRepository.get(actId)
|
||||||
actClosure = actsRepository.closure(actId)
|
actClosure = actsRepository.closure(actId)
|
||||||
refreshInspectorSignatureStatusInternal()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
clearFindingState()
|
clearFindingState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createAct() {
|
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
|
||||||
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
|
||||||
|
}
|
||||||
|
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||||
|
error = "Elegí si el Acta es urgente o no urgente."
|
||||||
return
|
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 borrador. Cerrala antes de crear la siguiente."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val actId = DhRepository.newOperationId()
|
val created = actsRepository.create(
|
||||||
val occurredAt = Instant.now().toString()
|
currentVisit.id,
|
||||||
offlineQueue.enqueueActCreate(actId, currentVisit.id, currentVisit.code, occurredAt)
|
asset?.id,
|
||||||
val localCode = "ACTA-PEND-${actId.take(6).uppercase()}"
|
currentVisit.code,
|
||||||
val local = MobileActDetail(
|
urgency,
|
||||||
id = actId,
|
|
||||||
visitId = currentVisit.id,
|
|
||||||
code = localCode,
|
|
||||||
status = "DRAFT",
|
|
||||||
occurredAt = occurredAt,
|
|
||||||
title = "Acta de inspección ${currentVisit.code}",
|
|
||||||
summary = "Se inició la inspección ${currentVisit.code}.",
|
|
||||||
)
|
)
|
||||||
selectedAct = local
|
selectedAct = created
|
||||||
acts = acts + MobileActSummary(
|
actClosure = actsRepository.closure(created.id)
|
||||||
id = actId, visitId = currentVisit.id, code = localCode, status = "DRAFT", occurredAt = occurredAt,
|
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||||
title = local.title, summary = local.summary,
|
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
||||||
)
|
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||||
actClosure = MobileActClosure(
|
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||||
act = MobileActClosureHeader(
|
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||||
id = actId, code = localCode, status = "DRAFT", visitId = currentVisit.id, currentVersion = 0,
|
|
||||||
),
|
|
||||||
visit = MobileVisitClosureHeader(
|
|
||||||
id = currentVisit.id, code = currentVisit.code, status = currentVisit.status, actualClosedAt = currentVisit.actualClosedAt,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
|
||||||
refreshSelectedActInternal(actId)
|
|
||||||
notice = "${selectedAct?.code ?: localCode} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
|
|
||||||
} else {
|
|
||||||
notice = "Acta creada en el dispositivo. Podés continuar trabajando; recibirá su código oficial cuando vuelva internet."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
val result = repository.fieldInventory(currentVisit.id, search, parentId).data
|
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
|
||||||
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +203,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
|
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
|
||||||
inventorySearchGeneration += 1
|
|
||||||
inventoryParentId = effectiveParentId
|
inventoryParentId = effectiveParentId
|
||||||
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
|
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
|
||||||
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
|
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
|
||||||
@@ -312,55 +214,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureAssetInActOffline(
|
|
||||||
actId: String,
|
|
||||||
assetId: String,
|
|
||||||
code: String,
|
|
||||||
name: String,
|
|
||||||
typeName: String?,
|
|
||||||
): OfflineMutationQueue.SyncResult {
|
|
||||||
if (selectedAct?.assets?.none { it.id == assetId } != false) {
|
|
||||||
offlineQueue.enqueueActAsset(actId, assetId)
|
|
||||||
selectedAct = selectedAct?.copy(
|
|
||||||
assets = selectedAct!!.assets + AssetSummary(assetId, code, name, typeName),
|
|
||||||
assetCount = selectedAct!!.assetCount + 1,
|
|
||||||
)
|
|
||||||
acts = acts.map { if (it.id == actId) it.copy(assetCount = selectedAct?.assetCount ?: it.assetCount) else it }
|
|
||||||
}
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) selectedAct = actsRepository.get(actId)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectExisting(item: FieldInventoryItem) {
|
fun selectExisting(item: FieldInventoryItem) {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
offlineQueue.enqueueFieldAssetSelect(visitId, item.id)
|
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||||
selectedFieldAsset = FieldAssetDetail(
|
notice = "Inventario agregado a la inspección."
|
||||||
context = null,
|
reloadCurrentInventory(visitId)
|
||||||
asset = item.copy(selectedInInspection = true),
|
|
||||||
selectedInInspection = true,
|
|
||||||
capture = CaptureStatus(
|
|
||||||
captureRequired = item.captureRequired,
|
|
||||||
hasGeometry = item.hasGeometry,
|
|
||||||
creationGpsCaptured = item.hasGeometry,
|
|
||||||
fieldPhotoCount = item.fieldPhotoCount,
|
|
||||||
readyForFinding = item.readyForFinding,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val sync = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (sync.pending == 0) {
|
|
||||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
|
||||||
runCatching { reloadCurrentInventory(visitId) }
|
|
||||||
notice = "Inventario agregado a la Inspección."
|
|
||||||
} else {
|
|
||||||
notice = "Inventario seleccionado en el dispositivo. Se sincronizará automáticamente al volver internet."
|
|
||||||
}
|
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||||
ensureAssetInActOffline(draft.id, item.id, item.code, item.name, item.type?.name)
|
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
|
||||||
|
loadActsInternal(visitId, selectDraft = false)
|
||||||
loadFindingOptionsInternal(visitId, item.id, draft.id)
|
loadFindingOptionsInternal(visitId, item.id, draft.id)
|
||||||
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||||
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
||||||
@@ -374,74 +237,40 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
familyId: String?,
|
familyId: String?,
|
||||||
name: String,
|
name: String,
|
||||||
commonName: String?,
|
commonName: String?,
|
||||||
|
description: String?,
|
||||||
|
discoveryNotes: String?,
|
||||||
attributes: Map<String, Any?>,
|
attributes: Map<String, Any?>,
|
||||||
latitude: Double,
|
latitude: Double,
|
||||||
longitude: Double,
|
longitude: Double,
|
||||||
accuracyM: Double?,
|
accuracyM: Double?,
|
||||||
description: String? = null,
|
|
||||||
) {
|
) {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
if (visit?.status != "IN_PROGRESS") {
|
if (visit?.status != "IN_PROGRESS") {
|
||||||
error = "La Inspección debe estar en curso para dar de alta Inventario."
|
error = "La inspección debe estar en curso para dar de alta Inventario."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type.familyRequired && familyId == null) {
|
if (type.familyRequired && familyId == null) {
|
||||||
error = "Elegí el Tipo de instalación o subinstalación correspondiente."
|
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val assetId = DhRepository.newOperationId()
|
|
||||||
val capturedAt = Instant.now().toString()
|
|
||||||
val request = CreateFieldInventoryRequest(
|
val request = CreateFieldInventoryRequest(
|
||||||
clientGeneratedId = assetId,
|
|
||||||
typeId = type.id,
|
typeId = type.id,
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
familyId = familyId,
|
familyId = familyId,
|
||||||
name = name.trim(),
|
name = name.trim(),
|
||||||
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
||||||
description = description?.trim()?.takeIf { it.isNotBlank() },
|
description = description?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
discoveryNotes = discoveryNotes?.trim()?.takeIf { it.isNotBlank() },
|
||||||
attributes = attributes,
|
attributes = attributes,
|
||||||
deviceLatitude = FieldCoordinates.latitude(latitude),
|
deviceLatitude = latitude,
|
||||||
deviceLongitude = FieldCoordinates.longitude(longitude),
|
deviceLongitude = longitude,
|
||||||
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
|
deviceAccuracyM = accuracyM,
|
||||||
deviceCapturedAt = capturedAt,
|
deviceCapturedAt = Instant.now().toString(),
|
||||||
)
|
)
|
||||||
offlineQueue.enqueueFieldAssetCreate(assetId, visitId, request)
|
selectedFieldAsset = repository.createFieldAsset(visitId, request)
|
||||||
val localAsset = FieldInventoryItem(
|
notice = "Inventario creado con ubicación. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
||||||
id = assetId,
|
reloadCurrentInventory(visitId)
|
||||||
code = "PEND-${assetId.take(6).uppercase()}",
|
|
||||||
name = request.name,
|
|
||||||
commonName = request.commonName,
|
|
||||||
informationStatus = "DRAFT",
|
|
||||||
dataOrigin = "FIELD_SURVEY",
|
|
||||||
type = AssetSummary(type.id, type.code, type.name, type.name),
|
|
||||||
selectedInInspection = true,
|
|
||||||
captureRequired = true,
|
|
||||||
hasGeometry = true,
|
|
||||||
fieldPhotoCount = 0,
|
|
||||||
readyForFinding = false,
|
|
||||||
)
|
|
||||||
selectedFieldAsset = FieldAssetDetail(
|
|
||||||
asset = localAsset,
|
|
||||||
selectedInInspection = true,
|
|
||||||
capture = CaptureStatus(
|
|
||||||
captureRequired = true,
|
|
||||||
hasGeometry = true,
|
|
||||||
creationGpsCaptured = true,
|
|
||||||
fieldPhotoCount = 0,
|
|
||||||
readyForFinding = false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
inventory = inventory + localAsset
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
selectedFieldAsset = repository.selectFieldAsset(visitId, assetId)
|
|
||||||
runCatching { reloadCurrentInventory(visitId) }
|
|
||||||
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
|
||||||
} else {
|
|
||||||
notice = "Inventario creado y guardado en el dispositivo. Sacá la foto obligatoria; se sincronizará automáticamente al volver internet."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,7 +285,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
error = "Explicá brevemente por qué se trata de un duplicado."
|
error = "Explicá brevemente por qué se trata de un duplicado."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val result = repository.mergeFieldAsset(
|
val result = repository.mergeFieldAsset(
|
||||||
visitId = visitId,
|
visitId = visitId,
|
||||||
assetId = source.id,
|
assetId = source.id,
|
||||||
@@ -468,9 +297,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
reloadCurrentInventory(visitId)
|
reloadCurrentInventory(visitId)
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||||
ensureAssetInActOffline(
|
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
|
||||||
draft.id, result.canonical.id, result.canonical.code, result.canonical.name, result.canonical.typeName,
|
loadActsInternal(visitId, selectDraft = false)
|
||||||
)
|
|
||||||
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
|
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,42 +312,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
) {
|
) {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
val asset = selectedFieldAsset?.asset ?: return
|
val asset = selectedFieldAsset?.asset ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
offlineQueue.enqueueFieldAssetPhoto(
|
val response = repository.uploadFieldPhoto(
|
||||||
assetId = asset.id,
|
|
||||||
visitId = visitId,
|
visitId = visitId,
|
||||||
source = file,
|
assetId = asset.id,
|
||||||
|
file = file,
|
||||||
latitude = latitude,
|
latitude = latitude,
|
||||||
longitude = longitude,
|
longitude = longitude,
|
||||||
accuracyM = accuracyM,
|
accuracyM = accuracyM,
|
||||||
)
|
)
|
||||||
val localCapture = selectedFieldAsset?.capture?.copy(
|
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
|
||||||
hasGeometry = true,
|
notice = if (response.capture.readyForFinding) {
|
||||||
creationGpsCaptured = true,
|
"Captura completa: ubicación y fotografía registradas."
|
||||||
fieldPhotoCount = (selectedFieldAsset?.capture?.fieldPhotoCount ?: 0) + 1,
|
|
||||||
readyForFinding = true,
|
|
||||||
) ?: CaptureStatus(
|
|
||||||
captureRequired = true, hasGeometry = true, creationGpsCaptured = true, fieldPhotoCount = 1, readyForFinding = true,
|
|
||||||
)
|
|
||||||
selectedFieldAsset = selectedFieldAsset?.copy(
|
|
||||||
asset = asset.copy(hasGeometry = true, fieldPhotoCount = localCapture.fieldPhotoCount, readyForFinding = true),
|
|
||||||
capture = localCapture,
|
|
||||||
)
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
selectedFieldAsset = repository.selectFieldAsset(visitId, asset.id)
|
|
||||||
runCatching { reloadCurrentInventory(visitId) }
|
|
||||||
notice = "Inventario listo: ubicación y foto registradas."
|
|
||||||
} else {
|
} else {
|
||||||
notice = "Foto del Inventario guardada en el dispositivo. Ya podés continuar; se sincronizará al volver internet."
|
"Fotografía registrada."
|
||||||
}
|
}
|
||||||
|
reloadCurrentInventory(visitId)
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
if (response.capture.readyForFinding && draft != null) {
|
||||||
val current = selectedFieldAsset?.asset ?: asset
|
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
|
||||||
ensureAssetInActOffline(draft.id, current.id, current.code, current.name, current.type?.name)
|
loadActsInternal(visitId, selectDraft = false)
|
||||||
loadFindingOptionsInternal(visitId, current.id, draft.id)
|
loadFindingOptionsInternal(visitId, asset.id, draft.id)
|
||||||
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
} else if (response.capture.readyForFinding) {
|
||||||
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,12 +344,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (draft == null) {
|
if (draft == null) {
|
||||||
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
|
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val asset = selectedFieldAsset?.asset ?: return@launchBusy
|
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
|
||||||
ensureAssetInActOffline(draft.id, assetId, asset.code, asset.name, asset.type?.name)
|
loadActsInternal(visitId, selectDraft = false)
|
||||||
loadFindingOptionsInternal(visitId, assetId, draft.id)
|
loadFindingOptionsInternal(visitId, assetId, draft.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -546,12 +360,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
customLegalBasis: String?,
|
customLegalBasis: String?,
|
||||||
description: String,
|
description: String,
|
||||||
severity: Int?,
|
severity: Int?,
|
||||||
|
correctionDueOn: String?,
|
||||||
) {
|
) {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||||
val actId = selectedDraftAct()?.id
|
val actId = selectedDraftAct()?.id
|
||||||
if (actId == null) {
|
if (actId == null) {
|
||||||
error = "No hay un Acta en elaboración seleccionada."
|
error = "No hay un Acta en borrador seleccionada."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (description.isBlank()) {
|
if (description.isBlank()) {
|
||||||
@@ -566,61 +381,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
error = "La gravedad debe estar entre 1 y 10."
|
error = "La gravedad debe estar entre 1 y 10."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val clientFindingId = DhRepository.newOperationId()
|
selectedAct = actsRepository.ensureAsset(actId, assetId)
|
||||||
val request = CreateFieldFindingRequest(
|
val response = findingsRepository.create(
|
||||||
actId = actId,
|
visitId,
|
||||||
clientGeneratedId = clientFindingId,
|
assetId,
|
||||||
catalogItemId = catalogItemId,
|
CreateFieldFindingRequest(
|
||||||
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
|
actId = actId,
|
||||||
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
catalogItemId = catalogItemId,
|
||||||
description = description.trim(),
|
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
|
||||||
severity = severity,
|
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
description = description.trim(),
|
||||||
|
severity = severity,
|
||||||
|
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (selectedAct?.assets?.none { it.id == assetId } != false) {
|
lastCreatedFinding = response.finding
|
||||||
offlineQueue.enqueueActAsset(actId, assetId)
|
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
|
||||||
selectedFieldAsset?.asset?.let { asset ->
|
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
|
||||||
selectedAct = selectedAct?.copy(assets = selectedAct!!.assets + AssetSummary(
|
loadActsInternal(visitId, selectDraft = false)
|
||||||
id = asset.id,
|
|
||||||
code = asset.code,
|
|
||||||
name = asset.name,
|
|
||||||
typeName = asset.type?.name,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offlineQueue.enqueueFindingCreate(clientFindingId, visitId, assetId, request)
|
|
||||||
|
|
||||||
val catalog = fieldFindingOptions?.catalog?.items?.firstOrNull { it.id == catalogItemId }
|
|
||||||
val localNumber = (fieldFindingOptions?.findings?.maxOfOrNull { it.findingNumber } ?: 0) + 1
|
|
||||||
val localFinding = FieldFindingItem(
|
|
||||||
id = clientFindingId,
|
|
||||||
actId = actId,
|
|
||||||
assetId = assetId,
|
|
||||||
catalogItemId = catalogItemId,
|
|
||||||
findingNumber = localNumber,
|
|
||||||
code = "PEND-H${localNumber.toString().padStart(3, '0')}",
|
|
||||||
status = "OPEN",
|
|
||||||
title = catalog?.title ?: customTitle?.trim().orEmpty().ifBlank { "Hallazgo pendiente" },
|
|
||||||
description = description.trim(),
|
|
||||||
severity = severity ?: catalog?.suggestedSeverity,
|
|
||||||
suggestedSeverity = catalog?.suggestedSeverity,
|
|
||||||
)
|
|
||||||
lastCreatedFinding = localFinding
|
|
||||||
fieldFindingOptions = fieldFindingOptions?.copy(
|
|
||||||
findings = fieldFindingOptions!!.findings + localFinding,
|
|
||||||
canAddAnother = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
|
|
||||||
lastCreatedFinding = fieldFindingOptions?.findings?.firstOrNull { it.id == clientFindingId } ?: localFinding
|
|
||||||
loadActsInternal(visitId, selectDraft = false)
|
|
||||||
notice = "Hallazgo ${lastCreatedFinding?.code ?: localFinding.code} registrado. Podés agregar evidencia fotográfica."
|
|
||||||
} else {
|
|
||||||
notice = "Hallazgo guardado en el dispositivo. Podés seguir sacando fotos; todo se sincronizará automáticamente al volver internet."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,24 +412,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
title: String? = null,
|
title: String? = null,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
) {
|
) {
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
offlineQueue.enqueueFindingPhoto(
|
findingsRepository.uploadObservationPhoto(
|
||||||
findingId = findingId,
|
findingId = findingId,
|
||||||
source = file,
|
file = file,
|
||||||
latitude = latitude,
|
latitude = latitude,
|
||||||
longitude = longitude,
|
longitude = longitude,
|
||||||
accuracyM = accuracyM,
|
accuracyM = accuracyM,
|
||||||
title = title,
|
title = title,
|
||||||
description = description,
|
description = description,
|
||||||
)
|
)
|
||||||
val result = offlineQueue.syncAll()
|
loadEvidenceInternal(findingId)
|
||||||
refreshOfflineStateInternal()
|
notice = "Evidencia fotográfica registrada con ubicación."
|
||||||
if (result.pending == 0) {
|
|
||||||
loadEvidenceInternal(findingId)
|
|
||||||
notice = "Evidencia fotográfica registrada con GPS."
|
|
||||||
} else {
|
|
||||||
notice = "Foto guardada en el dispositivo. Se sincronizará automáticamente al volver la conexión."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,41 +440,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
phone: String?,
|
phone: String?,
|
||||||
) {
|
) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
val normalizedEmail = email?.trim().orEmpty()
|
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
|
||||||
val emailValid = Regex("""^[^\s@]+@[^\s@]+\.[^\s@]+$""").matches(normalizedEmail)
|
error = "Completá nombre, documento y cargo del responsable de la empresa."
|
||||||
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank() || !emailValid) {
|
|
||||||
error = "Completá nombres y apellidos, DNI, cargo/función y un email válido del representante de la empresa."
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val request = MobileResponsibleRequest(
|
actClosure = actsRepository.setResponsible(
|
||||||
attendanceStatus = "PRESENT",
|
actId,
|
||||||
fullName = fullName.trim(),
|
MobileResponsibleRequest(
|
||||||
documentType = documentType,
|
attendanceStatus = "PRESENT",
|
||||||
documentNumber = documentNumber.trim(),
|
fullName = fullName.trim(),
|
||||||
position = position.trim(),
|
documentType = documentType,
|
||||||
email = normalizedEmail.lowercase(),
|
documentNumber = documentNumber.trim(),
|
||||||
phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
position = position.trim(),
|
||||||
|
email = email?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
offlineQueue.enqueueResponsible(actId, request)
|
notice = "Responsable de empresa registrado para el Acta."
|
||||||
actClosure = actClosure?.copy(responsible = MobileResponsible(
|
|
||||||
actId = actId,
|
|
||||||
attendanceStatus = request.attendanceStatus,
|
|
||||||
fullName = request.fullName,
|
|
||||||
documentType = request.documentType,
|
|
||||||
documentNumber = request.documentNumber,
|
|
||||||
position = request.position,
|
|
||||||
email = request.email,
|
|
||||||
phone = request.phone,
|
|
||||||
))
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
actClosure = actsRepository.closure(actId)
|
|
||||||
notice = "Representante de la empresa registrado para esta Acta."
|
|
||||||
} else {
|
|
||||||
notice = "Representante guardado en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -711,59 +467,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
|
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
val request = MobileResponsibleRequest(
|
actClosure = actsRepository.setResponsible(
|
||||||
attendanceStatus = "ABSENT",
|
actId,
|
||||||
absenceReason = reason.trim(),
|
MobileResponsibleRequest(
|
||||||
|
attendanceStatus = "ABSENT",
|
||||||
|
absenceReason = reason.trim(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
offlineQueue.enqueueResponsible(actId, request)
|
notice = "Ausencia del responsable registrada. Al cerrar el Acta, dejá asentada la ausencia en el circuito de firma."
|
||||||
actClosure = actClosure?.copy(responsible = MobileResponsible(
|
|
||||||
actId = actId,
|
|
||||||
attendanceStatus = "ABSENT",
|
|
||||||
absenceReason = reason.trim(),
|
|
||||||
))
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) actClosure = actsRepository.closure(actId)
|
|
||||||
notice = if (result.pending == 0) {
|
|
||||||
"Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
|
|
||||||
} else {
|
|
||||||
"Ausencia guardada en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepareSelectedAct(urgency: String, narrative: String) {
|
fun prepareSelectedAct() {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
launchBusy {
|
||||||
error = "Definí la urgencia antes de cerrar el Acta."
|
actClosure = actsRepository.lock(actId)
|
||||||
return
|
refreshSelectedActInternal(actId)
|
||||||
}
|
notice = "Acta cerrada y pendiente de firma. El contenido y los Hallazgos quedaron inmutables."
|
||||||
val summary = narrative.trim()
|
|
||||||
if (summary.length !in 10..20000) {
|
|
||||||
error = "Describí lo actuado antes de cerrar el Acta (entre 10 y 20000 caracteres)."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
launchBusy(mutation = true) {
|
|
||||||
offlineQueue.enqueueActSummary(actId, summary)
|
|
||||||
offlineQueue.enqueueActLock(actId, urgency)
|
|
||||||
selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency, summary = summary)
|
|
||||||
acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency, summary = summary) else it }
|
|
||||||
actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency))
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
|
||||||
if (result.pending == 0) {
|
|
||||||
refreshSelectedActInternal(actId)
|
|
||||||
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
|
|
||||||
} else {
|
|
||||||
notice = "Cierre de contenido guardado en el dispositivo como $urgencyLabel. Podés continuar con la firma; se sincronizará al recuperar internet."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reopenSelectedAct() {
|
fun reopenSelectedAct() {
|
||||||
error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
|
error = "Un Acta cerrada y pendiente de firma es inmutable y no puede volver a borrador."
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signSelectedActAsInspector(
|
fun signSelectedActAsInspector(
|
||||||
@@ -773,7 +499,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
accuracyM: Double?,
|
accuracyM: Double?,
|
||||||
) {
|
) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
|
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
|
||||||
notice = "Firma del inspector incorporada al Acta."
|
notice = "Firma del inspector incorporada al Acta."
|
||||||
}
|
}
|
||||||
@@ -788,51 +514,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
statement: String?,
|
statement: String?,
|
||||||
) {
|
) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
offlineQueue.enqueueCompanySignature(
|
actClosure = actsRepository.signCompany(
|
||||||
actId = actId,
|
actId, png, latitude, longitude, accuracyM, manifestation, statement,
|
||||||
source = png,
|
|
||||||
latitude = latitude,
|
|
||||||
longitude = longitude,
|
|
||||||
accuracyM = accuracyM,
|
|
||||||
manifestation = manifestation,
|
|
||||||
statement = statement,
|
|
||||||
)
|
)
|
||||||
val result = offlineQueue.syncAll()
|
notice = if (manifestation == "DISSENT") {
|
||||||
refreshOfflineStateInternal()
|
"Firma de empresa registrada con disidencia."
|
||||||
if (!selectedActCompanySignaturePending) {
|
|
||||||
actClosure = actsRepository.closure(actId)
|
|
||||||
notice = if (manifestation == "DISSENT") {
|
|
||||||
"Firma del representante registrada en disconformidad."
|
|
||||||
} else {
|
|
||||||
"Firma del representante de la empresa registrada."
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
notice = "Firma guardada en el dispositivo. Quedó pendiente de sincronización y no se perderá si no hay internet."
|
"Firma de empresa registrada."
|
||||||
}
|
}
|
||||||
result.lastError?.takeIf { result.pending > 0 && !result.retry }?.let { error = it }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun recordCompanyOutcome(status: String, reason: String) {
|
fun recordCompanyOutcome(status: String, reason: String) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
if (status != "REFUSED") {
|
if (status !in setOf("REFUSED", "ABSENT")) {
|
||||||
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
|
error = "La manifestación de la empresa debe resolverse con firma, negativa o ausencia documentada."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (reason.trim().length < 10) {
|
if (reason.trim().length < 10) {
|
||||||
error = "Indicá un motivo de al menos 10 caracteres."
|
error = "Indicá un motivo de al menos 10 caracteres."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
offlineQueue.enqueueCompanyOutcome(actId, status, reason.trim())
|
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||||
val result = offlineQueue.syncAll()
|
notice = if (status == "ABSENT") {
|
||||||
refreshOfflineStateInternal()
|
"Ausencia para la firma de empresa asentada."
|
||||||
if (result.pending == 0) {
|
|
||||||
actClosure = actsRepository.closure(actId)
|
|
||||||
notice = "Negativa a firmar asentada."
|
|
||||||
} else {
|
} else {
|
||||||
notice = "Negativa a firmar guardada en el dispositivo. Se sincronizará automáticamente al recuperar internet."
|
"Negativa a firmar asentada."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,51 +549,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fun closeSelectedAct() {
|
fun closeSelectedAct() {
|
||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy {
|
||||||
offlineQueue.enqueueSeal(actId)
|
actClosure = actsRepository.seal(actId)
|
||||||
if (inspectorSignatureConfigured == false) {
|
refreshSelectedActInternal(actId)
|
||||||
refreshOfflineStateInternal()
|
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||||
notice = "Cierre guardado en el dispositivo. Falta configurar la firma del inspector en Mi perfil del Dashboard; después se sincronizará automáticamente."
|
clearFindingState()
|
||||||
return@launchBusy
|
notice = "${selectedAct?.code ?: "Acta"} firmada, finalizada e inmutable. Podés crear otra Acta o continuar hacia el cierre de la inspección."
|
||||||
}
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (!selectedActSealPending) {
|
|
||||||
refreshSelectedActInternal(actId)
|
|
||||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
|
||||||
clearFindingState()
|
|
||||||
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
|
||||||
} else if (result.retry) {
|
|
||||||
val localClosedAt = Instant.now().toString()
|
|
||||||
selectedAct = selectedAct?.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt)
|
|
||||||
acts = acts.map { if (it.id == actId) it.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) else it }
|
|
||||||
clearFindingState()
|
|
||||||
notice = "Acta cerrada en el dispositivo. Podés continuar con otra Acta; se sincronizará automáticamente al recuperar internet."
|
|
||||||
} else {
|
|
||||||
error = result.lastError ?: "El cierre quedó pendiente de sincronización."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeInspection() {
|
fun closeInspection() {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
val previousVisit = visit
|
launchBusy {
|
||||||
launchBusy(mutation = true) {
|
visit = actsRepository.closeVisit(visitId)
|
||||||
val closedAt = Instant.now().toString()
|
loadVisitsInternal()
|
||||||
offlineQueue.enqueueVisitClose(visitId, closedAt)
|
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
|
||||||
visit = visit?.copy(status = "CLOSED", actualClosedAt = closedAt)
|
|
||||||
val result = offlineQueue.syncAll()
|
|
||||||
refreshOfflineStateInternal()
|
|
||||||
if (result.pending == 0) {
|
|
||||||
visit = repository.visit(visitId)
|
|
||||||
loadVisitsInternal()
|
|
||||||
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
|
|
||||||
} else if (result.retry) {
|
|
||||||
notice = "Cierre de Inspección guardado en el dispositivo. Quedará sincronizado automáticamente cuando vuelva internet."
|
|
||||||
} else {
|
|
||||||
visit = previousVisit
|
|
||||||
error = result.lastError ?: "No se pudo cerrar la Inspección."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,7 +601,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
selectedAct = null
|
selectedAct = null
|
||||||
actClosure = null
|
actClosure = null
|
||||||
}
|
}
|
||||||
refreshOfflineStateInternal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshSelectedActInternal(actId: String) {
|
private suspend fun refreshSelectedActInternal(actId: String) {
|
||||||
@@ -953,24 +631,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshInspectorSignatureStatusInternal() {
|
|
||||||
runCatching { actsRepository.selfSignatureStatus() }
|
|
||||||
.onSuccess { inspectorSignatureConfigured = it.configured }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshOfflineStateInternal() {
|
|
||||||
pendingSyncCount = offlineQueue.pendingCount()
|
|
||||||
val actId = selectedAct?.id
|
|
||||||
if (actId == null) {
|
|
||||||
selectedActCompanySignaturePending = false
|
|
||||||
selectedActSealPending = false
|
|
||||||
} else {
|
|
||||||
selectedActCompanySignaturePending = offlineQueue.hasPending("COMPANY_SIGNATURE", actId)
|
|
||||||
|| offlineQueue.hasPending("COMPANY_OUTCOME", actId)
|
|
||||||
selectedActSealPending = offlineQueue.hasPending("ACT_SEAL", actId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearActState() {
|
private fun clearActState() {
|
||||||
acts = emptyList()
|
acts = emptyList()
|
||||||
selectedAct = null
|
selectedAct = null
|
||||||
@@ -983,21 +643,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
fieldFindingEvidence = emptyMap()
|
fieldFindingEvidence = emptyMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
|
private fun launchBusy(block: suspend () -> Unit) {
|
||||||
if (!operations.begin(mutation)) return
|
|
||||||
busy = operations.busy
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
busy = true
|
||||||
error = null
|
error = null
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (cancelled: CancellationException) {
|
|
||||||
throw cancelled
|
|
||||||
} catch (throwable: Throwable) {
|
} catch (throwable: Throwable) {
|
||||||
error = DhRepository.humanError(throwable)
|
error = DhRepository.humanError(throwable)
|
||||||
if (repository.currentSession() == null) session = null
|
if (repository.currentSession() == null) session = null
|
||||||
} finally {
|
} finally {
|
||||||
operations.end()
|
busy = false
|
||||||
busy = operations.busy
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion
|
|
||||||
|
|
||||||
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
|
|
||||||
internal class OperationTracker {
|
|
||||||
private var pending = 0
|
|
||||||
val busy: Boolean get() = pending > 0
|
|
||||||
fun begin(mutation: Boolean): Boolean {
|
|
||||||
if (mutation && busy) return false
|
|
||||||
pending += 1
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
fun end() {
|
|
||||||
check(pending > 0)
|
|
||||||
pending -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,10 @@ import android.security.keystore.KeyGenParameterSpec
|
|||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.korexlabs.dhinspeccion.BuildConfig
|
import com.korexlabs.dhinspeccion.BuildConfig
|
||||||
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
|
|
||||||
import com.squareup.moshi.JsonAdapter
|
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.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
|
||||||
@@ -27,7 +27,6 @@ import retrofit2.http.Part
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -257,7 +256,6 @@ data class FieldAssetDetail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class CreateFieldInventoryRequest(
|
data class CreateFieldInventoryRequest(
|
||||||
val clientGeneratedId: String? = null,
|
|
||||||
val typeId: String,
|
val typeId: String,
|
||||||
val parentId: String? = null,
|
val parentId: String? = null,
|
||||||
val familyId: String? = null,
|
val familyId: String? = null,
|
||||||
@@ -370,7 +368,6 @@ interface DhApi {
|
|||||||
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
|
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
|
||||||
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
|
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
|
||||||
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
|
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
|
||||||
@Part("operationId") operationId: okhttp3.RequestBody?,
|
|
||||||
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
|
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
|
||||||
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
|
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
|
||||||
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
|
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
|
||||||
@@ -453,14 +450,9 @@ class SecureSessionStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DhRepository(context: Context) {
|
class DhRepository(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
private val store = SecureSessionStore(context.applicationContext)
|
||||||
private val store = SecureSessionStore(appContext)
|
private val refreshMutex = Mutex()
|
||||||
private val cache = OfflineJsonCache(appContext)
|
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||||
private val visitListAdapter = moshi.adapter(VisitListResponse::class.java)
|
|
||||||
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
|
|
||||||
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
|
|
||||||
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
|
|
||||||
private val api: DhApi = Retrofit.Builder()
|
private val api: DhApi = Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
.client(OkHttpClient.Builder().build())
|
.client(OkHttpClient.Builder().build())
|
||||||
@@ -479,47 +471,25 @@ class DhRepository(context: Context) {
|
|||||||
store.clear()
|
store.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun visits(): VisitListResponse {
|
suspend fun visits(): VisitListResponse = authorized { session ->
|
||||||
val session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
api.visits("Bearer ${session.accessToken}", session.userId)
|
||||||
return cached("visits:${session.userId}", visitListAdapter) {
|
|
||||||
authorized { active -> api.visits("Bearer ${active.accessToken}", active.userId) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun visit(id: String): VisitDetail = cached("visit:$id", visitAdapter) {
|
suspend fun visit(id: String): VisitDetail = authorized { session ->
|
||||||
authorized { session -> api.visit("Bearer ${session.accessToken}", id) }
|
api.visit("Bearer ${session.accessToken}", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun startVisit(id: String): VisitDetail {
|
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
|
||||||
val value = authorized { session -> api.startVisit("Bearer ${session.accessToken}", id) }
|
api.startVisit("Bearer ${session.accessToken}", id)
|
||||||
cache.put(userCacheKey("visit:$id"), visitAdapter.toJson(value))
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null): FieldInventoryListResponse {
|
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
|
||||||
val normalizedSearch = search?.trim()?.takeIf { it.isNotBlank() }
|
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
|
||||||
val baseKey = "inventory:$visitId:${parentId ?: "root"}"
|
|
||||||
return try {
|
|
||||||
val value = authorized { session ->
|
|
||||||
api.fieldInventory("Bearer ${session.accessToken}", visitId, normalizedSearch, parentId)
|
|
||||||
}
|
|
||||||
if (normalizedSearch == null) cache.put(userCacheKey(baseKey), inventoryAdapter.toJson(value))
|
|
||||||
value
|
|
||||||
} catch (error: IOException) {
|
|
||||||
val cached = cache.get(userCacheKey(baseKey))?.let(inventoryAdapter::fromJson) ?: throw error
|
|
||||||
if (normalizedSearch == null) cached else cached.copy(
|
|
||||||
data = cached.data.filter { item ->
|
|
||||||
item.code.contains(normalizedSearch, ignoreCase = true)
|
|
||||||
|| item.name.contains(normalizedSearch, ignoreCase = true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fieldTypes(visitId: String, parentId: String?): FieldTypeResponse =
|
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
|
||||||
cached("field-types:$visitId:${parentId ?: "root"}", fieldTypeAdapter) {
|
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
|
||||||
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
||||||
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
||||||
@@ -551,7 +521,6 @@ class DhRepository(context: Context) {
|
|||||||
longitude: Double,
|
longitude: Double,
|
||||||
accuracyM: Double?,
|
accuracyM: Double?,
|
||||||
capturedAt: String = Instant.now().toString(),
|
capturedAt: String = Instant.now().toString(),
|
||||||
operationId: String? = null,
|
|
||||||
): FieldPhotoResponse = authorized { session ->
|
): FieldPhotoResponse = authorized { session ->
|
||||||
val text = "text/plain".toMediaType()
|
val text = "text/plain".toMediaType()
|
||||||
val body = file.asRequestBody("image/jpeg".toMediaType())
|
val body = file.asRequestBody("image/jpeg".toMediaType())
|
||||||
@@ -561,32 +530,17 @@ class DhRepository(context: Context) {
|
|||||||
visitId = visitId,
|
visitId = visitId,
|
||||||
assetId = assetId,
|
assetId = assetId,
|
||||||
file = part,
|
file = part,
|
||||||
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
|
latitude = latitude.toString().toRequestBody(text),
|
||||||
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
|
longitude = longitude.toString().toRequestBody(text),
|
||||||
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
|
accuracy = accuracyM?.toString()?.toRequestBody(text),
|
||||||
capturedAt = capturedAt.toRequestBody(text),
|
capturedAt = capturedAt.toRequestBody(text),
|
||||||
deviceLabel = "DH Android".toRequestBody(text),
|
deviceLabel = "DH Android".toRequestBody(text),
|
||||||
operationId = operationId?.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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
|
|
||||||
|
|
||||||
private suspend fun <T> cached(
|
|
||||||
key: String,
|
|
||||||
adapter: JsonAdapter<T>,
|
|
||||||
online: suspend () -> T,
|
|
||||||
): T {
|
|
||||||
return try {
|
|
||||||
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
|
|
||||||
} catch (error: IOException) {
|
|
||||||
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
||||||
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
try {
|
try {
|
||||||
@@ -594,26 +548,28 @@ class DhRepository(context: Context) {
|
|||||||
} catch (error: HttpException) {
|
} catch (error: HttpException) {
|
||||||
if (error.code() != 401) throw error
|
if (error.code() != 401) throw error
|
||||||
}
|
}
|
||||||
session = refresh(session)
|
session = refresh(session.refreshToken)
|
||||||
return block(session)
|
return block(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refresh(previous: StoredSession): StoredSession =
|
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
|
if (latest.refreshToken != previousRefreshToken) return@withLock latest
|
||||||
|
try {
|
||||||
|
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
store.clear()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun humanError(error: Throwable): String {
|
fun humanError(error: Throwable): String {
|
||||||
if (error is java.io.IOException) {
|
|
||||||
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
|
|
||||||
}
|
|
||||||
if (error is HttpException) {
|
if (error is HttpException) {
|
||||||
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
|
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
|
||||||
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
|
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
|
||||||
if (message == "Error interno") return "No se pudo completar la operación. Intentá nuevamente."
|
|
||||||
if (!message.isNullOrBlank()) return message
|
if (!message.isNullOrBlank()) return message
|
||||||
if (error.code() >= 500) return "No se pudo completar la operación. Intentá nuevamente."
|
return "Error HTTP ${error.code()}"
|
||||||
return "No se pudo procesar la solicitud (${error.code()})."
|
|
||||||
}
|
}
|
||||||
return error.message ?: "Ocurrió un error inesperado"
|
return error.message ?: "Ocurrió un error inesperado"
|
||||||
}
|
}
|
||||||
|
|||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.data
|
|
||||||
|
|
||||||
/** Builds the API payload using definition IDs while the form keeps stable field codes. */
|
|
||||||
internal fun fieldAttributePayload(
|
|
||||||
definitions: List<FieldAttributeDefinition>,
|
|
||||||
valuesByCode: Map<String, String>,
|
|
||||||
): Map<String, Any?> = definitions.mapNotNull { definition ->
|
|
||||||
val raw = valuesByCode[definition.code]?.trim().orEmpty()
|
|
||||||
if (raw.isBlank()) return@mapNotNull null
|
|
||||||
definition.id to coerceFieldAttribute(definition, raw)
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
private fun coerceFieldAttribute(definition: FieldAttributeDefinition, raw: String): Any =
|
|
||||||
when (definition.dataType.uppercase()) {
|
|
||||||
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
|
||||||
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
|
||||||
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
|
||||||
else -> raw
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.data
|
|
||||||
|
|
||||||
import java.math.BigDecimal
|
|
||||||
import java.math.RoundingMode
|
|
||||||
|
|
||||||
internal object FieldCoordinates {
|
|
||||||
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
|
|
||||||
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
|
|
||||||
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
|
|
||||||
}
|
|
||||||
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
|
|
||||||
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
|
|
||||||
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
|
|
||||||
}
|
|
||||||
+25
-58
@@ -2,10 +2,10 @@ package com.korexlabs.dhinspeccion.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.korexlabs.dhinspeccion.BuildConfig
|
import com.korexlabs.dhinspeccion.BuildConfig
|
||||||
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
|
|
||||||
import com.squareup.moshi.JsonAdapter
|
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.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
|
||||||
@@ -24,7 +24,6 @@ import retrofit2.http.Part
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class FieldFindingAct(
|
data class FieldFindingAct(
|
||||||
@@ -111,12 +110,12 @@ data class FieldFindingOptionsResponse(
|
|||||||
|
|
||||||
data class CreateFieldFindingRequest(
|
data class CreateFieldFindingRequest(
|
||||||
val actId: String,
|
val actId: String,
|
||||||
val clientGeneratedId: String? = null,
|
|
||||||
val catalogItemId: String? = null,
|
val catalogItemId: String? = null,
|
||||||
val customTitle: String? = null,
|
val customTitle: String? = null,
|
||||||
val customLegalBasis: String? = null,
|
val customLegalBasis: String? = null,
|
||||||
val description: String,
|
val description: String,
|
||||||
val severity: Int? = null,
|
val severity: Int? = null,
|
||||||
|
val correctionDueOn: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class FieldFindingCreateResponse(
|
data class FieldFindingCreateResponse(
|
||||||
@@ -165,7 +164,6 @@ private interface FieldFindingsApi {
|
|||||||
@Part("longitude") longitude: RequestBody,
|
@Part("longitude") longitude: RequestBody,
|
||||||
@Part("accuracyM") accuracyM: RequestBody?,
|
@Part("accuracyM") accuracyM: RequestBody?,
|
||||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||||
@Part("operationId") operationId: RequestBody?,
|
|
||||||
): FieldFindingEvidence
|
): FieldFindingEvidence
|
||||||
|
|
||||||
@POST("auth/mobile/refresh")
|
@POST("auth/mobile/refresh")
|
||||||
@@ -177,12 +175,9 @@ private interface FieldFindingsApi {
|
|||||||
* F3.2 exige que la APK identifique explícitamente el Acta activa.
|
* F3.2 exige que la APK identifique explícitamente el Acta activa.
|
||||||
*/
|
*/
|
||||||
class FieldFindingsRepository(context: Context) {
|
class FieldFindingsRepository(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
private val store = SecureSessionStore(context.applicationContext)
|
||||||
private val store = SecureSessionStore(appContext)
|
private val refreshMutex = Mutex()
|
||||||
private val cache = OfflineJsonCache(appContext)
|
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||||
private val optionsAdapter = moshi.adapter(FieldFindingOptionsResponse::class.java)
|
|
||||||
private val evidenceAdapter = moshi.adapter(FieldFindingEvidenceListResponse::class.java)
|
|
||||||
private val api: FieldFindingsApi = Retrofit.Builder()
|
private val api: FieldFindingsApi = Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
.client(OkHttpClient.Builder().build())
|
.client(OkHttpClient.Builder().build())
|
||||||
@@ -190,28 +185,10 @@ class FieldFindingsRepository(context: Context) {
|
|||||||
.build()
|
.build()
|
||||||
.create(FieldFindingsApi::class.java)
|
.create(FieldFindingsApi::class.java)
|
||||||
|
|
||||||
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse {
|
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
|
||||||
val exactKey = "finding-options:$visitId:$assetId:$actId"
|
authorized { session ->
|
||||||
val templateKey = "finding-options-template:$visitId:$assetId"
|
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
|
||||||
return try {
|
|
||||||
val value = authorized { session ->
|
|
||||||
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
|
|
||||||
}
|
|
||||||
val json = optionsAdapter.toJson(value)
|
|
||||||
cache.put(userCacheKey(exactKey), json)
|
|
||||||
cache.put(userCacheKey(templateKey), json)
|
|
||||||
value
|
|
||||||
} catch (error: IOException) {
|
|
||||||
cache.get(userCacheKey(exactKey))?.let(optionsAdapter::fromJson)
|
|
||||||
?: cache.get(userCacheKey(templateKey))?.let(optionsAdapter::fromJson)?.copy(
|
|
||||||
act = FieldFindingAct(id = actId, code = "Acta local pendiente", status = "DRAFT"),
|
|
||||||
findings = emptyList(),
|
|
||||||
assetIncludedInAct = true,
|
|
||||||
canAddAnother = true,
|
|
||||||
)
|
|
||||||
?: throw error
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
visitId: String,
|
visitId: String,
|
||||||
@@ -221,10 +198,9 @@ class FieldFindingsRepository(context: Context) {
|
|||||||
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
|
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse =
|
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
|
||||||
cached("finding-evidence:$findingId", evidenceAdapter) {
|
api.evidence("Bearer ${session.accessToken}", findingId)
|
||||||
authorized { session -> api.evidence("Bearer ${session.accessToken}", findingId) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun uploadObservationPhoto(
|
suspend fun uploadObservationPhoto(
|
||||||
findingId: String,
|
findingId: String,
|
||||||
@@ -235,7 +211,6 @@ class FieldFindingsRepository(context: Context) {
|
|||||||
title: String? = null,
|
title: String? = null,
|
||||||
description: String? = null,
|
description: String? = null,
|
||||||
capturedAt: String = Instant.now().toString(),
|
capturedAt: String = Instant.now().toString(),
|
||||||
operationId: String? = null,
|
|
||||||
): FieldFindingEvidence = authorized { session ->
|
): FieldFindingEvidence = authorized { session ->
|
||||||
val text = "text/plain".toMediaType()
|
val text = "text/plain".toMediaType()
|
||||||
val part = MultipartBody.Part.createFormData(
|
val part = MultipartBody.Part.createFormData(
|
||||||
@@ -252,28 +227,13 @@ class FieldFindingsRepository(context: Context) {
|
|||||||
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||||
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||||
capturedAt = capturedAt.toRequestBody(text),
|
capturedAt = capturedAt.toRequestBody(text),
|
||||||
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
|
latitude = latitude.toString().toRequestBody(text),
|
||||||
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
|
longitude = longitude.toString().toRequestBody(text),
|
||||||
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
|
accuracyM = accuracyM?.toString()?.toRequestBody(text),
|
||||||
deviceLabel = "DH Android".toRequestBody(text),
|
deviceLabel = "DH Android".toRequestBody(text),
|
||||||
operationId = operationId?.toRequestBody(text),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
|
|
||||||
|
|
||||||
private suspend fun <T> cached(
|
|
||||||
key: String,
|
|
||||||
adapter: JsonAdapter<T>,
|
|
||||||
online: suspend () -> T,
|
|
||||||
): T {
|
|
||||||
return try {
|
|
||||||
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
|
|
||||||
} catch (error: IOException) {
|
|
||||||
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
||||||
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
try {
|
try {
|
||||||
@@ -281,11 +241,18 @@ class FieldFindingsRepository(context: Context) {
|
|||||||
} catch (error: HttpException) {
|
} catch (error: HttpException) {
|
||||||
if (error.code() != 401) throw error
|
if (error.code() != 401) throw error
|
||||||
}
|
}
|
||||||
session = refresh(session)
|
session = refresh(session.refreshToken)
|
||||||
return block(session)
|
return block(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refresh(previous: StoredSession): StoredSession =
|
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
|
if (latest.refreshToken != previousRefreshToken) return@withLock latest
|
||||||
|
try {
|
||||||
|
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
store.clear()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ package com.korexlabs.dhinspeccion.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.korexlabs.dhinspeccion.BuildConfig
|
import com.korexlabs.dhinspeccion.BuildConfig
|
||||||
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
|
|
||||||
import com.squareup.moshi.JsonAdapter
|
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.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
|
||||||
@@ -26,7 +26,6 @@ import retrofit2.http.Part
|
|||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
data class MobileActSummary(
|
data class MobileActSummary(
|
||||||
@@ -38,7 +37,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? = null,
|
val urgency: String = "NON_URGENT",
|
||||||
val deadlineDays: Int? = null,
|
val deadlineDays: Int? = null,
|
||||||
val deadlineDayType: String? = null,
|
val deadlineDayType: String? = null,
|
||||||
val deadlineBasis: String? = null,
|
val deadlineBasis: String? = null,
|
||||||
@@ -63,7 +62,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? = null,
|
val urgency: String = "NON_URGENT",
|
||||||
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,21 +92,16 @@ data class MobileActListResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class CreateMobileActRequest(
|
data class CreateMobileActRequest(
|
||||||
val clientGeneratedId: String? = null,
|
|
||||||
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>? = null,
|
val assetIds: List<String>,
|
||||||
val summary: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileResponsibleRequest(
|
data class MobileResponsibleRequest(
|
||||||
@@ -138,7 +132,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? = null,
|
val urgency: String = "NON_URGENT",
|
||||||
val deadlineDays: Int? = null,
|
val deadlineDays: Int? = null,
|
||||||
val deadlineDayType: String? = null,
|
val deadlineDayType: String? = null,
|
||||||
val deadlineBasis: String? = null,
|
val deadlineBasis: String? = null,
|
||||||
@@ -193,20 +187,11 @@ data class MobileActClosure(
|
|||||||
val act: MobileActClosureHeader,
|
val act: MobileActClosureHeader,
|
||||||
val visit: MobileVisitClosureHeader,
|
val visit: MobileVisitClosureHeader,
|
||||||
val responsible: MobileResponsible? = null,
|
val responsible: MobileResponsible? = null,
|
||||||
val representativeSuggestion: MobileResponsible? = null,
|
|
||||||
val closure: MobileClosureRecord? = null,
|
val closure: MobileClosureRecord? = null,
|
||||||
val signatures: List<MobileSignature> = emptyList(),
|
val signatures: List<MobileSignature> = emptyList(),
|
||||||
val consents: MobileClosureConsents = MobileClosureConsents(),
|
val consents: MobileClosureConsents = MobileClosureConsents(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileProfileSignatureStatus(
|
|
||||||
val configured: Boolean = false,
|
|
||||||
val mimeType: String? = null,
|
|
||||||
val sizeBytes: Int? = null,
|
|
||||||
val imageSha256: String? = null,
|
|
||||||
val updatedAt: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MobileCompanyOutcomeRequest(
|
data class MobileCompanyOutcomeRequest(
|
||||||
val status: String,
|
val status: String,
|
||||||
val reason: String,
|
val reason: String,
|
||||||
@@ -214,7 +199,7 @@ data class MobileCompanyOutcomeRequest(
|
|||||||
|
|
||||||
data class MobileSealActRequest(
|
data class MobileSealActRequest(
|
||||||
val clientClosedAt: String = Instant.now().toString(),
|
val clientClosedAt: String = Instant.now().toString(),
|
||||||
val uploadMode: String = "IMMEDIATE",
|
val uploadMode: String = "ONLINE",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileCloseVisitRequest(
|
data class MobileCloseVisitRequest(
|
||||||
@@ -249,11 +234,6 @@ private interface MobileActsApi {
|
|||||||
@Body request: UpdateMobileActRequest,
|
@Body request: UpdateMobileActRequest,
|
||||||
): MobileActDetail
|
): MobileActDetail
|
||||||
|
|
||||||
@GET("users/self/signature")
|
|
||||||
suspend fun selfSignature(
|
|
||||||
@Header("Authorization") authorization: String,
|
|
||||||
): MobileProfileSignatureStatus
|
|
||||||
|
|
||||||
@GET("inspection-acts/{actId}/closure")
|
@GET("inspection-acts/{actId}/closure")
|
||||||
suspend fun closure(
|
suspend fun closure(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@@ -271,7 +251,6 @@ 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
|
||||||
@@ -330,14 +309,9 @@ private interface MobileActsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MobileActsRepository(context: Context) {
|
class MobileActsRepository(context: Context) {
|
||||||
private val appContext = context.applicationContext
|
private val store = SecureSessionStore(context.applicationContext)
|
||||||
private val store = SecureSessionStore(appContext)
|
private val refreshMutex = Mutex()
|
||||||
private val cache = OfflineJsonCache(appContext)
|
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||||
private val actListAdapter = moshi.adapter(MobileActListResponse::class.java)
|
|
||||||
private val actAdapter = moshi.adapter(MobileActDetail::class.java)
|
|
||||||
private val closureAdapter = moshi.adapter(MobileActClosure::class.java)
|
|
||||||
private val signatureStatusAdapter = moshi.adapter(MobileProfileSignatureStatus::class.java)
|
|
||||||
private val api: MobileActsApi = Retrofit.Builder()
|
private val api: MobileActsApi = Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
.client(OkHttpClient.Builder().build())
|
.client(OkHttpClient.Builder().build())
|
||||||
@@ -345,40 +319,31 @@ class MobileActsRepository(context: Context) {
|
|||||||
.build()
|
.build()
|
||||||
.create(MobileActsApi::class.java)
|
.create(MobileActsApi::class.java)
|
||||||
|
|
||||||
suspend fun list(visitId: String): MobileActListResponse =
|
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
|
||||||
cached("acts:$visitId", actListAdapter) {
|
api.listActs("Bearer ${session.accessToken}", visitId)
|
||||||
authorized { session -> api.listActs("Bearer ${session.accessToken}", visitId) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun get(actId: String): MobileActDetail = cached("act:$actId", actAdapter) {
|
suspend fun get(actId: String): MobileActDetail = authorized { session ->
|
||||||
authorized { session -> api.act("Bearer ${session.accessToken}", actId) }
|
api.act("Bearer ${session.accessToken}", actId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
visitId: String,
|
visitId: String,
|
||||||
|
assetId: String? = null,
|
||||||
visitCode: String,
|
visitCode: String,
|
||||||
clientGeneratedId: String? = null,
|
urgency: String = "NON_URGENT",
|
||||||
occurredAt: String = Instant.now().toString(),
|
): MobileActDetail = authorized { session ->
|
||||||
): MobileActDetail {
|
api.createAct(
|
||||||
val created = authorized { session ->
|
"Bearer ${session.accessToken}",
|
||||||
api.createAct(
|
visitId,
|
||||||
"Bearer ${session.accessToken}",
|
CreateMobileActRequest(
|
||||||
visitId,
|
occurredAt = Instant.now().toString(),
|
||||||
CreateMobileActRequest(
|
urgency = urgency,
|
||||||
clientGeneratedId = clientGeneratedId,
|
title = "Acta de inspección $visitCode",
|
||||||
occurredAt = occurredAt,
|
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
||||||
title = "Acta de inspección $visitCode",
|
assetIds = listOfNotNull(assetId),
|
||||||
summary = "Se inició la inspección $visitCode.",
|
),
|
||||||
assetIds = emptyList(),
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
cache.put(userCacheKey("act:${created.id}"), actAdapter.toJson(created))
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateSummary(actId: String, summary: String): MobileActDetail = authorized { session ->
|
|
||||||
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(summary = summary))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
|
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
|
||||||
@@ -390,25 +355,16 @@ class MobileActsRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closure(actId: String): MobileActClosure = cached("act-closure:$actId", closureAdapter) {
|
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
|
||||||
authorized { session -> api.closure("Bearer ${session.accessToken}", actId) }
|
api.closure("Bearer ${session.accessToken}", actId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun selfSignatureStatus(): MobileProfileSignatureStatus =
|
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
|
||||||
cached("self-signature-status", signatureStatusAdapter) {
|
api.responsible("Bearer ${session.accessToken}", actId, request)
|
||||||
authorized { session -> api.selfSignature("Bearer ${session.accessToken}") }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure {
|
|
||||||
val value = authorized { session -> api.responsible("Bearer ${session.accessToken}", actId, request) }
|
|
||||||
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun lock(actId: String, urgency: String): MobileActClosure {
|
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
|
||||||
val value = authorized { session -> api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) }
|
api.lock("Bearer ${session.accessToken}", actId)
|
||||||
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signInspector(
|
suspend fun signInspector(
|
||||||
@@ -427,7 +383,6 @@ class MobileActsRepository(context: Context) {
|
|||||||
accuracyM: Double?,
|
accuracyM: Double?,
|
||||||
manifestation: String = "CONFORMITY",
|
manifestation: String = "CONFORMITY",
|
||||||
statement: String? = null,
|
statement: String? = null,
|
||||||
clientSignedAt: String = Instant.now().toString(),
|
|
||||||
): MobileActClosure = signature(
|
): MobileActClosure = signature(
|
||||||
actId = actId,
|
actId = actId,
|
||||||
png = png,
|
png = png,
|
||||||
@@ -437,38 +392,22 @@ class MobileActsRepository(context: Context) {
|
|||||||
company = true,
|
company = true,
|
||||||
manifestation = manifestation,
|
manifestation = manifestation,
|
||||||
statement = statement,
|
statement = statement,
|
||||||
clientSignedAt = clientSignedAt,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure {
|
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
|
||||||
val value = authorized { session ->
|
api.companyOutcome(
|
||||||
api.companyOutcome(
|
"Bearer ${session.accessToken}",
|
||||||
"Bearer ${session.accessToken}",
|
actId,
|
||||||
actId,
|
MobileCompanyOutcomeRequest(status, reason.trim()),
|
||||||
MobileCompanyOutcomeRequest(status, reason.trim()),
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun seal(
|
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
|
||||||
actId: String,
|
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
|
||||||
uploadMode: String = "IMMEDIATE",
|
|
||||||
clientClosedAt: String = Instant.now().toString(),
|
|
||||||
): MobileActClosure {
|
|
||||||
val value = authorized { session ->
|
|
||||||
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest(clientClosedAt, uploadMode))
|
|
||||||
}
|
|
||||||
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closeVisit(
|
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
||||||
visitId: String,
|
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
|
||||||
clientClosedAt: String = Instant.now().toString(),
|
|
||||||
): VisitDetail = authorized { session ->
|
|
||||||
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest(clientClosedAt))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun signature(
|
private suspend fun signature(
|
||||||
@@ -480,7 +419,6 @@ class MobileActsRepository(context: Context) {
|
|||||||
company: Boolean,
|
company: Boolean,
|
||||||
manifestation: String? = null,
|
manifestation: String? = null,
|
||||||
statement: String? = null,
|
statement: String? = null,
|
||||||
clientSignedAt: String = Instant.now().toString(),
|
|
||||||
): MobileActClosure = authorized { session ->
|
): MobileActClosure = authorized { session ->
|
||||||
val text = "text/plain".toMediaType()
|
val text = "text/plain".toMediaType()
|
||||||
val file = MultipartBody.Part.createFormData(
|
val file = MultipartBody.Part.createFormData(
|
||||||
@@ -489,11 +427,11 @@ class MobileActsRepository(context: Context) {
|
|||||||
png.asRequestBody("image/png".toMediaType()),
|
png.asRequestBody("image/png".toMediaType()),
|
||||||
)
|
)
|
||||||
val consent = "true".toRequestBody(text)
|
val consent = "true".toRequestBody(text)
|
||||||
val signedAt = clientSignedAt.toRequestBody(text)
|
val signedAt = Instant.now().toString().toRequestBody(text)
|
||||||
val device = "DH Android".toRequestBody(text)
|
val device = "DH Android".toRequestBody(text)
|
||||||
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
|
val lat = latitude?.toString()?.toRequestBody(text)
|
||||||
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
|
val lon = longitude?.toString()?.toRequestBody(text)
|
||||||
val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
|
val accuracy = accuracyM?.toString()?.toRequestBody(text)
|
||||||
if (company) {
|
if (company) {
|
||||||
api.signCompany(
|
api.signCompany(
|
||||||
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
|
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
|
||||||
@@ -509,20 +447,6 @@ class MobileActsRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
|
|
||||||
|
|
||||||
private suspend fun <T> cached(
|
|
||||||
key: String,
|
|
||||||
adapter: JsonAdapter<T>,
|
|
||||||
online: suspend () -> T,
|
|
||||||
): T {
|
|
||||||
return try {
|
|
||||||
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
|
|
||||||
} catch (error: IOException) {
|
|
||||||
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
||||||
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
try {
|
try {
|
||||||
@@ -530,11 +454,18 @@ class MobileActsRepository(context: Context) {
|
|||||||
} catch (error: HttpException) {
|
} catch (error: HttpException) {
|
||||||
if (error.code() != 401) throw error
|
if (error.code() != 401) throw error
|
||||||
}
|
}
|
||||||
session = refresh(session)
|
session = refresh(session.refreshToken)
|
||||||
return block(session)
|
return block(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refresh(previous: StoredSession): StoredSession =
|
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
|
if (latest.refreshToken != previousRefreshToken) return@withLock latest
|
||||||
|
try {
|
||||||
|
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
store.clear()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import com.korexlabs.dhinspeccion.BuildConfig
|
import com.korexlabs.dhinspeccion.BuildConfig
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -53,6 +55,7 @@ private interface MobileInspectionOpenApi {
|
|||||||
|
|
||||||
class MobileInspectionOpenRepository(context: Context) {
|
class MobileInspectionOpenRepository(context: Context) {
|
||||||
private val store = SecureSessionStore(context.applicationContext)
|
private val store = SecureSessionStore(context.applicationContext)
|
||||||
|
private val refreshMutex = Mutex()
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||||
private val api: MobileInspectionOpenApi = Retrofit.Builder()
|
private val api: MobileInspectionOpenApi = Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(BuildConfig.API_BASE_URL)
|
||||||
@@ -83,11 +86,18 @@ class MobileInspectionOpenRepository(context: Context) {
|
|||||||
} catch (error: HttpException) {
|
} catch (error: HttpException) {
|
||||||
if (error.code() != 401) throw error
|
if (error.code() != 401) throw error
|
||||||
}
|
}
|
||||||
session = refresh(session)
|
session = refresh(session.refreshToken)
|
||||||
return block(session)
|
return block(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refresh(previous: StoredSession): StoredSession =
|
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
|
if (latest.refreshToken != previousRefreshToken) return@withLock latest
|
||||||
|
try {
|
||||||
|
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
store.clear()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.data
|
|
||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import retrofit2.HttpException
|
|
||||||
|
|
||||||
/** One refresh lock for every API client sharing the encrypted session. */
|
|
||||||
internal object MobileSessionCoordinator {
|
|
||||||
private val mutex = Mutex()
|
|
||||||
|
|
||||||
suspend fun refresh(
|
|
||||||
previous: StoredSession,
|
|
||||||
load: () -> StoredSession?,
|
|
||||||
save: (MobileSessionResponse) -> StoredSession,
|
|
||||||
clear: () -> Unit,
|
|
||||||
request: suspend (RefreshRequest) -> MobileSessionResponse,
|
|
||||||
): StoredSession = mutex.withLock {
|
|
||||||
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
|
|
||||||
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
|
|
||||||
if (latest.refreshToken != previous.refreshToken) return@withLock latest
|
|
||||||
val response = try {
|
|
||||||
request(RefreshRequest(previous.refreshToken))
|
|
||||||
} catch (error: HttpException) {
|
|
||||||
// Network failures and server outages must not discard the saved login.
|
|
||||||
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
// A logout or another login during the request must never be undone.
|
|
||||||
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
|
|
||||||
save(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.korexlabs.dhinspeccion.BuildConfig
|
|
||||||
import com.squareup.moshi.Moshi
|
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
|
||||||
import retrofit2.HttpException
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
|
||||||
import retrofit2.http.*
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
internal data class TechnicalDefinition(
|
|
||||||
val id: String, val code: String, val name: String, val dataType: String,
|
|
||||||
val isRequired: Boolean = false, val isActive: Boolean = true,
|
|
||||||
val unit: String? = null, val options: List<String>? = null,
|
|
||||||
)
|
|
||||||
internal data class TechnicalValues(
|
|
||||||
val assetId: String, val family: FieldInventoryFamily,
|
|
||||||
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
|
|
||||||
)
|
|
||||||
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
|
|
||||||
private interface TechnicalApi {
|
|
||||||
@GET("assets/{id}/technical-values")
|
|
||||||
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
|
|
||||||
@PUT("assets/{id}/technical-values")
|
|
||||||
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
|
|
||||||
@POST("auth/mobile/refresh")
|
|
||||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
|
||||||
}
|
|
||||||
internal class TechnicalRepository(context: Context) {
|
|
||||||
private val store = SecureSessionStore(context.applicationContext)
|
|
||||||
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
|
|
||||||
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
|
|
||||||
.build().create(TechnicalApi::class.java)
|
|
||||||
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
|
|
||||||
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
|
|
||||||
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
|
|
||||||
}
|
|
||||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
|
||||||
val previous = store.load() ?: error("Sesión no iniciada")
|
|
||||||
try { return block(previous) }
|
|
||||||
catch (error: HttpException) { if (error.code() != 401) throw error }
|
|
||||||
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
|
|
||||||
return block(current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
|
|
||||||
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
|
|
||||||
val value = text.trim()
|
|
||||||
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
|
|
||||||
if (value.isBlank()) return null
|
|
||||||
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
|
|
||||||
return when (definition.dataType) {
|
|
||||||
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
|
|
||||||
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
|
|
||||||
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
|
|
||||||
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
|
|
||||||
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
|
|
||||||
else -> value.takeIf { it.length <= 4000 } ?: invalid()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-632
@@ -1,632 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.data.offline
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Room
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
import androidx.work.Constraints
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
|
||||||
import androidx.work.NetworkType
|
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
|
|
||||||
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
|
|
||||||
import com.korexlabs.dhinspeccion.data.DhRepository
|
|
||||||
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobileActsRepository
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
|
|
||||||
import com.korexlabs.dhinspeccion.data.SecureSessionStore
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONObject
|
|
||||||
import retrofit2.HttpException
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.UUID
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
@Entity(tableName = "pending_mobile_operations")
|
|
||||||
data class PendingMobileOperation(
|
|
||||||
@PrimaryKey val id: String,
|
|
||||||
val ownerUserId: String,
|
|
||||||
val type: String,
|
|
||||||
val resourceId: String,
|
|
||||||
val payloadJson: String,
|
|
||||||
val filePath: String? = null,
|
|
||||||
val state: String = "PENDING",
|
|
||||||
val attempts: Int = 0,
|
|
||||||
val lastError: String? = null,
|
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface PendingMobileOperationDao {
|
|
||||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
|
||||||
suspend fun insert(value: PendingMobileOperation)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR') ORDER BY createdAt ASC")
|
|
||||||
suspend fun pending(ownerUserId: String): List<PendingMobileOperation>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR','SYNCING')")
|
|
||||||
suspend fun pendingCount(ownerUserId: String): Int
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND resourceId=:resourceId AND type=:type AND state IN ('PENDING','ERROR','SYNCING')")
|
|
||||||
suspend fun hasPending(ownerUserId: String, type: String, resourceId: String): Int
|
|
||||||
|
|
||||||
@Query("UPDATE pending_mobile_operations SET state=:state, attempts=:attempts, lastError=:lastError, updatedAt=:updatedAt WHERE id=:id")
|
|
||||||
suspend fun mark(id: String, state: String, attempts: Int, lastError: String?, updatedAt: Long = System.currentTimeMillis())
|
|
||||||
|
|
||||||
@Query("DELETE FROM pending_mobile_operations WHERE id=:id")
|
|
||||||
suspend fun delete(id: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Entity(tableName = "mobile_offline_cache")
|
|
||||||
data class OfflineCacheEntry(
|
|
||||||
@PrimaryKey val cacheKey: String,
|
|
||||||
val json: String,
|
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface OfflineCacheDao {
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun put(value: OfflineCacheEntry)
|
|
||||||
|
|
||||||
@Query("SELECT * FROM mobile_offline_cache WHERE cacheKey=:key LIMIT 1")
|
|
||||||
suspend fun get(key: String): OfflineCacheEntry?
|
|
||||||
|
|
||||||
@Query("DELETE FROM mobile_offline_cache WHERE cacheKey=:key")
|
|
||||||
suspend fun delete(key: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Database(entities = [PendingMobileOperation::class, OfflineCacheEntry::class], version = 1, exportSchema = false)
|
|
||||||
abstract class OfflineQueueDatabase : RoomDatabase() {
|
|
||||||
abstract fun operations(): PendingMobileOperationDao
|
|
||||||
abstract fun cache(): OfflineCacheDao
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@Volatile private var instance: OfflineQueueDatabase? = null
|
|
||||||
|
|
||||||
fun get(context: Context): OfflineQueueDatabase = instance ?: synchronized(this) {
|
|
||||||
instance ?: Room.databaseBuilder(
|
|
||||||
context.applicationContext,
|
|
||||||
OfflineQueueDatabase::class.java,
|
|
||||||
"dh_mobile_offline_queue.db",
|
|
||||||
).build().also { instance = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class OfflineJsonCache(context: Context) {
|
|
||||||
private val dao = OfflineQueueDatabase.get(context).cache()
|
|
||||||
|
|
||||||
suspend fun put(key: String, json: String) {
|
|
||||||
dao.put(OfflineCacheEntry(key, json))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun get(key: String): String? = dao.get(key)?.json
|
|
||||||
|
|
||||||
suspend fun delete(key: String) = dao.delete(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
class OfflineMutationQueue(private val context: Context) {
|
|
||||||
private val dao = OfflineQueueDatabase.get(context).operations()
|
|
||||||
private val sessions = SecureSessionStore(context.applicationContext)
|
|
||||||
private val mediaRoot = File(context.filesDir, "offline-media").apply { mkdirs() }
|
|
||||||
|
|
||||||
suspend fun pendingCount(): Int = currentOwnerOrNull()?.let { dao.pendingCount(it) } ?: 0
|
|
||||||
|
|
||||||
suspend fun hasPending(type: String, resourceId: String): Boolean =
|
|
||||||
currentOwnerOrNull()?.let { dao.hasPending(it, type, resourceId) > 0 } ?: false
|
|
||||||
|
|
||||||
suspend fun enqueueFindingCreate(
|
|
||||||
findingId: String,
|
|
||||||
visitId: String,
|
|
||||||
assetId: String,
|
|
||||||
request: CreateFieldFindingRequest,
|
|
||||||
): String = enqueueJson(
|
|
||||||
type = "FINDING_CREATE",
|
|
||||||
resourceId = findingId,
|
|
||||||
payload = JSONObject()
|
|
||||||
.put("visitId", visitId)
|
|
||||||
.put("assetId", assetId)
|
|
||||||
.put("actId", request.actId)
|
|
||||||
.put("clientGeneratedId", request.clientGeneratedId ?: findingId)
|
|
||||||
.put("catalogItemId", request.catalogItemId)
|
|
||||||
.put("customTitle", request.customTitle)
|
|
||||||
.put("customLegalBasis", request.customLegalBasis)
|
|
||||||
.put("description", request.description)
|
|
||||||
.put("severity", request.severity)
|
|
||||||
.toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueFindingPhoto(
|
|
||||||
findingId: String,
|
|
||||||
source: File,
|
|
||||||
latitude: Double,
|
|
||||||
longitude: Double,
|
|
||||||
accuracyM: Double?,
|
|
||||||
title: String?,
|
|
||||||
description: String?,
|
|
||||||
capturedAt: String = Instant.now().toString(),
|
|
||||||
): String = persistWithFile("FINDING_PHOTO", findingId, source, "jpg") { id ->
|
|
||||||
JSONObject()
|
|
||||||
.put("operationId", id)
|
|
||||||
.put("latitude", latitude)
|
|
||||||
.put("longitude", longitude)
|
|
||||||
.put("accuracyM", accuracyM)
|
|
||||||
.put("title", title)
|
|
||||||
.put("description", description)
|
|
||||||
.put("capturedAt", capturedAt)
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun enqueueVisitStart(visitId: String): String = enqueueJson(
|
|
||||||
type = "VISIT_START",
|
|
||||||
resourceId = visitId,
|
|
||||||
payload = JSONObject().toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueVisitClose(
|
|
||||||
visitId: String,
|
|
||||||
clientClosedAt: String = Instant.now().toString(),
|
|
||||||
): String = enqueueJson(
|
|
||||||
type = "VISIT_CLOSE",
|
|
||||||
resourceId = visitId,
|
|
||||||
payload = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueFieldAssetCreate(
|
|
||||||
assetId: String,
|
|
||||||
visitId: String,
|
|
||||||
request: CreateFieldInventoryRequest,
|
|
||||||
): String = enqueueJson(
|
|
||||||
type = "FIELD_ASSET_CREATE",
|
|
||||||
resourceId = assetId,
|
|
||||||
payload = JSONObject()
|
|
||||||
.put("visitId", visitId)
|
|
||||||
.put("typeId", request.typeId)
|
|
||||||
.put("parentId", request.parentId)
|
|
||||||
.put("familyId", request.familyId)
|
|
||||||
.put("code", request.code)
|
|
||||||
.put("name", request.name)
|
|
||||||
.put("commonName", request.commonName)
|
|
||||||
.put("description", request.description)
|
|
||||||
.put("discoveryNotes", request.discoveryNotes)
|
|
||||||
.put("attributes", JSONObject(request.attributes))
|
|
||||||
.put("deviceLatitude", request.deviceLatitude)
|
|
||||||
.put("deviceLongitude", request.deviceLongitude)
|
|
||||||
.put("deviceAccuracyM", request.deviceAccuracyM)
|
|
||||||
.put("deviceCapturedAt", request.deviceCapturedAt)
|
|
||||||
.put("deviceLabel", request.deviceLabel)
|
|
||||||
.toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueFieldAssetPhoto(
|
|
||||||
assetId: String,
|
|
||||||
visitId: String,
|
|
||||||
source: File,
|
|
||||||
latitude: Double,
|
|
||||||
longitude: Double,
|
|
||||||
accuracyM: Double?,
|
|
||||||
capturedAt: String = Instant.now().toString(),
|
|
||||||
): String = persistWithFile("FIELD_ASSET_PHOTO", assetId, source, "jpg") { id ->
|
|
||||||
JSONObject()
|
|
||||||
.put("visitId", visitId)
|
|
||||||
.put("operationId", id)
|
|
||||||
.put("latitude", latitude)
|
|
||||||
.put("longitude", longitude)
|
|
||||||
.put("accuracyM", accuracyM)
|
|
||||||
.put("capturedAt", capturedAt)
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun enqueueFieldAssetSelect(visitId: String, assetId: String): String = enqueueJson(
|
|
||||||
type = "FIELD_ASSET_SELECT",
|
|
||||||
resourceId = assetId,
|
|
||||||
payload = JSONObject().put("visitId", visitId).toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueActCreate(
|
|
||||||
actId: String,
|
|
||||||
visitId: String,
|
|
||||||
visitCode: String,
|
|
||||||
occurredAt: String,
|
|
||||||
): String = enqueueJson(
|
|
||||||
type = "ACT_CREATE",
|
|
||||||
resourceId = actId,
|
|
||||||
payload = JSONObject()
|
|
||||||
.put("visitId", visitId)
|
|
||||||
.put("visitCode", visitCode)
|
|
||||||
.put("occurredAt", occurredAt)
|
|
||||||
.toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueActSummary(actId: String, summary: String): String = enqueueJson(
|
|
||||||
type = "ACT_SUMMARY",
|
|
||||||
resourceId = actId,
|
|
||||||
payload = JSONObject().put("summary", summary).toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson(
|
|
||||||
type = "ACT_ASSET_ENSURE",
|
|
||||||
resourceId = actId,
|
|
||||||
payload = JSONObject().put("assetId", assetId).toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueResponsible(actId: String, request: MobileResponsibleRequest): String = enqueueJson(
|
|
||||||
type = "ACT_RESPONSIBLE",
|
|
||||||
resourceId = actId,
|
|
||||||
payload = JSONObject()
|
|
||||||
.put("attendanceStatus", request.attendanceStatus)
|
|
||||||
.put("fullName", request.fullName)
|
|
||||||
.put("documentType", request.documentType)
|
|
||||||
.put("documentNumber", request.documentNumber)
|
|
||||||
.put("position", request.position)
|
|
||||||
.put("email", request.email)
|
|
||||||
.put("phone", request.phone)
|
|
||||||
.put("absenceReason", request.absenceReason)
|
|
||||||
.toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueActLock(actId: String, urgency: String): String = enqueueJson(
|
|
||||||
type = "ACT_LOCK",
|
|
||||||
resourceId = actId,
|
|
||||||
payload = JSONObject().put("urgency", urgency).toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueCompanyOutcome(actId: String, status: String, reason: String): String = enqueueJson(
|
|
||||||
type = "COMPANY_OUTCOME",
|
|
||||||
resourceId = actId,
|
|
||||||
payload = JSONObject().put("status", status).put("reason", reason).toString(),
|
|
||||||
)
|
|
||||||
|
|
||||||
suspend fun enqueueCompanySignature(
|
|
||||||
actId: String,
|
|
||||||
source: File,
|
|
||||||
latitude: Double?,
|
|
||||||
longitude: Double?,
|
|
||||||
accuracyM: Double?,
|
|
||||||
manifestation: String,
|
|
||||||
statement: String?,
|
|
||||||
clientSignedAt: String = Instant.now().toString(),
|
|
||||||
): String = persistWithFile("COMPANY_SIGNATURE", actId, source, "png") { _ ->
|
|
||||||
JSONObject()
|
|
||||||
.put("latitude", latitude)
|
|
||||||
.put("longitude", longitude)
|
|
||||||
.put("accuracyM", accuracyM)
|
|
||||||
.put("manifestation", manifestation)
|
|
||||||
.put("statement", statement)
|
|
||||||
.put("clientSignedAt", clientSignedAt)
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun enqueueSeal(
|
|
||||||
actId: String,
|
|
||||||
clientClosedAt: String = Instant.now().toString(),
|
|
||||||
): String {
|
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
dao.insert(
|
|
||||||
PendingMobileOperation(
|
|
||||||
id = id,
|
|
||||||
ownerUserId = currentOwner(),
|
|
||||||
type = "ACT_SEAL",
|
|
||||||
resourceId = actId,
|
|
||||||
payloadJson = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
|
|
||||||
createdAt = nextCreatedAt(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
schedule(context)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun enqueueJson(type: String, resourceId: String, payload: String): String {
|
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
dao.insert(PendingMobileOperation(
|
|
||||||
id = id,
|
|
||||||
ownerUserId = currentOwner(),
|
|
||||||
type = type,
|
|
||||||
resourceId = resourceId,
|
|
||||||
payloadJson = payload,
|
|
||||||
createdAt = nextCreatedAt(),
|
|
||||||
))
|
|
||||||
schedule(context)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun persistWithFile(
|
|
||||||
type: String,
|
|
||||||
resourceId: String,
|
|
||||||
source: File,
|
|
||||||
extension: String,
|
|
||||||
payload: (String) -> String,
|
|
||||||
): String = withContext(Dispatchers.IO) {
|
|
||||||
val id = UUID.randomUUID().toString()
|
|
||||||
val target = File(mediaRoot, "$id.$extension")
|
|
||||||
source.copyTo(target, overwrite = false)
|
|
||||||
dao.insert(
|
|
||||||
PendingMobileOperation(
|
|
||||||
id = id,
|
|
||||||
ownerUserId = currentOwner(),
|
|
||||||
type = type,
|
|
||||||
resourceId = resourceId,
|
|
||||||
payloadJson = payload(id),
|
|
||||||
filePath = target.absolutePath,
|
|
||||||
createdAt = nextCreatedAt(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
source.delete()
|
|
||||||
schedule(context)
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun syncAll(): SyncResult = syncMutex.withLock {
|
|
||||||
val ownerUserId = currentOwnerOrNull() ?: return@withLock SyncResult(0, 0, retry = false)
|
|
||||||
var synced = 0
|
|
||||||
var lastError: String? = null
|
|
||||||
for (operation in dao.pending(ownerUserId)) {
|
|
||||||
val attempt = operation.attempts + 1
|
|
||||||
dao.mark(operation.id, "SYNCING", attempt, null)
|
|
||||||
try {
|
|
||||||
syncOne(operation)
|
|
||||||
operation.filePath?.let { File(it).delete() }
|
|
||||||
dao.delete(operation.id)
|
|
||||||
synced += 1
|
|
||||||
} catch (error: IOException) {
|
|
||||||
dao.mark(operation.id, "PENDING", attempt, error.message)
|
|
||||||
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = true, lastError = error.message)
|
|
||||||
} catch (error: HttpException) {
|
|
||||||
if (isAlreadyApplied(operation, error)) {
|
|
||||||
operation.filePath?.let { File(it).delete() }
|
|
||||||
dao.delete(operation.id)
|
|
||||||
synced += 1
|
|
||||||
} else {
|
|
||||||
lastError = httpError(error)
|
|
||||||
dao.mark(operation.id, "ERROR", attempt, lastError)
|
|
||||||
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
|
|
||||||
}
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
lastError = error.message ?: error::class.java.simpleName
|
|
||||||
dao.mark(operation.id, "ERROR", attempt, lastError)
|
|
||||||
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun syncOne(operation: PendingMobileOperation) {
|
|
||||||
val payload = JSONObject(operation.payloadJson)
|
|
||||||
when (operation.type) {
|
|
||||||
"VISIT_START" -> DhRepository(context).startVisit(operation.resourceId)
|
|
||||||
"VISIT_CLOSE" -> MobileActsRepository(context).closeVisit(
|
|
||||||
operation.resourceId,
|
|
||||||
payload.getString("clientClosedAt"),
|
|
||||||
)
|
|
||||||
"FIELD_ASSET_CREATE" -> DhRepository(context).createFieldAsset(
|
|
||||||
payload.getString("visitId"),
|
|
||||||
CreateFieldInventoryRequest(
|
|
||||||
clientGeneratedId = operation.resourceId,
|
|
||||||
typeId = payload.getString("typeId"),
|
|
||||||
parentId = payload.optNullableString("parentId"),
|
|
||||||
familyId = payload.optNullableString("familyId"),
|
|
||||||
code = payload.optNullableString("code"),
|
|
||||||
name = payload.getString("name"),
|
|
||||||
commonName = payload.optNullableString("commonName"),
|
|
||||||
description = payload.optNullableString("description"),
|
|
||||||
discoveryNotes = payload.optNullableString("discoveryNotes"),
|
|
||||||
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
|
|
||||||
deviceLatitude = payload.getDouble("deviceLatitude"),
|
|
||||||
deviceLongitude = payload.getDouble("deviceLongitude"),
|
|
||||||
deviceAccuracyM = payload.optDoubleOrNull("deviceAccuracyM"),
|
|
||||||
deviceCapturedAt = payload.getString("deviceCapturedAt"),
|
|
||||||
deviceLabel = payload.optString("deviceLabel", "DH Android"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"FIELD_ASSET_PHOTO" -> DhRepository(context).uploadFieldPhoto(
|
|
||||||
visitId = payload.getString("visitId"),
|
|
||||||
assetId = operation.resourceId,
|
|
||||||
file = requiredFile(operation),
|
|
||||||
latitude = payload.getDouble("latitude"),
|
|
||||||
longitude = payload.getDouble("longitude"),
|
|
||||||
accuracyM = payload.optDoubleOrNull("accuracyM"),
|
|
||||||
capturedAt = payload.getString("capturedAt"),
|
|
||||||
operationId = payload.getString("operationId"),
|
|
||||||
)
|
|
||||||
"FIELD_ASSET_SELECT" -> DhRepository(context).selectFieldAsset(
|
|
||||||
payload.getString("visitId"),
|
|
||||||
operation.resourceId,
|
|
||||||
)
|
|
||||||
"ACT_CREATE" -> MobileActsRepository(context).create(
|
|
||||||
visitId = payload.getString("visitId"),
|
|
||||||
visitCode = payload.getString("visitCode"),
|
|
||||||
clientGeneratedId = operation.resourceId,
|
|
||||||
occurredAt = payload.getString("occurredAt"),
|
|
||||||
)
|
|
||||||
"ACT_SUMMARY" -> MobileActsRepository(context).updateSummary(
|
|
||||||
operation.resourceId,
|
|
||||||
payload.getString("summary"),
|
|
||||||
)
|
|
||||||
"ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset(
|
|
||||||
operation.resourceId,
|
|
||||||
payload.getString("assetId"),
|
|
||||||
)
|
|
||||||
"FINDING_CREATE" -> FieldFindingsRepository(context).create(
|
|
||||||
payload.getString("visitId"),
|
|
||||||
payload.getString("assetId"),
|
|
||||||
CreateFieldFindingRequest(
|
|
||||||
actId = payload.getString("actId"),
|
|
||||||
clientGeneratedId = payload.getString("clientGeneratedId"),
|
|
||||||
catalogItemId = payload.optNullableString("catalogItemId"),
|
|
||||||
customTitle = payload.optNullableString("customTitle"),
|
|
||||||
customLegalBasis = payload.optNullableString("customLegalBasis"),
|
|
||||||
description = payload.getString("description"),
|
|
||||||
severity = if (!payload.has("severity") || payload.isNull("severity")) null else payload.getInt("severity"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"ACT_RESPONSIBLE" -> MobileActsRepository(context).setResponsible(
|
|
||||||
operation.resourceId,
|
|
||||||
MobileResponsibleRequest(
|
|
||||||
attendanceStatus = payload.getString("attendanceStatus"),
|
|
||||||
fullName = payload.optNullableString("fullName"),
|
|
||||||
documentType = payload.optNullableString("documentType"),
|
|
||||||
documentNumber = payload.optNullableString("documentNumber"),
|
|
||||||
position = payload.optNullableString("position"),
|
|
||||||
email = payload.optNullableString("email"),
|
|
||||||
phone = payload.optNullableString("phone"),
|
|
||||||
absenceReason = payload.optNullableString("absenceReason"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
"ACT_LOCK" -> MobileActsRepository(context).lock(
|
|
||||||
operation.resourceId,
|
|
||||||
payload.getString("urgency"),
|
|
||||||
)
|
|
||||||
"COMPANY_OUTCOME" -> MobileActsRepository(context).companyOutcome(
|
|
||||||
operation.resourceId,
|
|
||||||
payload.getString("status"),
|
|
||||||
payload.getString("reason"),
|
|
||||||
)
|
|
||||||
"FINDING_PHOTO" -> {
|
|
||||||
val file = requiredFile(operation)
|
|
||||||
FieldFindingsRepository(context).uploadObservationPhoto(
|
|
||||||
findingId = operation.resourceId,
|
|
||||||
file = file,
|
|
||||||
latitude = payload.getDouble("latitude"),
|
|
||||||
longitude = payload.getDouble("longitude"),
|
|
||||||
accuracyM = payload.optDoubleOrNull("accuracyM"),
|
|
||||||
title = payload.optNullableString("title"),
|
|
||||||
description = payload.optNullableString("description"),
|
|
||||||
capturedAt = payload.getString("capturedAt"),
|
|
||||||
operationId = payload.getString("operationId"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"COMPANY_SIGNATURE" -> {
|
|
||||||
val file = requiredFile(operation)
|
|
||||||
MobileActsRepository(context).signCompany(
|
|
||||||
actId = operation.resourceId,
|
|
||||||
png = file,
|
|
||||||
latitude = payload.optDoubleOrNull("latitude"),
|
|
||||||
longitude = payload.optDoubleOrNull("longitude"),
|
|
||||||
accuracyM = payload.optDoubleOrNull("accuracyM"),
|
|
||||||
manifestation = payload.optString("manifestation", "CONFORMITY"),
|
|
||||||
statement = payload.optNullableString("statement"),
|
|
||||||
clientSignedAt = payload.getString("clientSignedAt"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"ACT_SEAL" -> MobileActsRepository(context).seal(
|
|
||||||
actId = operation.resourceId,
|
|
||||||
uploadMode = "DEFERRED",
|
|
||||||
clientClosedAt = payload.getString("clientClosedAt"),
|
|
||||||
)
|
|
||||||
else -> error("Operación offline desconocida: ${operation.type}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun isAlreadyApplied(operation: PendingMobileOperation, error: HttpException): Boolean {
|
|
||||||
if (error.code() != 409) return false
|
|
||||||
return when (operation.type) {
|
|
||||||
"VISIT_START" -> runCatching {
|
|
||||||
DhRepository(context).visit(operation.resourceId).status in setOf("IN_PROGRESS", "CLOSED")
|
|
||||||
}.getOrDefault(false)
|
|
||||||
"VISIT_CLOSE" -> runCatching {
|
|
||||||
DhRepository(context).visit(operation.resourceId).status == "CLOSED"
|
|
||||||
}.getOrDefault(false)
|
|
||||||
"ACT_LOCK" -> runCatching {
|
|
||||||
MobileActsRepository(context).get(operation.resourceId).status != "DRAFT"
|
|
||||||
}.getOrDefault(false)
|
|
||||||
"COMPANY_OUTCOME", "COMPANY_SIGNATURE" -> runCatching {
|
|
||||||
MobileActsRepository(context).closure(operation.resourceId).signatures.any {
|
|
||||||
it.signerType == "COMPANY_RESPONSIBLE" && (it.status == "SIGNED" || it.status == "REFUSED")
|
|
||||||
}
|
|
||||||
}.getOrDefault(false)
|
|
||||||
"ACT_SEAL" -> runCatching {
|
|
||||||
MobileActsRepository(context).get(operation.resourceId).status == "SEALED"
|
|
||||||
}.getOrDefault(false)
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requiredFile(operation: PendingMobileOperation): File {
|
|
||||||
val path = operation.filePath ?: error("La operación ${operation.id} no tiene archivo local")
|
|
||||||
return File(path).also { require(it.isFile) { "Archivo local pendiente inexistente" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentOwnerOrNull(): String? = sessions.load()?.userId
|
|
||||||
|
|
||||||
private fun currentOwner(): String = currentOwnerOrNull()
|
|
||||||
?: throw IllegalStateException("Sesión no iniciada")
|
|
||||||
|
|
||||||
data class SyncResult(val synced: Int, val pending: Int, val retry: Boolean, val lastError: String? = null)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val UNIQUE_WORK = "dh-mobile-offline-sync"
|
|
||||||
private val syncMutex = Mutex()
|
|
||||||
private val queueClock = AtomicLong(System.currentTimeMillis())
|
|
||||||
|
|
||||||
private fun nextCreatedAt(): Long = queueClock.updateAndGet { previous ->
|
|
||||||
maxOf(System.currentTimeMillis(), previous + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun schedule(context: Context) {
|
|
||||||
val constraints = Constraints.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
||||||
.build()
|
|
||||||
val request = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
|
|
||||||
.setConstraints(constraints)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance(context.applicationContext)
|
|
||||||
.enqueueUniqueWork(UNIQUE_WORK, ExistingWorkPolicy.KEEP, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class OfflineSyncWorker(
|
|
||||||
appContext: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(appContext, params) {
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
val result = OfflineMutationQueue(applicationContext).syncAll()
|
|
||||||
return if (result.retry) Result.retry() else Result.success()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun httpError(error: HttpException): String {
|
|
||||||
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull().orEmpty()
|
|
||||||
return runCatching { JSONObject(body).optString("message") }.getOrNull()
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?: "No se pudo sincronizar (HTTP ${error.code()})"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun jsonObjectToMap(value: JSONObject): Map<String, Any?> = buildMap {
|
|
||||||
val keys = value.keys()
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
put(key, jsonValue(value.opt(key)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun jsonValue(value: Any?): Any? = when (value) {
|
|
||||||
null, JSONObject.NULL -> null
|
|
||||||
is JSONObject -> jsonObjectToMap(value)
|
|
||||||
is org.json.JSONArray -> List(value.length()) { index -> jsonValue(value.opt(index)) }
|
|
||||||
else -> value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JSONObject.optDoubleOrNull(name: String): Double? =
|
|
||||||
if (!has(name) || isNull(name)) null else getDouble(name)
|
|
||||||
|
|
||||||
private fun JSONObject.optNullableString(name: String): String? =
|
|
||||||
if (!has(name) || isNull(name)) null else optString(name).takeIf { it.isNotBlank() }
|
|
||||||
@@ -60,7 +60,6 @@ import com.korexlabs.dhinspeccion.MainViewModel
|
|||||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
||||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||||
import com.korexlabs.dhinspeccion.data.FieldType
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
|
|
||||||
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.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -522,7 +521,18 @@ private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||||
fieldAttributePayload(type.attributes, values)
|
type.attributes.mapNotNull { definition ->
|
||||||
|
val raw = values[definition.code]?.trim().orEmpty()
|
||||||
|
if (raw.isBlank()) return@mapNotNull null
|
||||||
|
definition.code to coerceAttribute(definition, raw)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||||
|
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||||
|
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||||
|
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
||||||
|
else -> raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasPermission(context: Context, permission: String): Boolean =
|
private fun hasPermission(context: Context, permission: String): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ import com.korexlabs.dhinspeccion.MainViewModel
|
|||||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
||||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||||
import com.korexlabs.dhinspeccion.data.FieldType
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
|
|
||||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
@@ -694,11 +693,11 @@ private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedType?.familyRequired == true) {
|
if (selectedType?.familyRequired == true) {
|
||||||
Text("Tipo de instalación *", fontWeight = FontWeight.Bold)
|
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = familySearch,
|
value = familySearch,
|
||||||
onValueChange = { familySearch = it },
|
onValueChange = { familySearch = it },
|
||||||
label = { Text("Buscar tipo de instalación") },
|
label = { Text("Buscar clasificación") },
|
||||||
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
|
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
@@ -907,7 +906,18 @@ private fun dynamicStatusLabel(status: String): String = when (status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||||
fieldAttributePayload(type.attributes, values)
|
type.attributes.mapNotNull { definition ->
|
||||||
|
val raw = values[definition.code]?.trim().orEmpty()
|
||||||
|
if (raw.isBlank()) return@mapNotNull null
|
||||||
|
definition.code to coerceDynamicAttribute(definition, raw)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||||
|
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||||
|
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||||
|
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
||||||
|
else -> raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun dynamicHasPermission(context: Context, permission: String): Boolean =
|
private fun dynamicHasPermission(context: Context, permission: String): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import com.korexlabs.dhinspeccion.MainViewModel
|
|||||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
||||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||||
import com.korexlabs.dhinspeccion.data.FieldType
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
|
|
||||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
@@ -721,7 +720,18 @@ private fun statusLabel(status: String): String = when (status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||||
fieldAttributePayload(type.attributes, values)
|
type.attributes.mapNotNull { definition ->
|
||||||
|
val raw = values[definition.code]?.trim().orEmpty()
|
||||||
|
if (raw.isBlank()) return@mapNotNull null
|
||||||
|
definition.code to coerceF3Attribute(definition, raw)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||||
|
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||||
|
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||||
|
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
||||||
|
else -> raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun f3HasPermission(context: Context, permission: String): Boolean =
|
private fun f3HasPermission(context: Context, permission: String): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
+88
-208
@@ -1,35 +1,27 @@
|
|||||||
package com.korexlabs.dhinspeccion.ui
|
package com.korexlabs.dhinspeccion.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MenuAnchorType
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -42,17 +34,10 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
@@ -75,32 +60,25 @@ private data class FindingGeoSnapshot(
|
|||||||
val accuracyM: Double?,
|
val accuracyM: Double?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FieldFindingScreen(model: MainViewModel) {
|
fun FieldFindingScreen(model: MainViewModel) {
|
||||||
val options = model.fieldFindingOptions ?: return
|
val options = model.fieldFindingOptions ?: return
|
||||||
val asset = model.selectedFieldAsset?.asset ?: return
|
val asset = model.selectedFieldAsset?.asset ?: return
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
|
||||||
var search by rememberSaveable(asset.id) { mutableStateOf("") }
|
var search by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||||
var catalogExpanded by rememberSaveable(asset.id) { mutableStateOf(false) }
|
|
||||||
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
|
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
|
||||||
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
|
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
|
||||||
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
|
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||||
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
|
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||||
var description by rememberSaveable(asset.id) { mutableStateOf("") }
|
var description by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||||
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
|
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||||
|
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||||
|
|
||||||
var localError by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
var requestedFindingId by remember { mutableStateOf<String?>(null) }
|
var 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) }
|
||||||
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
|
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
|
||||||
var reviewPhotoFile by remember { mutableStateOf<File?>(null) }
|
|
||||||
var reviewPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
|
|
||||||
var reviewPhotoFindingId by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||||
val file = pendingPhotoFile
|
val file = pendingPhotoFile
|
||||||
@@ -108,11 +86,14 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
val findingId = pendingPhotoFindingId
|
val findingId = pendingPhotoFindingId
|
||||||
if (success && file != null && geo != null && findingId != null) {
|
if (success && file != null && geo != null && findingId != null) {
|
||||||
runCatching { writeFindingExif(file, geo) }
|
runCatching { writeFindingExif(file, geo) }
|
||||||
reviewPhotoFile = file
|
model.uploadFindingPhoto(
|
||||||
reviewPhotoGeo = geo
|
findingId = findingId,
|
||||||
reviewPhotoFindingId = findingId
|
file = file,
|
||||||
} else {
|
latitude = geo.latitude,
|
||||||
file?.delete()
|
longitude = geo.longitude,
|
||||||
|
accuracyM = geo.accuracyM,
|
||||||
|
title = "Evidencia fotográfica de campo",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
pendingPhotoFile = null
|
pendingPhotoFile = null
|
||||||
pendingPhotoGeo = null
|
pendingPhotoGeo = null
|
||||||
@@ -129,7 +110,6 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
pendingPhotoFindingId = findingId
|
pendingPhotoFindingId = findingId
|
||||||
takePicture.launch(uri)
|
takePicture.launch(uri)
|
||||||
}
|
}
|
||||||
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +122,6 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
val findingId = requestedFindingId
|
val findingId = requestedFindingId
|
||||||
requestedFindingId = null
|
requestedFindingId = null
|
||||||
if (camera && location && findingId != null) beginPhoto(findingId)
|
if (camera && location && findingId != null) beginPhoto(findingId)
|
||||||
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestPhoto(findingId: String) {
|
fun requestPhoto(findingId: String) {
|
||||||
@@ -182,81 +161,8 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
customLegalBasis = ""
|
customLegalBasis = ""
|
||||||
description = ""
|
description = ""
|
||||||
severityText = ""
|
severityText = ""
|
||||||
|
correctionDueOn = ""
|
||||||
search = ""
|
search = ""
|
||||||
catalogExpanded = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val reviewFile = reviewPhotoFile
|
|
||||||
val reviewGeo = reviewPhotoGeo
|
|
||||||
val reviewFindingId = reviewPhotoFindingId
|
|
||||||
if (reviewFile != null && reviewGeo != null && reviewFindingId != null) {
|
|
||||||
val bitmap = remember(reviewFile.absolutePath, reviewFile.lastModified()) {
|
|
||||||
runCatching { BitmapFactory.decodeFile(reviewFile.absolutePath)?.asImageBitmap() }.getOrNull()
|
|
||||||
}
|
|
||||||
Dialog(onDismissRequest = {}) {
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text("Revisar fotografía", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
||||||
Text(
|
|
||||||
"Todavía no se subió. Verificá la imagen antes de incorporarla al Hallazgo.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (bitmap != null) {
|
|
||||||
Image(
|
|
||||||
bitmap = bitmap,
|
|
||||||
contentDescription = "Vista previa de la fotografía del Hallazgo",
|
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 220.dp, max = 460.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text("No se pudo generar la vista previa.", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
"GPS ${"%.6f".format(reviewGeo.latitude)}, ${"%.6f".format(reviewGeo.longitude)}${reviewGeo.accuracyM?.let { " · ±${"%.1f".format(it)} m" }.orEmpty()}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
model.uploadFindingPhoto(
|
|
||||||
findingId = reviewFindingId,
|
|
||||||
file = reviewFile,
|
|
||||||
latitude = reviewGeo.latitude,
|
|
||||||
longitude = reviewGeo.longitude,
|
|
||||||
accuracyM = reviewGeo.accuracyM,
|
|
||||||
title = "Evidencia fotográfica de campo",
|
|
||||||
)
|
|
||||||
reviewPhotoFile = null
|
|
||||||
reviewPhotoGeo = null
|
|
||||||
reviewPhotoFindingId = null
|
|
||||||
},
|
|
||||||
enabled = !model.busy && bitmap != null,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Usar esta foto") }
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
reviewFile.delete()
|
|
||||||
reviewPhotoFile = null
|
|
||||||
reviewPhotoGeo = null
|
|
||||||
reviewPhotoFindingId = null
|
|
||||||
beginPhoto(reviewFindingId)
|
|
||||||
},
|
|
||||||
enabled = !model.busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Volver a tomar") }
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
reviewFile.delete()
|
|
||||||
reviewPhotoFile = null
|
|
||||||
reviewPhotoGeo = null
|
|
||||||
reviewPhotoFindingId = null
|
|
||||||
},
|
|
||||||
enabled = !model.busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Eliminar foto") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,22 +177,21 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
|
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
|
||||||
Text("Volver")
|
Text("Volver")
|
||||||
}
|
}
|
||||||
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(asset.name, fontWeight = FontWeight.Bold)
|
Text(asset.name, fontWeight = FontWeight.Bold)
|
||||||
Text("${asset.code} · ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
||||||
Text(
|
Text(
|
||||||
if (options.capture.readyForFinding) "Inventario listo para registrar Hallazgos" else "Completá GPS y foto antes de continuar",
|
"Ubicación y foto del Inventario: ${if (options.capture.readyForFinding) "completas" else "pendientes"}",
|
||||||
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -305,7 +210,10 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
|
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
|
||||||
Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
|
Text(
|
||||||
|
"Gravedad: ${finding.severity ?: "sin definir"} · ${findingStatusLabel(finding.status)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
Text(
|
Text(
|
||||||
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
|
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -316,123 +224,87 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val photoCount = evidence.count { it.kind == "PHOTO" }
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { requestPhoto(finding.id) },
|
onClick = { requestPhoto(finding.id) },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(if (photoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
|
Text("Tomar foto con ubicación")
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
if (photoCount == 0) "Podés adjuntar una o varias fotos." else "$photoCount foto${if (photoCount == 1) "" else "s"} adjunta${if (photoCount == 1) "" else "s"}. Podés seguir agregando.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("1. Tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
if (!options.catalog.typeConfigured) {
|
if (!options.catalog.typeConfigured) {
|
||||||
Text(
|
Text(
|
||||||
"No hay tipos sugeridos para este Inventario. Elegí Otro para cargarlo manualmente.",
|
options.catalog.configurationReason
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ExposedDropdownMenuBox(
|
OutlinedTextField(
|
||||||
expanded = catalogExpanded && !model.busy,
|
value = search,
|
||||||
onExpandedChange = {
|
onValueChange = { search = it },
|
||||||
if (!model.busy) {
|
label = { Text("Buscar en catálogo") },
|
||||||
catalogExpanded = it
|
modifier = Modifier.fillMaxWidth(),
|
||||||
if (it) search = ""
|
singleLine = true,
|
||||||
}
|
)
|
||||||
},
|
|
||||||
) {
|
filtered.forEach { item ->
|
||||||
OutlinedTextField(
|
val chosen = !other && selectedCatalogId == item.id
|
||||||
value = if (catalogExpanded) search else when {
|
Card(
|
||||||
other -> "Otro / No está en la lista"
|
modifier = Modifier.fillMaxWidth().clickable {
|
||||||
selected != null -> selected.title
|
selectedCatalogId = item.id
|
||||||
else -> ""
|
other = false
|
||||||
|
severityText = item.suggestedSeverity?.toString().orEmpty()
|
||||||
|
},
|
||||||
|
colors = if (chosen) {
|
||||||
|
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
} else {
|
||||||
|
CardDefaults.cardColors()
|
||||||
},
|
},
|
||||||
onValueChange = { search = it; catalogExpanded = true },
|
|
||||||
label = { Text("Tipo de Hallazgo *") },
|
|
||||||
placeholder = { Text("Buscar o seleccionar") },
|
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = catalogExpanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
|
||||||
enabled = !model.busy,
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = {
|
|
||||||
catalogExpanded = false
|
|
||||||
focusManager.moveFocus(FocusDirection.Down)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = catalogExpanded && !model.busy,
|
|
||||||
onDismissRequest = { catalogExpanded = false; search = "" },
|
|
||||||
modifier = Modifier.heightIn(max = 320.dp),
|
|
||||||
) {
|
) {
|
||||||
filtered.forEach { item ->
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
DropdownMenuItem(
|
Text(if (chosen) "✓ ${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
|
||||||
text = {
|
Text(
|
||||||
Column {
|
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
|
||||||
Text(item.title, fontWeight = FontWeight.SemiBold)
|
.joinToString(" · "),
|
||||||
item.suggestedSeverity?.let {
|
style = MaterialTheme.typography.bodySmall,
|
||||||
Text("Gravedad sugerida: $it/10", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selectedCatalogId = item.id
|
|
||||||
other = false
|
|
||||||
severityText = item.suggestedSeverity?.toString().orEmpty()
|
|
||||||
catalogExpanded = false
|
|
||||||
search = ""
|
|
||||||
keyboard?.hide()
|
|
||||||
focusManager.moveFocus(FocusDirection.Down)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("Otro / No está en la lista", fontWeight = FontWeight.SemiBold) },
|
|
||||||
onClick = {
|
|
||||||
other = true
|
|
||||||
selectedCatalogId = null
|
|
||||||
severityText = ""
|
|
||||||
catalogExpanded = false
|
|
||||||
search = ""
|
|
||||||
keyboard?.hide()
|
|
||||||
focusManager.moveFocus(FocusDirection.Down)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
other = true
|
||||||
|
selectedCatalogId = null
|
||||||
|
severityText = ""
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
|
||||||
|
}
|
||||||
if (other) {
|
if (other) {
|
||||||
Text("Cargá un nombre claro para identificar el Hallazgo.", style = MaterialTheme.typography.bodySmall)
|
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = customTitle,
|
value = customTitle,
|
||||||
onValueChange = { customTitle = it },
|
onValueChange = { customTitle = it },
|
||||||
label = { Text("Nombre del Hallazgo *") },
|
label = { Text("Título del nuevo Hallazgo *") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = customLegalBasis,
|
value = customLegalBasis,
|
||||||
onValueChange = { customLegalBasis = it },
|
onValueChange = { customLegalBasis = it },
|
||||||
label = { Text("Normativa (opcional)") },
|
label = { Text("Base legal / normativa (opcional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("2. Qué observaste", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
selected?.let {
|
selected?.let {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
@@ -445,21 +317,23 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
label = { Text("Qué observaste *") },
|
label = { Text("Descripción del Hallazgo *") },
|
||||||
placeholder = { Text("Describí brevemente el problema") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
minLines = 3,
|
minLines = 3,
|
||||||
maxLines = 6,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = severityText,
|
value = severityText,
|
||||||
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
|
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
|
||||||
label = { Text("Gravedad (1 a 10, opcional)") },
|
label = { Text("Gravedad 1 a 10") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = correctionDueOn,
|
||||||
|
onValueChange = { correctionDueOn = it },
|
||||||
|
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); focusManager.clearFocus() }),
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -473,6 +347,7 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
customLegalBasis = if (other) customLegalBasis else null,
|
customLegalBasis = if (other) customLegalBasis else null,
|
||||||
description = description,
|
description = description,
|
||||||
severity = severity,
|
severity = severity,
|
||||||
|
correctionDueOn = correctionDueOn,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
|
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
|
||||||
@@ -489,19 +364,13 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
|
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
|
||||||
Text("${finding.code} · ${finding.title}")
|
Text("${finding.code} · ${finding.title}")
|
||||||
val createdEvidence = model.fieldFindingEvidence[finding.id].orEmpty()
|
|
||||||
val createdPhotoCount = createdEvidence.count { it.kind == "PHOTO" }
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { requestPhoto(finding.id) },
|
onClick = { requestPhoto(finding.id) },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(if (createdPhotoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
|
Text("Tomar foto con ubicación")
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
if (createdPhotoCount == 0) "Podés adjuntar todas las fotos que necesites." else "Fotos adjuntas: $createdPhotoCount. Podés seguir agregando.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
|
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,6 +378,17 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findingStatusLabel(status: String): String = when (status) {
|
||||||
|
"OPEN" -> "Abierto"
|
||||||
|
"COMPANY_RESPONDED" -> "Respondido por la empresa"
|
||||||
|
"UNDER_REVIEW" -> "En revisión"
|
||||||
|
"VERIFICATION_PENDING" -> "Verificación pendiente"
|
||||||
|
"READY_TO_CLOSE" -> "Listo para cierre"
|
||||||
|
"CLOSED" -> "Cerrado"
|
||||||
|
"CANCELLED" -> "Cancelado"
|
||||||
|
else -> "Estado no reconocido"
|
||||||
|
}
|
||||||
|
|
||||||
private fun findingHasPermission(context: Context, permission: String): Boolean =
|
private fun findingHasPermission(context: Context, permission: String): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
@@ -527,7 +407,7 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
|
|||||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||||
.addOnSuccessListener { location ->
|
.addOnSuccessListener { location ->
|
||||||
if (!continuation.isActive) return@addOnSuccessListener
|
if (!continuation.isActive) return@addOnSuccessListener
|
||||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
|
||||||
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||||
}
|
}
|
||||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.korexlabs.dhinspeccion.ui
|
||||||
|
|
||||||
|
import com.korexlabs.dhinspeccion.MainViewModel
|
||||||
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibilidad de compilación para pantallas heredadas que aún forman parte del árbol Android,
|
||||||
|
* aunque la navegación productiva use ModernVisitRoot.
|
||||||
|
*/
|
||||||
|
internal fun MainViewModel.createFieldAsset(
|
||||||
|
type: FieldType,
|
||||||
|
parentId: String?,
|
||||||
|
familyId: String?,
|
||||||
|
name: String,
|
||||||
|
commonName: String?,
|
||||||
|
attributes: Map<String, Any?>,
|
||||||
|
latitude: Double,
|
||||||
|
longitude: Double,
|
||||||
|
accuracyM: Double?,
|
||||||
|
) = createFieldAsset(
|
||||||
|
type = type,
|
||||||
|
parentId = parentId,
|
||||||
|
familyId = familyId,
|
||||||
|
name = name,
|
||||||
|
commonName = commonName,
|
||||||
|
description = null,
|
||||||
|
discoveryNotes = null,
|
||||||
|
attributes = attributes,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
|
accuracyM = accuracyM,
|
||||||
|
)
|
||||||
@@ -9,12 +9,8 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
import androidx.compose.foundation.layout.size
|
||||||
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
|
||||||
@@ -35,7 +31,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -84,7 +79,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DhTheme {
|
DhTheme {
|
||||||
Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
when {
|
when {
|
||||||
model.session == null -> EnhancedLoginScreen(model) {
|
model.session == null -> EnhancedLoginScreen(model) {
|
||||||
passwordLoginInFlight = true
|
passwordLoginInFlight = true
|
||||||
@@ -107,22 +102,32 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MendozaBrand() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.logo_mendoza_oficial),
|
||||||
|
contentDescription = "Gobierno de Mendoza",
|
||||||
|
modifier = Modifier.size(156.dp),
|
||||||
|
)
|
||||||
|
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text("Dirección de Hidrocarburos · Aplicación de campo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
|
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
|
||||||
var identifier by rememberSaveable { mutableStateOf("") }
|
var identifier by rememberSaveable { mutableStateOf("") }
|
||||||
var password by remember { mutableStateOf("") }
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Image(
|
MendozaBrand()
|
||||||
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
|
|
||||||
contentDescription = "Gobierno de Mendoza",
|
|
||||||
modifier = Modifier.fillMaxWidth().height(170.dp),
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
|
||||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
|
||||||
Text("Aplicación de campo · Dirección de Hidrocarburos")
|
|
||||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -207,17 +212,16 @@ private fun BiometricUnlockScreen(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) { authenticate() }
|
LaunchedEffect(Unit) { authenticate() }
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
|
painter = painterResource(R.drawable.logo_mendoza_oficial),
|
||||||
contentDescription = "Gobierno de Mendoza",
|
contentDescription = "Gobierno de Mendoza",
|
||||||
modifier = Modifier.fillMaxWidth().height(140.dp),
|
modifier = Modifier.size(120.dp),
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
)
|
)
|
||||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("Ingresá con tu huella")
|
Text("Ingresá con tu huella")
|
||||||
|
|||||||
@@ -57,34 +57,30 @@ fun MobileActsScreen(
|
|||||||
val visit = model.visit ?: return
|
val visit = model.visit ?: return
|
||||||
val selected = model.selectedAct
|
val selected = model.selectedAct
|
||||||
val closure = model.actClosure
|
val closure = model.actClosure
|
||||||
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||||
var actNarrative by rememberSaveable(selected?.id) {
|
|
||||||
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
|
|
||||||
}
|
|
||||||
var attendance by rememberSaveable(selected?.id) {
|
var attendance by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
|
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
|
||||||
}
|
}
|
||||||
var fullName by rememberSaveable(selected?.id) {
|
var fullName by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.fullName.orEmpty())
|
mutableStateOf(closure?.responsible?.fullName.orEmpty())
|
||||||
}
|
}
|
||||||
var documentType by rememberSaveable(selected?.id) {
|
var documentType by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf("DNI")
|
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
|
||||||
}
|
}
|
||||||
var documentNumber by rememberSaveable(selected?.id) {
|
var documentNumber by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.documentNumber.orEmpty())
|
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
|
||||||
}
|
}
|
||||||
var position by rememberSaveable(selected?.id) {
|
var position by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.position.orEmpty())
|
mutableStateOf(closure?.responsible?.position.orEmpty())
|
||||||
}
|
}
|
||||||
var email by rememberSaveable(selected?.id) {
|
var email by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.email.orEmpty())
|
mutableStateOf(closure?.responsible?.email.orEmpty())
|
||||||
}
|
}
|
||||||
var phone by rememberSaveable(selected?.id) {
|
var phone by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(representativeSeed?.phone.orEmpty())
|
mutableStateOf(closure?.responsible?.phone.orEmpty())
|
||||||
}
|
}
|
||||||
var absenceReason by rememberSaveable(selected?.id) {
|
var absenceReason by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
|
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
|
||||||
@@ -95,17 +91,26 @@ fun MobileActsScreen(
|
|||||||
|
|
||||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||||
|
|
||||||
fun signCompanyWithGeo(file: File) {
|
fun signWithGeo(file: File, company: Boolean) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
|
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
|
||||||
model.signSelectedActAsCompany(
|
if (company) {
|
||||||
png = file,
|
model.signSelectedActAsCompany(
|
||||||
latitude = geo?.latitude,
|
png = file,
|
||||||
longitude = geo?.longitude,
|
latitude = geo?.latitude,
|
||||||
accuracyM = geo?.accuracyM,
|
longitude = geo?.longitude,
|
||||||
manifestation = manifestation,
|
accuracyM = geo?.accuracyM,
|
||||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
manifestation = manifestation,
|
||||||
)
|
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
model.signSelectedActAsInspector(
|
||||||
|
png = file,
|
||||||
|
latitude = geo?.latitude,
|
||||||
|
longitude = geo?.longitude,
|
||||||
|
accuracyM = geo?.accuracyM,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +137,7 @@ fun MobileActsScreen(
|
|||||||
}
|
}
|
||||||
model.acts.forEach { act ->
|
model.acts.forEach { act ->
|
||||||
val active = selected?.id == act.id
|
val active = selected?.id == act.id
|
||||||
val urgency = when (act.urgency) {
|
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
|
||||||
"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(),
|
||||||
@@ -164,8 +165,23 @@ 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(
|
||||||
"La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
|
if (newActUrgency == "URGENT") {
|
||||||
|
"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(
|
||||||
@@ -173,7 +189,7 @@ fun MobileActsScreen(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.createAct() },
|
onClick = { model.createAct(newActUrgency) },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Crear nueva Acta") }
|
) { Text("Crear nueva Acta") }
|
||||||
@@ -182,6 +198,22 @@ fun MobileActsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(actStatusLabel(selected.status))
|
||||||
|
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
|
||||||
|
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) }
|
||||||
|
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||||
|
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
|
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (selected.status) {
|
when (selected.status) {
|
||||||
"DRAFT" -> {
|
"DRAFT" -> {
|
||||||
Card(
|
Card(
|
||||||
@@ -198,26 +230,29 @@ fun MobileActsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
|
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
|
||||||
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
|
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
|
||||||
}
|
}
|
||||||
if (attendance == "PRESENT") {
|
if (attendance == "PRESENT") {
|
||||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
|
||||||
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth())
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth())
|
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||||
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth())
|
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "✓ $kind" else kind) })
|
||||||
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
|
}
|
||||||
Text("Datos precargados del Acta anterior. Confirmalos para esta Acta.", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
}
|
||||||
|
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
|
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
|
||||||
},
|
},
|
||||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
|
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Guardar representante") }
|
) { Text("Guardar responsable") }
|
||||||
} else {
|
} else {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
absenceReason,
|
absenceReason,
|
||||||
@@ -235,79 +270,74 @@ 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 cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
|
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
|
||||||
OutlinedTextField(
|
|
||||||
actNarrative,
|
|
||||||
{ actNarrative = it },
|
|
||||||
label = { Text("Descripción de lo actuado *") },
|
|
||||||
minLines = 4,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
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(closingUrgency, actNarrative) },
|
onClick = { model.prepareSelectedAct() },
|
||||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
|
enabled = !model.busy && closure?.responsible != null,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar contenido y pasar a firma") }
|
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||||
}
|
}
|
||||||
|
|
||||||
"LOCKED" -> {
|
"LOCKED" -> {
|
||||||
val signatures = closure?.signatures.orEmpty()
|
val signatures = closure?.signatures.orEmpty()
|
||||||
|
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
|
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||||
|
|
||||||
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.")
|
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
||||||
|
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
|
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||||
|
if (inspectorSigned) {
|
||||||
|
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||||
|
SignaturePad(
|
||||||
|
label = "Firmá como inspector/a",
|
||||||
|
enabled = !model.busy,
|
||||||
|
onCaptured = { file -> signWithGeo(file, company = false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||||
if (companyOutcome != null && companyResolved) {
|
if (companyOutcome != null && companyResolved) {
|
||||||
val detail = when (companyOutcome.status) {
|
val detail = when (companyOutcome.status) {
|
||||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
|
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||||
"REFUSED" -> "Se negó a firmar"
|
"REFUSED" -> "Negativa a firmar"
|
||||||
"ABSENT" -> "Ausencia documentada"
|
|
||||||
else -> companyOutcome.status
|
else -> companyOutcome.status
|
||||||
}
|
}
|
||||||
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
||||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||||
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
|
Text(
|
||||||
Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.")
|
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
||||||
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
OutlinedButton(
|
)
|
||||||
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
|
|
||||||
enabled = !model.busy && recordedAbsence.trim().length >= 10,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Confirmar ausencia documentada") }
|
|
||||||
} else {
|
} else {
|
||||||
|
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
|
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
|
||||||
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") })
|
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
|
||||||
}
|
}
|
||||||
if (manifestation == "DISSENT") {
|
if (manifestation == "DISSENT") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
dissentStatement,
|
dissentStatement,
|
||||||
{ dissentStatement = it },
|
{ dissentStatement = it },
|
||||||
label = { Text("Motivo de disconformidad *") },
|
label = { Text("Manifestación de disidencia *") },
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SignaturePad(
|
SignaturePad(
|
||||||
label = "Firma del representante de la empresa (acompañante)",
|
label = "Firma del responsable de empresa",
|
||||||
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||||
onCaptured = { file -> signCompanyWithGeo(file) },
|
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||||
)
|
)
|
||||||
|
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
refusalReason,
|
refusalReason,
|
||||||
{ refusalReason = it },
|
{ refusalReason = it },
|
||||||
@@ -317,20 +347,23 @@ fun MobileActsScreen(
|
|||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||||
enabled = !model.busy && refusalReason.trim().length >= 10,
|
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Registrar negativa a firmar") }
|
) { Text("Registrar negativa a firmar") }
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
if (companyResolved) {
|
if (inspectorSigned && companyResolved) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.closeSelectedAct() },
|
onClick = { model.closeSelectedAct() },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Aplicar mi firma y cerrar Acta") }
|
) { Text("SELLAR Acta definitivamente") }
|
||||||
} else {
|
} else {
|
||||||
Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall)
|
Text(
|
||||||
|
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,109 +9,51 @@ 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.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Assignment
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.korexlabs.dhinspeccion.MainViewModel
|
import com.korexlabs.dhinspeccion.MainViewModel
|
||||||
import com.korexlabs.dhinspeccion.data.DhRepository
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
|
|
||||||
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
|
|
||||||
import com.korexlabs.dhinspeccion.data.VisitSummary
|
import com.korexlabs.dhinspeccion.data.VisitSummary
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MobileHomeScreen(model: MainViewModel) {
|
fun MobileHomeScreen(model: MainViewModel) {
|
||||||
val session = model.session ?: return
|
val session = model.session ?: return
|
||||||
val context = LocalContext.current
|
val fieldVisits = model.visits.filter { it.status != "DRAFT" }
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
|
||||||
|
|
||||||
var showOpen by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
|
||||||
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
|
||||||
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
var loadingContext by remember { mutableStateOf(false) }
|
|
||||||
var opening by remember { mutableStateOf(false) }
|
|
||||||
var localError by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
fun loadAreas() {
|
|
||||||
scope.launch {
|
|
||||||
loadingContext = true
|
|
||||||
localError = null
|
|
||||||
runCatching { openRepository.areas().data }
|
|
||||||
.onSuccess { areas = it }
|
|
||||||
.onFailure { localError = DhRepository.humanError(it) }
|
|
||||||
loadingContext = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chooseArea(areaId: String) {
|
|
||||||
selectedAreaId = areaId
|
|
||||||
selectedCompanyId = null
|
|
||||||
operators = emptyList()
|
|
||||||
scope.launch {
|
|
||||||
loadingContext = true
|
|
||||||
localError = null
|
|
||||||
runCatching { openRepository.operators(areaId).data }
|
|
||||||
.onSuccess { operators = it }
|
|
||||||
.onFailure { localError = DhRepository.humanError(it) }
|
|
||||||
loadingContext = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(showOpen) {
|
|
||||||
if (showOpen && areas.isEmpty()) loadAreas()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||||
Column(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column(Modifier.weight(1f)) {
|
||||||
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
|
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
Row(
|
Row {
|
||||||
Modifier.fillMaxWidth(),
|
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
Spacer(Modifier.width(6.dp))
|
||||||
) {
|
Text("Actualizar")
|
||||||
OutlinedButton(
|
|
||||||
onClick = { model.loadVisits() },
|
|
||||||
enabled = !model.busy && !opening,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text("Actualizar", maxLines = 1)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { model.logout() },
|
|
||||||
enabled = !opening,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Text("Salir", maxLines = 1)
|
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,97 +63,49 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
) {
|
) {
|
||||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||||
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
||||||
|
|
||||||
Button(
|
Surface(
|
||||||
onClick = {
|
|
||||||
showOpen = !showOpen
|
|
||||||
localError = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = !opening,
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
) {
|
) {
|
||||||
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
|
Row(
|
||||||
}
|
Modifier.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (showOpen) {
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
Card(Modifier.fillMaxWidth()) {
|
) {
|
||||||
Column(
|
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||||
Modifier.fillMaxWidth().padding(14.dp),
|
Column(Modifier.weight(1f)) {
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
Text("Trabajo planificado desde oficina", fontWeight = FontWeight.Bold)
|
||||||
) {
|
|
||||||
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
Text(
|
Text(
|
||||||
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
|
"Acá aparecen las inspecciones ya planificadas y asignadas desde el tablero. La visita se inicia únicamente al llegar al lugar.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
Text("1. Área", fontWeight = FontWeight.SemiBold)
|
|
||||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(areas, key = { it.id }) { area ->
|
|
||||||
AssistChip(
|
|
||||||
onClick = { chooseArea(area.id) },
|
|
||||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedAreaId != null) {
|
|
||||||
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
|
|
||||||
if (loadingContext && operators.isEmpty()) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
} else if (operators.isEmpty()) {
|
|
||||||
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
items(operators, key = { it.id }) { company ->
|
|
||||||
AssistChip(
|
|
||||||
onClick = { selectedCompanyId = company.id },
|
|
||||||
label = { Text(if (company.id == selectedCompanyId) "✓ ${company.name}" else company.name) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val areaId = selectedAreaId ?: return@Button
|
|
||||||
val companyId = selectedCompanyId ?: return@Button
|
|
||||||
scope.launch {
|
|
||||||
opening = true
|
|
||||||
localError = null
|
|
||||||
runCatching { openRepository.open(areaId, companyId) }
|
|
||||||
.onSuccess { opened ->
|
|
||||||
showOpen = false
|
|
||||||
model.openVisit(opened.id)
|
|
||||||
}
|
|
||||||
.onFailure { localError = DhRepository.humanError(it) }
|
|
||||||
opening = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
|
|
||||||
) {
|
|
||||||
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.busy && model.visits.isEmpty()) {
|
if (model.busy && fieldVisits.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||||
} else if (model.visits.isEmpty()) {
|
} else if (fieldVisits.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||||
|
Text("No tenés inspecciones listas para campo", fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Cuando una inspección quede planificada y asignada desde el tablero, aparecerá en esta pantalla.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Button(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar lista") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
|
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
items(model.visits, key = { it.id }) { visit ->
|
items(fieldVisits, key = { it.id }) { visit ->
|
||||||
MobileVisitCard(visit) { model.openVisit(visit.id) }
|
MobileVisitCard(visit) { model.openVisit(visit.id) }
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(24.dp)) }
|
item { Spacer(Modifier.height(24.dp)) }
|
||||||
@@ -222,21 +116,33 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
|
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
|
||||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
ElevatedCard(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Text(visit.code, fontWeight = FontWeight.Bold)
|
Text(visit.code, fontWeight = FontWeight.Bold)
|
||||||
Text(visitStatusLabelEs(visit.status))
|
StatusPill(mobileVisitStatusLabel(visit.status))
|
||||||
}
|
}
|
||||||
|
Text(visit.scopeAsset?.name ?: visit.operationalArea?.name ?: "Alcance sin informar", fontWeight = FontWeight.SemiBold)
|
||||||
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
||||||
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
visit.operationalArea?.name?.takeIf { it != visit.scopeAsset?.name }?.let {
|
||||||
|
Text("Área · $it", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
visit.plannedStartAt?.let {
|
visit.plannedStartAt?.let {
|
||||||
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
|
Text("Planificada · ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
|
"Inventario previsto: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun mobileVisitStatusLabel(status: String): String = when (status) {
|
||||||
|
"DRAFT" -> "Borrador"
|
||||||
|
"PLANNED" -> "Planificada"
|
||||||
|
"IN_PROGRESS" -> "En curso"
|
||||||
|
"CLOSED" -> "Cerrada"
|
||||||
|
"CANCELLED" -> "Cancelada"
|
||||||
|
else -> "Estado no reconocido"
|
||||||
|
}
|
||||||
|
|||||||
+209
-205
@@ -3,6 +3,7 @@ package com.korexlabs.dhinspeccion.ui
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -71,37 +72,44 @@ fun ModernMobileActsScreen(
|
|||||||
val visit = model.visit ?: return
|
val visit = model.visit ?: return
|
||||||
val selected = model.selectedAct
|
val selected = model.selectedAct
|
||||||
val closure = model.actClosure
|
val closure = model.actClosure
|
||||||
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||||
var actNarrative by rememberSaveable(selected?.id) {
|
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
|
||||||
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
|
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
|
||||||
}
|
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
|
||||||
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
|
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
|
||||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
|
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
|
||||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
|
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
|
||||||
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
|
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
|
||||||
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
|
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
|
||||||
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
|
|
||||||
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||||
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
|
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
|
||||||
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||||
|
|
||||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||||
|
|
||||||
fun signCompanyWithGeo(file: File) {
|
fun signWithGeo(file: File, company: Boolean) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
|
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
|
||||||
model.signSelectedActAsCompany(
|
if (company) {
|
||||||
png = file,
|
model.signSelectedActAsCompany(
|
||||||
latitude = geo?.latitude,
|
png = file,
|
||||||
longitude = geo?.longitude,
|
latitude = geo?.latitude,
|
||||||
accuracyM = geo?.accuracyM,
|
longitude = geo?.longitude,
|
||||||
manifestation = manifestation,
|
accuracyM = geo?.accuracyM,
|
||||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
manifestation = manifestation,
|
||||||
)
|
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
model.signSelectedActAsInspector(
|
||||||
|
png = file,
|
||||||
|
latitude = geo?.latitude,
|
||||||
|
longitude = geo?.longitude,
|
||||||
|
accuracyM = geo?.accuracyM,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,29 +147,6 @@ fun ModernMobileActsScreen(
|
|||||||
|
|
||||||
ModernMessage(model)
|
ModernMessage(model)
|
||||||
|
|
||||||
if (model.pendingSyncCount > 0) {
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(
|
|
||||||
"${model.pendingSyncCount} cambio${if (model.pendingSyncCount == 1) "" else "s"} guardado${if (model.pendingSyncCount == 1) "" else "s"} en este dispositivo",
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Podés seguir trabajando sin internet. Los cambios se sincronizan automáticamente cuando vuelve la conexión.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
OutlinedButton(onClick = { model.syncPendingNow() }, enabled = !model.busy) {
|
|
||||||
Text("Sincronizar ahora")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (model.acts.isEmpty()) {
|
if (model.acts.isEmpty()) {
|
||||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -186,8 +171,12 @@ fun ModernMobileActsScreen(
|
|||||||
onClick = { model.selectAct(act.id) },
|
onClick = { model.selectAct(act.id) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = if (active) 1.dp else 0.dp,
|
||||||
|
border = BorderStroke(
|
||||||
|
if (active) 2.dp else 1.dp,
|
||||||
|
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(15.dp),
|
Modifier.padding(15.dp),
|
||||||
@@ -206,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)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
|
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -236,13 +225,36 @@ fun ModernMobileActsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text("Urgencia", fontWeight = FontWeight.SemiBold)
|
||||||
"La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
FilterChip(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
selected = newActUrgency == "NON_URGENT",
|
||||||
)
|
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á conforme a la política institucional vigente al cerrar el contenido del Acta."
|
||||||
|
} 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() },
|
onClick = { model.createAct(newActUrgency) },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
@@ -255,6 +267,26 @@ fun ModernMobileActsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
|
HorizontalDivider()
|
||||||
|
Surface(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
StatusPill(modernActStatusLabel(selected.status))
|
||||||
|
}
|
||||||
|
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
|
||||||
|
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
|
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||||
|
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (selected.status) {
|
when (selected.status) {
|
||||||
"DRAFT" -> {
|
"DRAFT" -> {
|
||||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||||
@@ -277,25 +309,46 @@ fun ModernMobileActsScreen(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Text(
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
"Identificá a la persona que acompaña el recorrido. Al finalizar esta Acta podrá firmar conforme, firmar en disconformidad o negarse a firmar.",
|
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
|
||||||
style = MaterialTheme.typography.bodySmall,
|
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
}
|
||||||
)
|
if (attendance == "PRESENT") {
|
||||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||||
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
FilterChip(
|
||||||
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
|
selected = documentType == kind,
|
||||||
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
onClick = { documentType = kind },
|
||||||
|
label = { Text(documentTypeLabel(kind)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
|
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
|
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
|
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
|
Button(
|
||||||
|
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
||||||
|
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Guardar responsable") }
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
absenceReason,
|
||||||
|
{ absenceReason = it },
|
||||||
|
label = { Text("Motivo de ausencia *") },
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
|
||||||
|
enabled = !model.busy && absenceReason.trim().length >= 10,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Guardar ausencia") }
|
||||||
}
|
}
|
||||||
Button(
|
|
||||||
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
|
||||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Guardar representante") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,156 +360,103 @@ 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 Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
"Al cerrar el Acta, su contenido y sus Hallazgos quedan inmutables. El Acta quedará pendiente de firma.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
actNarrative,
|
|
||||||
{ actNarrative = it },
|
|
||||||
label = { Text("Descripción de lo actuado *") },
|
|
||||||
supportingText = { Text("Relatá la inspección y sus observaciones. Se incorpora al acta antes de sellarla.") },
|
|
||||||
minLines = 4,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
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(closingUrgency, actNarrative) },
|
onClick = { model.prepareSelectedAct() },
|
||||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
|
enabled = !model.busy && closure?.responsible != null,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar contenido y pasar a firma") }
|
) { Text("Cerrar Acta y dejar pendiente de firma") }
|
||||||
|
if (closure?.responsible == null) {
|
||||||
|
Text(
|
||||||
|
"Antes de cerrar, registrá al responsable de la empresa o su ausencia.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"LOCKED" -> {
|
"LOCKED" -> {
|
||||||
val signatures = closure?.signatures.orEmpty()
|
val signatures = closure?.signatures.orEmpty()
|
||||||
|
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||||
val companyResolvedOnServer = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
|
val companyResolved = companyOutcome?.status in setOf("SIGNED", "REFUSED", "ABSENT")
|
||||||
val companyResolved = companyResolvedOnServer || model.selectedActCompanySignaturePending
|
|
||||||
|
|
||||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Text(
|
Text("El contenido ya es inmutable. Completá las firmas o dejá asentada la ausencia/negativa correspondiente para finalizarla.")
|
||||||
"El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
|
closure?.closure?.preparedSha256?.let { Text("SHA-256 de cierre · $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
HorizontalDivider()
|
||||||
when (model.inspectorSignatureConfigured) {
|
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||||
true -> Surface(
|
if (inspectorSigned) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
SuccessLine("Firma del inspector registrada")
|
||||||
shape = MaterialTheme.shapes.medium,
|
} else {
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||||
) {
|
SignaturePad(
|
||||||
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
label = "Firmá como inspector/a",
|
||||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
enabled = !model.busy,
|
||||||
Text(
|
onCaptured = { file -> signWithGeo(file, company = false) },
|
||||||
"Firma del inspector configurada en Mi perfil. Se aplicará automáticamente al cierre definitivo de esta Acta.",
|
|
||||||
Modifier.weight(1f),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false -> Surface(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
|
|
||||||
Text(
|
|
||||||
"Falta configurar la firma del inspector en Mi perfil del Dashboard. La firma de la empresa puede guardarse igual; el cierre quedará pendiente hasta completar este requisito.",
|
|
||||||
Modifier.weight(1f),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OutlinedButton(onClick = { model.refreshInspectorSignatureStatus() }, enabled = !model.busy) {
|
|
||||||
Text("Volver a verificar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
null -> Text(
|
|
||||||
"La firma del inspector se validará con el Dashboard al sincronizar.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
|
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||||
if (companyOutcome != null && companyResolvedOnServer) {
|
if (companyOutcome != null && companyResolved) {
|
||||||
val detail = when (companyOutcome.status) {
|
val detail = when (companyOutcome.status) {
|
||||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
|
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||||
"REFUSED" -> "Se negó a firmar"
|
"REFUSED" -> "Negativa a firmar registrada"
|
||||||
"ABSENT" -> "Ausencia documentada"
|
"ABSENT" -> "Ausencia para la firma registrada"
|
||||||
else -> humanBackendLabelEs(companyOutcome.status)
|
else -> "Manifestación registrada"
|
||||||
}
|
}
|
||||||
SuccessLine(detail)
|
SuccessLine(detail)
|
||||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
} else if (model.selectedActCompanySignaturePending) {
|
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||||
|
val savedAbsenceReason = closure.responsible.absenceReason.orEmpty()
|
||||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
SuccessLine("Firma guardada en el dispositivo")
|
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.secondary)
|
||||||
Text(
|
Column(Modifier.weight(1f)) {
|
||||||
"Todavía no llegó al servidor. Podés continuar; se enviará automáticamente cuando haya conexión.",
|
Text("El responsable fue registrado como ausente.", fontWeight = FontWeight.SemiBold)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (savedAbsenceReason.isNotBlank()) Text(savedAbsenceReason, style = MaterialTheme.typography.bodySmall)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
OutlinedButton(
|
||||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
|
onClick = { model.recordCompanyOutcome("ABSENT", savedAbsenceReason) },
|
||||||
Text(
|
enabled = !model.busy && inspectorSigned && savedAbsenceReason.trim().length >= 10,
|
||||||
"Esta Acta conserva un registro histórico de ausencia. El flujo actual requiere identificar al acompañante antes de pasar el contenido a firma.",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.padding(12.dp),
|
) { Text("Registrar ausencia para la firma") }
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
|
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
|
||||||
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disconformidad") })
|
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
|
||||||
}
|
}
|
||||||
if (manifestation == "DISSENT") {
|
if (manifestation == "DISSENT") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
dissentStatement,
|
dissentStatement,
|
||||||
{ dissentStatement = it },
|
{ dissentStatement = it },
|
||||||
label = { Text("Motivo de disconformidad *") },
|
label = { Text("Manifestación de disidencia *") },
|
||||||
minLines = 2,
|
minLines = 2,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SignaturePad(
|
SignaturePad(
|
||||||
label = "Firma del representante de la empresa (acompañante)",
|
label = "Firma del responsable de empresa",
|
||||||
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||||
onCaptured = { file -> signCompanyWithGeo(file) },
|
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||||
)
|
)
|
||||||
Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", style = MaterialTheme.typography.bodySmall)
|
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
refusalReason,
|
refusalReason,
|
||||||
{ refusalReason = it },
|
{ refusalReason = it },
|
||||||
@@ -466,34 +466,24 @@ fun ModernMobileActsScreen(
|
|||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||||
enabled = !model.busy && refusalReason.trim().length >= 10,
|
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Registrar negativa a firmar") }
|
) { Text("Registrar negativa a firmar") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (companyResolved) {
|
if (inspectorSigned && companyResolved) {
|
||||||
if (model.selectedActSealPending) {
|
Button(
|
||||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
onClick = { model.closeSelectedAct() },
|
||||||
Text(
|
enabled = !model.busy,
|
||||||
"Cierre guardado en este dispositivo · pendiente de sincronización",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.padding(12.dp),
|
) {
|
||||||
fontWeight = FontWeight.SemiBold,
|
Icon(Icons.Filled.CheckCircle, null)
|
||||||
)
|
Spacer(Modifier.width(6.dp))
|
||||||
}
|
Text("Finalizar Acta")
|
||||||
} else {
|
|
||||||
Button(
|
|
||||||
onClick = { model.closeSelectedAct() },
|
|
||||||
enabled = !model.busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Filled.CheckCircle, null)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(if (model.inspectorSignatureConfigured == false) "Guardar cierre pendiente" else "Firmar y cerrar Acta")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.",
|
"La inspección no puede cerrarse hasta resolver la firma, negativa o ausencia de esta Acta.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -507,9 +497,9 @@ fun ModernMobileActsScreen(
|
|||||||
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
||||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
|
Text("Acta finalizada e inmutable", fontWeight = FontWeight.Bold)
|
||||||
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
|
Text("Al finalizar se genera el PDF del Acta y el archivo INF editable para el Inspector.")
|
||||||
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
|
closure?.closure?.finalSha256?.let { Text("SHA-256 final · $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,12 +518,12 @@ fun ModernMobileActsScreen(
|
|||||||
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||||
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
if (pendingActs.isEmpty()) {
|
if (pendingActs.isEmpty()) {
|
||||||
SuccessLine("Todas las Actas están firmadas y cerradas")
|
SuccessLine("Todas las Actas están finalizadas")
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
|
"Actas pendientes de firma o finalización: ${pendingActs.joinToString { it.code }}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
@@ -542,7 +532,7 @@ fun ModernMobileActsScreen(
|
|||||||
onClick = { model.closeInspection() },
|
onClick = { model.closeInspection() },
|
||||||
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar Inspección y salir") }
|
) { Text("Cerrar inspección y salir") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,7 +548,21 @@ private fun SuccessLine(text: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
|
private fun documentTypeLabel(kind: String): String = when (kind) {
|
||||||
|
"DNI" -> "DNI"
|
||||||
|
"CUIL" -> "CUIL"
|
||||||
|
"PASSPORT" -> "Pasaporte"
|
||||||
|
"OTHER" -> "Otro"
|
||||||
|
else -> "Otro"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modernActStatusLabel(status: String): String = when (status) {
|
||||||
|
"DRAFT" -> "Borrador"
|
||||||
|
"LOCKED" -> "Pendiente de firma"
|
||||||
|
"SEALED" -> "Finalizada"
|
||||||
|
"CANCELLED" -> "Cancelada"
|
||||||
|
else -> "Estado no reconocido"
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasModernActLocation(context: Context): Boolean =
|
private fun hasModernActLocation(context: Context): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -37,11 +36,6 @@ import androidx.compose.material.icons.filled.LocationOn
|
|||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.WarningAmber
|
import androidx.compose.material.icons.filled.WarningAmber
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
||||||
import androidx.compose.material3.MenuAnchorType
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -63,7 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
@@ -83,7 +76,6 @@ import com.korexlabs.dhinspeccion.MainViewModel
|
|||||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
||||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||||
import com.korexlabs.dhinspeccion.data.FieldType
|
import com.korexlabs.dhinspeccion.data.FieldType
|
||||||
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
|
|
||||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -114,6 +106,7 @@ 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,
|
||||||
@@ -131,6 +124,7 @@ 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(
|
||||||
@@ -189,7 +183,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 y Hallazgos.",
|
"Al iniciar se habilitan Actas, Hallazgos y el Inventario de campo.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
@@ -223,6 +217,25 @@ 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") }
|
||||||
}
|
}
|
||||||
@@ -249,7 +262,7 @@ private fun ModernChecklistCard(visit: VisitDetail) {
|
|||||||
val checklist = visit.checklist
|
val checklist = visit.checklist
|
||||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text("Antecedentes", fontWeight = FontWeight.Bold)
|
Text("Antecedentes y controles", fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
"${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles",
|
"${checklist.antecedents} antecedentes · ${checklist.upcomingControls} próximos controles",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -267,7 +280,6 @@ private fun ModernChecklistCard(visit: VisitDetail) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -285,10 +297,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var description by rememberSaveable(visit.id) { mutableStateOf("") }
|
var description by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
|
var discoveryNotes by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||||
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||||
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||||
var familyExpanded by remember { mutableStateOf(false) }
|
|
||||||
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
||||||
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
|
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||||
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||||
@@ -302,10 +314,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
name = ""
|
name = ""
|
||||||
commonName = ""
|
commonName = ""
|
||||||
description = ""
|
description = ""
|
||||||
|
discoveryNotes = ""
|
||||||
selectedTypeId = null
|
selectedTypeId = null
|
||||||
selectedFamilyId = null
|
selectedFamilyId = null
|
||||||
familySearch = ""
|
familySearch = ""
|
||||||
familyExpanded = false
|
|
||||||
attributeValues.clear()
|
attributeValues.clear()
|
||||||
localError = null
|
localError = null
|
||||||
}
|
}
|
||||||
@@ -319,13 +331,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
model.loadFieldTypes(null)
|
model.loadFieldTypes(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startInstallation(parent: FieldInventoryItem? = null) {
|
fun startInstallation() {
|
||||||
model.clearSelectedFieldAsset()
|
model.clearSelectedFieldAsset()
|
||||||
modeName = ModernInventoryMode.CREATE_INSTALLATION.name
|
modeName = ModernInventoryMode.CREATE_INSTALLATION.name
|
||||||
parentId = parent?.id
|
parentId = null
|
||||||
parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección"
|
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
|
||||||
resetForm()
|
resetForm()
|
||||||
model.loadFieldTypes(parent?.id)
|
model.loadFieldTypes(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startSubinstallation() {
|
fun startSubinstallation() {
|
||||||
@@ -339,10 +351,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun chooseParent(item: FieldInventoryItem) {
|
fun chooseParent(item: FieldInventoryItem) {
|
||||||
if (modernItemTypeCode(item) == "yacimiento") {
|
|
||||||
startInstallation(item)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keyboard?.hide()
|
keyboard?.hide()
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
parentId = item.id
|
parentId = item.id
|
||||||
@@ -379,8 +387,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
|
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
} ?: model.fieldTypes.firstOrNull()
|
||||||
if (selectedTypeId != preferred?.id) {
|
if (model.fieldTypes.none { it.id == selectedTypeId }) {
|
||||||
selectedTypeId = preferred?.id
|
selectedTypeId = preferred?.id
|
||||||
selectedFamilyId = null
|
selectedFamilyId = null
|
||||||
familySearch = ""
|
familySearch = ""
|
||||||
@@ -390,9 +398,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
|
|
||||||
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
||||||
val editableAttributes = selectedType?.attributes.orEmpty().filterNot {
|
|
||||||
selectedType?.familyRequired == true && it.code == "tipo_instalacion"
|
|
||||||
}
|
|
||||||
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
||||||
val filteredFamilies = remember(selectedType, familySearch) {
|
val filteredFamilies = remember(selectedType, familySearch) {
|
||||||
val needle = modernNormalize(familySearch)
|
val needle = modernNormalize(familySearch)
|
||||||
@@ -426,13 +431,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
pendingPhotoGeo = geo
|
pendingPhotoGeo = geo
|
||||||
takePicture.launch(uri)
|
takePicture.launch(uri)
|
||||||
}
|
}
|
||||||
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
|
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación para la fotografía." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createWithLocation() {
|
fun createWithLocation() {
|
||||||
val type = selectedType ?: return
|
val type = selectedType ?: return
|
||||||
val effectiveParent = parentId
|
val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { currentModernGeo(context) }
|
runCatching { currentModernGeo(context) }
|
||||||
.onSuccess { geo ->
|
.onSuccess { geo ->
|
||||||
@@ -444,13 +449,14 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
name = name,
|
name = name,
|
||||||
commonName = commonName,
|
commonName = commonName,
|
||||||
description = description,
|
description = description,
|
||||||
|
discoveryNotes = discoveryNotes,
|
||||||
attributes = buildModernAttributes(type, attributeValues),
|
attributes = buildModernAttributes(type, attributeValues),
|
||||||
latitude = geo.latitude,
|
latitude = geo.latitude,
|
||||||
longitude = geo.longitude,
|
longitude = geo.longitude,
|
||||||
accuracyM = geo.accuracyM,
|
accuracyM = geo.accuracyM,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
|
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación actual." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +532,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 -> "Ubicación del Hallazgo"
|
ModernInventoryMode.BROWSE -> "Inventario de campo"
|
||||||
},
|
},
|
||||||
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() },
|
||||||
@@ -541,9 +547,6 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCapture != null) {
|
if (selectedCapture != null) {
|
||||||
if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) {
|
|
||||||
TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy)
|
|
||||||
}
|
|
||||||
ModernCaptureCard(
|
ModernCaptureCard(
|
||||||
item = selectedCapture.asset,
|
item = selectedCapture.asset,
|
||||||
gps = selectedCapture.capture.creationGpsCaptured,
|
gps = selectedCapture.capture.creationGpsCaptured,
|
||||||
@@ -735,9 +738,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedType == null && !model.busy) {
|
if (model.fieldTypes.isEmpty() && !model.busy) {
|
||||||
ModernLocalError("Elegí un Yacimiento en Inventario y tocá Agregar Instalación. No se puede crear una Instalación directamente dentro de un Área.") {}
|
ModernLocalError("No hay un tipo habilitado para esta ubicación.") {}
|
||||||
OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.fieldTypes.size > 1) {
|
if (model.fieldTypes.size > 1) {
|
||||||
@@ -760,56 +762,37 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedType?.familyRequired == true) {
|
if (selectedType?.familyRequired == true) {
|
||||||
ExposedDropdownMenuBox(
|
Text("Clasificación técnica", fontWeight = FontWeight.Bold)
|
||||||
expanded = familyExpanded && !model.busy,
|
OutlinedTextField(
|
||||||
onExpandedChange = {
|
value = familySearch,
|
||||||
if (!model.busy) {
|
onValueChange = { familySearch = it },
|
||||||
familyExpanded = it
|
label = { Text("Buscar clasificación") },
|
||||||
familySearch = ""
|
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
||||||
}
|
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
|
||||||
},
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
singleLine = true,
|
||||||
OutlinedTextField(
|
)
|
||||||
value = if (familyExpanded) familySearch else selectedFamily?.name.orEmpty(),
|
filteredFamilies.take(12).forEach { family ->
|
||||||
onValueChange = { familySearch = it; familyExpanded = true },
|
Surface(
|
||||||
label = { Text(if (isSubinstallation) "Tipo de subinstalación" else "Tipo de instalación") },
|
onClick = { selectedFamilyId = family.id },
|
||||||
placeholder = { Text("Buscar por nombre o código") },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
leadingIcon = { Icon(Icons.Filled.Search, null) },
|
shape = MaterialTheme.shapes.medium,
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = familyExpanded) },
|
color = if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||||
enabled = !model.busy,
|
border = BorderStroke(1.dp, if (selectedFamilyId == family.id) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant),
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = {
|
|
||||||
familyExpanded = false
|
|
||||||
focusManager.moveFocus(FocusDirection.Down)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = familyExpanded && !model.busy,
|
|
||||||
onDismissRequest = { familyExpanded = false; familySearch = "" },
|
|
||||||
modifier = Modifier.heightIn(max = 280.dp),
|
|
||||||
) {
|
) {
|
||||||
if (filteredFamilies.isEmpty()) {
|
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
DropdownMenuItem(text = { Text("Sin coincidencias") }, onClick = {}, enabled = false)
|
Text(
|
||||||
}
|
(if (family.isOther) "Otro · " else "") + family.name,
|
||||||
filteredFamilies.forEach { family ->
|
Modifier.weight(1f),
|
||||||
DropdownMenuItem(
|
fontWeight = if (selectedFamilyId == family.id) FontWeight.Bold else FontWeight.Normal,
|
||||||
text = { Text((if (family.isOther) "Otro · " else "") + family.name) },
|
|
||||||
trailingIcon = {
|
|
||||||
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, "Seleccionado")
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
selectedFamilyId = family.id
|
|
||||||
familyExpanded = false
|
|
||||||
familySearch = ""
|
|
||||||
keyboard?.hide()
|
|
||||||
focusManager.clearFocus()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
if (selectedFamilyId == family.id) Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (filteredFamilies.size > 12) {
|
||||||
|
Text("Seguí escribiendo para reducir la lista.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
selectedFamily?.let { family ->
|
selectedFamily?.let { family ->
|
||||||
if (family.isOther) {
|
if (family.isOther) {
|
||||||
Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
|
Text("Quedará marcado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
|
||||||
@@ -823,37 +806,35 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Nombre técnico *") },
|
label = { Text("Nombre técnico / identificación *") },
|
||||||
placeholder = { Text("Denominación técnica") },
|
supportingText = { Text("Usá lo que figura en placa o identifica el elemento. El Código DH de campo se genera automáticamente.") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = commonName,
|
value = commonName,
|
||||||
onValueChange = { commonName = it },
|
onValueChange = { commonName = it },
|
||||||
label = { Text("Nombre habitual (opcional)") },
|
label = { Text("Nombre habitual") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = description,
|
value = description,
|
||||||
onValueChange = { description = it },
|
onValueChange = { description = it },
|
||||||
label = { Text("Descripción (opcional)") },
|
label = { Text("Descripción (opcional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
minLines = 2,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = if (editableAttributes.isEmpty()) ImeAction.Done else ImeAction.Next),
|
)
|
||||||
keyboardActions = KeyboardActions(
|
OutlinedTextField(
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
value = discoveryNotes,
|
||||||
onDone = { keyboard?.hide(); focusManager.clearFocus() },
|
onValueChange = { discoveryNotes = it },
|
||||||
),
|
label = { Text("Observaciones del alta (opcional)") },
|
||||||
|
supportingText = { Text("Anotá datos útiles para la revisión posterior en oficina.") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 2,
|
||||||
)
|
)
|
||||||
|
|
||||||
editableAttributes.forEachIndexed { index, definition ->
|
selectedType?.attributes?.forEach { definition ->
|
||||||
val isLastAttribute = index == editableAttributes.lastIndex
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = attributeValues[definition.code].orEmpty(),
|
value = attributeValues[definition.code].orEmpty(),
|
||||||
onValueChange = { attributeValues[definition.code] = it },
|
onValueChange = { attributeValues[definition.code] = it },
|
||||||
@@ -864,33 +845,28 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
|
|||||||
},
|
},
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
|
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
|
||||||
imeAction = if (isLastAttribute) ImeAction.Done else ImeAction.Next,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Down) },
|
|
||||||
onDone = { keyboard?.hide(); focusManager.clearFocus() },
|
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
ModernStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto")
|
ModernStepHeader(if (isSubinstallation) 3 else 2, "Ubicación y foto")
|
||||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||||
Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(14.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
|
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
|
||||||
Text(
|
Text(
|
||||||
"Al guardar capturamos el GPS y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
|
"Al guardar capturamos la ubicación y abrimos la cámara. La foto es obligatoria antes de registrar Hallazgos.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val attributesReady = editableAttributes
|
val attributesReady = selectedType?.attributes
|
||||||
.filter { it.isRequired }
|
?.filter { it.isRequired }
|
||||||
.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||||
|
?: false
|
||||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||||
Button(
|
Button(
|
||||||
onClick = { requestCreate() },
|
onClick = { requestCreate() },
|
||||||
@@ -920,7 +896,7 @@ private fun ModernInventoryBrowse(
|
|||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("yacimiento", "instalacion", "subinstalacion") }
|
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") }
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
|
Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) {
|
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) {
|
||||||
@@ -938,7 +914,7 @@ private fun ModernInventoryBrowse(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = onSearchChange,
|
onValueChange = onSearchChange,
|
||||||
label = { Text("Buscar Instalación o Subinstalación") },
|
label = { Text("Buscar Inventario") },
|
||||||
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),
|
||||||
@@ -968,11 +944,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
|
|||||||
Text(item.name, fontWeight = FontWeight.Bold)
|
Text(item.name, fontWeight = FontWeight.Bold)
|
||||||
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
StatusPill(when (typeCode) { "subinstalacion" -> "Subinstalación"; "yacimiento" -> "Yacimiento"; else -> "Instalación" })
|
StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación")
|
||||||
}
|
}
|
||||||
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
Text(
|
Text(
|
||||||
if (item.readyForFinding) "Listo para Hallazgos" else "Falta GPS / foto",
|
if (item.readyForFinding) "Listo para Hallazgos" else "Falta ubicación / foto",
|
||||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
@@ -980,11 +956,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
|
|||||||
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo")
|
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo")
|
||||||
}
|
}
|
||||||
if (typeCode in setOf("yacimiento", "instalacion")) {
|
if (typeCode == "instalacion") {
|
||||||
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
|
||||||
Icon(Icons.Filled.Add, null)
|
Icon(Icons.Filled.Add, null)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Text(if (typeCode == "yacimiento") "Agregar Instalación" else "Agregar Subinstalación")
|
Text("Agregar Subinstalación")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1011,7 +987,7 @@ private fun ModernCaptureCard(
|
|||||||
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") }
|
IconButton(onClick = onClose) { Icon(Icons.Filled.Close, "Cerrar") }
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(if (gps) "✓ GPS" else "GPS pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
|
Text(if (gps) "✓ Ubicación" else "Ubicación pendiente", color = if (gps) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
|
||||||
Text("Fotos · $photos")
|
Text("Fotos · $photos")
|
||||||
}
|
}
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
@@ -1111,17 +1087,34 @@ private fun ModernStepHeader(step: Int, title: String) {
|
|||||||
|
|
||||||
private fun modernItemTypeCode(item: FieldInventoryItem): String {
|
private fun modernItemTypeCode(item: FieldInventoryItem): String {
|
||||||
val type = item.type ?: return ""
|
val type = item.type ?: return ""
|
||||||
return modernNormalize(type.code.ifBlank { type.typeName ?: type.name })
|
return modernNormalize(type.typeName ?: type.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun modernNormalize(value: String): String = value.trim().lowercase()
|
private fun modernNormalize(value: String): String = value.trim().lowercase()
|
||||||
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
|
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
|
||||||
.replace("_", "").replace("-", "").replace(" ", "")
|
.replace("_", "").replace("-", "").replace(" ", "")
|
||||||
|
|
||||||
private fun modernStatusLabel(status: String): String = visitStatusLabelEs(status)
|
private fun modernStatusLabel(status: String): String = when (status) {
|
||||||
|
"PLANNED" -> "Planificada"
|
||||||
|
"IN_PROGRESS" -> "En curso"
|
||||||
|
"CLOSED" -> "Cerrada"
|
||||||
|
"CANCELLED" -> "Cancelada"
|
||||||
|
else -> "Estado no reconocido"
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
private fun buildModernAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||||
fieldAttributePayload(type.attributes, values)
|
type.attributes.mapNotNull { definition ->
|
||||||
|
val raw = values[definition.code]?.trim().orEmpty()
|
||||||
|
if (raw.isBlank()) return@mapNotNull null
|
||||||
|
definition.code to coerceModernAttribute(definition, raw)
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
private fun coerceModernAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||||
|
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||||
|
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||||
|
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
||||||
|
else -> raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun modernHasPermission(context: Context, permission: String): Boolean =
|
private fun modernHasPermission(context: Context, permission: String): Boolean =
|
||||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
@@ -1140,7 +1133,7 @@ private suspend fun currentModernGeo(context: Context): ModernGeoSnapshot = susp
|
|||||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||||
.addOnSuccessListener { location ->
|
.addOnSuccessListener { location ->
|
||||||
if (!continuation.isActive) return@addOnSuccessListener
|
if (!continuation.isActive) return@addOnSuccessListener
|
||||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación actual."))
|
||||||
else continuation.resume(ModernGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
else continuation.resume(ModernGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||||
}
|
}
|
||||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fun SignaturePad(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
|
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
|
||||||
Text(
|
Text(
|
||||||
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
|
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Canvas(
|
Canvas(
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.korexlabs.dhinspeccion.data.*
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val repository = remember { TechnicalRepository(context) }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
var open by rememberSaveable(assetId) { mutableStateOf(false) }
|
|
||||||
var busy by remember(assetId) { mutableStateOf(false) }
|
|
||||||
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
|
|
||||||
var error by remember(assetId) { mutableStateOf<String?>(null) }
|
|
||||||
var saved by remember(assetId) { mutableStateOf(false) }
|
|
||||||
val values = remember(assetId) { mutableStateMapOf<String, String>() }
|
|
||||||
|
|
||||||
OutlinedButton(onClick = { open = true }, enabled = enabled,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
|
|
||||||
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
|
|
||||||
}
|
|
||||||
if (!open) return
|
|
||||||
LaunchedEffect(assetId, open) {
|
|
||||||
busy = true; error = null; snapshot = null
|
|
||||||
try {
|
|
||||||
val loaded = repository.get(assetId)
|
|
||||||
snapshot = loaded
|
|
||||||
values.clear()
|
|
||||||
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
|
|
||||||
} catch (cancelled: CancellationException) { throw cancelled }
|
|
||||||
catch (failure: Exception) { error = DhRepository.humanError(failure) }
|
|
||||||
finally { busy = false }
|
|
||||||
}
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { if (!busy) open = false },
|
|
||||||
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
|
|
||||||
text = {
|
|
||||||
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
|
|
||||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
|
||||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
||||||
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
|
|
||||||
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
|
|
||||||
val current = values[definition.id].orEmpty()
|
|
||||||
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
|
|
||||||
Text(label)
|
|
||||||
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
|
|
||||||
options.forEach { option ->
|
|
||||||
FilterChip(selected = current == option, enabled = !busy,
|
|
||||||
onClick = { values[definition.id] = option },
|
|
||||||
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "Sí" else "No" else option) })
|
|
||||||
}
|
|
||||||
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
|
|
||||||
} else {
|
|
||||||
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
|
|
||||||
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
|
|
||||||
supportingText = {
|
|
||||||
when (definition.dataType) {
|
|
||||||
"DATE" -> Text("AAAA-MM-DD")
|
|
||||||
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(enabled = !busy && snapshot != null, onClick = {
|
|
||||||
val current = snapshot ?: return@TextButton
|
|
||||||
busy = true; error = null
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
|
|
||||||
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
|
|
||||||
}.toMap()
|
|
||||||
snapshot = repository.save(assetId, payload)
|
|
||||||
saved = true; open = false
|
|
||||||
} catch (cancelled: CancellationException) { throw cancelled }
|
|
||||||
catch (failure: Exception) { error = DhRepository.humanError(failure) }
|
|
||||||
finally { busy = false }
|
|
||||||
}
|
|
||||||
}) { Text("Guardar datos") }
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion.ui
|
|
||||||
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
|
||||||
"PLANNED" -> "Planificada"
|
|
||||||
"IN_PROGRESS" -> "En curso"
|
|
||||||
"CLOSED" -> "Cerrada"
|
|
||||||
"CANCELLED" -> "Cancelada"
|
|
||||||
null, "" -> "Sin estado"
|
|
||||||
else -> humanBackendLabelEs(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
|
||||||
"DRAFT" -> "En elaboración"
|
|
||||||
"LOCKED" -> "Para firmar"
|
|
||||||
"SEALED" -> "Firmada y cerrada"
|
|
||||||
"CLOSED" -> "Cerrada"
|
|
||||||
"RECTIFIED" -> "Rectificada"
|
|
||||||
"CANCELLED" -> "Cancelada"
|
|
||||||
null, "" -> "Sin estado"
|
|
||||||
else -> humanBackendLabelEs(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
|
||||||
"OPEN" -> "Abierto"
|
|
||||||
"PENDING" -> "Pendiente"
|
|
||||||
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
|
|
||||||
"COMPANY_RESPONDED" -> "Respondido por la empresa"
|
|
||||||
"VERIFICATION_PENDING" -> "Pendiente de verificación"
|
|
||||||
"VERIFICATION_SCHEDULED" -> "Verificación programada"
|
|
||||||
"READY_TO_CLOSE" -> "Listo para cierre"
|
|
||||||
"CLOSED" -> "Cerrado"
|
|
||||||
"CANCELLED" -> "Cancelado"
|
|
||||||
null, "" -> "Sin estado"
|
|
||||||
else -> humanBackendLabelEs(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
|
|
||||||
"DNI" -> "DNI"
|
|
||||||
"CUIL" -> "CUIL"
|
|
||||||
"PASSPORT" -> "Pasaporte"
|
|
||||||
"OTHER" -> "Otro"
|
|
||||||
else -> humanBackendLabelEs(type)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun humanBackendLabelEs(value: String): String {
|
|
||||||
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
|
|
||||||
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 6.5 KiB |
+1
-1
@@ -10,7 +10,7 @@ class DynamicFieldFlowContractTest {
|
|||||||
@Test
|
@Test
|
||||||
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
|
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
|
||||||
assertTrue(source.contains("Elegí la Instalación padre"))
|
assertTrue(source.contains("Elegí la Instalación padre"))
|
||||||
assertTrue(source.contains("Tipo de instalación *"))
|
assertTrue(source.contains("Clasificación técnica *"))
|
||||||
assertTrue(source.contains("Guardar y tomar foto"))
|
assertTrue(source.contains("Guardar y tomar foto"))
|
||||||
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
|
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
|
||||||
assertTrue(source.contains("parentId = item.id"))
|
assertTrue(source.contains("parentId = item.id"))
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion
|
|
||||||
|
|
||||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
|
||||||
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class FieldAttributePayloadTest {
|
|
||||||
@Test
|
|
||||||
fun payloadUsesDefinitionIdsExpectedByApiAndKeepsTypedValues() {
|
|
||||||
val definitions = listOf(
|
|
||||||
FieldAttributeDefinition("id-marca", "campo_marca", "Marca", "TEXT"),
|
|
||||||
FieldAttributeDefinition("id-capacidad", "campo_capacidad", "Capacidad", "NUMBER"),
|
|
||||||
FieldAttributeDefinition("id-serie", "campo_numero_serie", "Número de serie", "TEXT"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val payload = fieldAttributePayload(
|
|
||||||
definitions,
|
|
||||||
mapOf("campo_marca" to " algo ", "campo_capacidad" to "37,73", "campo_numero_serie" to " "),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals("algo", payload["id-marca"])
|
|
||||||
assertEquals(37.73, payload["id-capacidad"])
|
|
||||||
assertFalse(payload.containsKey("id-serie"))
|
|
||||||
assertTrue(payload.keys.none { it.startsWith("campo_") })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion
|
|
||||||
import com.korexlabs.dhinspeccion.data.FieldCoordinates
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
class FieldCoordinatesTest {
|
|
||||||
@Test fun realGpsIsRoundedToApiPrecision() {
|
|
||||||
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
|
|
||||||
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
|
|
||||||
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
|
|
||||||
}
|
|
||||||
@Test fun boundsAndZeroRemainValid() {
|
|
||||||
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
|
|
||||||
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
|
|
||||||
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
|
|
||||||
}
|
|
||||||
@Test fun invalidCoordinatesAreRejected() {
|
|
||||||
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
|
|
||||||
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
|
|
||||||
catch (_: IllegalArgumentException) { }
|
|
||||||
}
|
|
||||||
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
|
|
||||||
catch (_: IllegalArgumentException) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+41
-49
@@ -6,71 +6,63 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class FinalFieldFlowContractTest {
|
class FinalFieldFlowContractTest {
|
||||||
|
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
|
||||||
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
|
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
|
||||||
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
|
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
|
||||||
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
|
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
|
||||||
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
|
private val viewModel = File("src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt").readText()
|
||||||
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun inspectionActFindingFlowMatchesFieldProcedure() {
|
fun inspectionIsPlannedInOfficeAndOnlyStartedOnTablet() {
|
||||||
|
assertTrue(home.contains("Trabajo planificado desde oficina"))
|
||||||
|
assertFalse(home.contains("MobileInspectionOpenRepository"))
|
||||||
|
assertFalse(home.contains("Abrir inspección ahora"))
|
||||||
|
assertTrue(home.contains("fieldVisits = model.visits.filter { it.status != \"DRAFT\" }"))
|
||||||
|
assertTrue(home.contains("\"DRAFT\" -> \"Borrador\""))
|
||||||
assertTrue(visit.contains("Iniciar inspección"))
|
assertTrue(visit.contains("Iniciar inspección"))
|
||||||
assertTrue(acts.contains("Crear nueva Acta"))
|
|
||||||
assertTrue(acts.contains("Agregar Hallazgo"))
|
|
||||||
assertTrue(visit.contains("Instalación"))
|
|
||||||
assertTrue(visit.contains("Subinstalación"))
|
|
||||||
assertTrue(visit.contains("Guardar y tomar foto"))
|
|
||||||
assertTrue(acts.contains("Cerrar contenido y pasar a firma"))
|
|
||||||
assertTrue(acts.contains("Nombres y apellidos *"))
|
|
||||||
assertTrue(acts.contains("Cargo / función *"))
|
|
||||||
assertTrue(acts.contains("Email *"))
|
|
||||||
assertTrue(acts.contains("En disconformidad"))
|
|
||||||
assertTrue(acts.contains("Motivo de disconformidad *"))
|
|
||||||
assertTrue(acts.contains("urgencia pertenece al Acta completa"))
|
|
||||||
assertTrue(acts.contains("urgencia no se asigna a cada Hallazgo"))
|
|
||||||
assertTrue(labels.contains("\"LOCKED\" -> \"Para firmar\""))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
|
fun findingsAreScopedToInstallationsAndSubinstallations() {
|
||||||
assertTrue(visit.contains("Nombre técnico *"))
|
assertTrue(visit.contains("setOf(\"instalacion\", \"subinstalacion\")"))
|
||||||
assertTrue(visit.contains("Nombre habitual (opcional)"))
|
assertTrue(visit.contains("Buscar existente o cargar Instalación / Subinstalación"))
|
||||||
assertTrue(visit.contains("Descripción (opcional)"))
|
assertTrue(visit.contains("Guardar y tomar foto"))
|
||||||
assertTrue(visit.contains("familyReady"))
|
|
||||||
assertTrue(visit.contains("attributesReady"))
|
|
||||||
assertTrue(visit.contains("GPS + foto"))
|
|
||||||
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
|
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun structuralSelectionUsesTheServerTypeCode() {
|
fun fieldCreationKeepsDashboardIdentityAndTechnicalRequirements() {
|
||||||
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
|
assertTrue(visit.contains("Nombre técnico / identificación *"))
|
||||||
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
|
assertTrue(visit.contains("El Código DH de campo se genera automáticamente"))
|
||||||
|
assertTrue(visit.contains("Clasificación técnica"))
|
||||||
|
assertTrue(visit.contains("description = description"))
|
||||||
|
assertTrue(visit.contains("discoveryNotes = discoveryNotes"))
|
||||||
|
assertTrue(viewModel.contains("description = description?.trim()"))
|
||||||
|
assertTrue(viewModel.contains("discoveryNotes = discoveryNotes?.trim()"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
|
fun closingActLeavesItPendingForSignatureAndResolvesAbsenceCorrectly() {
|
||||||
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
|
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
|
||||||
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
|
assertTrue(acts.contains("Acta pendiente de firma"))
|
||||||
assertTrue(acts.contains("DNI *"))
|
assertTrue(viewModel.contains("Acta cerrada y pendiente de firma"))
|
||||||
assertFalse(acts.contains("PASSPORT"))
|
assertTrue(acts.contains("Registrar ausencia para la firma"))
|
||||||
assertFalse(acts.contains("OTHER"))
|
assertTrue(acts.contains("recordCompanyOutcome(\"ABSENT\""))
|
||||||
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
|
assertTrue(viewModel.contains("status !in setOf(\"REFUSED\", \"ABSENT\")"))
|
||||||
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
|
assertTrue(acts.contains("Finalizar Acta"))
|
||||||
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
|
assertTrue(acts.contains("Acta finalizada e inmutable"))
|
||||||
assertFalse(acts.contains("Acta BLOQUEADA"))
|
|
||||||
assertFalse(acts.contains("SELLAR Acta"))
|
|
||||||
assertFalse(acts.contains("Acta SELLADA"))
|
|
||||||
assertFalse(acts.contains("Text(\"Email\")"))
|
|
||||||
}
|
|
||||||
@Test
|
|
||||||
fun findingCreationIsCompactSearchableAndHasNoCorrectionDate() {
|
|
||||||
assertTrue(findings.contains("ExposedDropdownMenuBox"))
|
|
||||||
assertTrue(findings.contains("Tipo de Hallazgo *"))
|
|
||||||
assertTrue(findings.contains("Buscar o seleccionar"))
|
|
||||||
assertTrue(findings.contains("Otro / No está en la lista"))
|
|
||||||
assertFalse(findings.contains("Fecha de corrección"))
|
|
||||||
assertFalse(findings.contains("Hallazgos del modelo autoritativo"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeFieldScreensDoNotExposeKnownEnglishLabels() {
|
||||||
|
val activeUi = home + visit + acts + findings
|
||||||
|
assertFalse(activeUi.contains("Text(\"Email\")"))
|
||||||
|
assertFalse(activeUi.contains("El checklist requiere actualización"))
|
||||||
|
assertFalse(activeUi.contains("Acta BLOQUEADA"))
|
||||||
|
assertFalse(activeUi.contains("SELLAR Acta"))
|
||||||
|
assertFalse(activeUi.contains("Acta SELLADA"))
|
||||||
|
assertTrue(acts.contains("\"PASSPORT\" -> \"Pasaporte\""))
|
||||||
|
assertTrue(acts.contains("\"OTHER\" -> \"Otro\""))
|
||||||
|
assertTrue(findings.contains("findingStatusLabel(finding.status)"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion
|
|
||||||
|
|
||||||
import com.korexlabs.dhinspeccion.data.*
|
|
||||||
import java.io.IOException
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import retrofit2.HttpException
|
|
||||||
import retrofit2.Response
|
|
||||||
|
|
||||||
class MobileSessionCoordinatorTest {
|
|
||||||
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
|
|
||||||
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
|
|
||||||
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
|
|
||||||
|
|
||||||
private class Store(var value: StoredSession?) {
|
|
||||||
var clears = 0
|
|
||||||
fun load() = value
|
|
||||||
fun clear() { clears++; value = null }
|
|
||||||
fun save(r: MobileSessionResponse): StoredSession =
|
|
||||||
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
|
|
||||||
val previous = old(); val store = Store(previous); var calls = 0
|
|
||||||
val results = coroutineScope {
|
|
||||||
(1..4).map {
|
|
||||||
async {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
|
|
||||||
calls++; delay(20); response()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
}
|
|
||||||
assertEquals(1, calls)
|
|
||||||
assertTrue(results.all { it.refreshToken == "r2" })
|
|
||||||
assertEquals(0, store.clears)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
|
|
||||||
for (failure in listOf(IOException("offline"), http(500), http(429))) {
|
|
||||||
val previous = old(); val store = Store(previous)
|
|
||||||
try {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
|
|
||||||
fail("Expected request failure")
|
|
||||||
} catch (caught: Exception) { assertSame(failure, caught) }
|
|
||||||
assertEquals(previous, store.value)
|
|
||||||
assertEquals(0, store.clears)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
|
|
||||||
val previous = old(); val store = Store(previous)
|
|
||||||
try {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
|
|
||||||
fail("Expected unauthorized")
|
|
||||||
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
|
|
||||||
assertNull(store.value)
|
|
||||||
assertEquals(1, store.clears)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun cancelledRefreshKeepsSession() = runBlocking {
|
|
||||||
val previous = old(); val store = Store(previous)
|
|
||||||
try {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
|
|
||||||
fail("Expected cancellation")
|
|
||||||
} catch (_: CancellationException) { }
|
|
||||||
assertEquals(previous, store.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
|
|
||||||
val previous = old(); val store = Store(previous)
|
|
||||||
try {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
|
|
||||||
store.clear(); response()
|
|
||||||
}
|
|
||||||
fail("Must reject a refresh after logout")
|
|
||||||
} catch (_: IllegalStateException) { }
|
|
||||||
assertNull(store.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
|
|
||||||
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
|
|
||||||
try {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
|
|
||||||
fail("No refresh allowed for another inspector"); response()
|
|
||||||
}
|
|
||||||
fail("Must reject account switch")
|
|
||||||
} catch (_: IllegalStateException) { }
|
|
||||||
assertEquals("u2", store.value?.userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
|
|
||||||
val previous = old(); val store = Store(previous)
|
|
||||||
try {
|
|
||||||
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
|
|
||||||
store.value = previous.copy(refreshToken = "new-login")
|
|
||||||
throw http(401)
|
|
||||||
}
|
|
||||||
} catch (_: HttpException) { }
|
|
||||||
assertEquals("new-login", store.value?.refreshToken)
|
|
||||||
assertEquals(0, store.clears)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class OperationTrackerTest {
|
|
||||||
@Test fun doubleTapCannotQueueASecondWrite() {
|
|
||||||
val tracker = OperationTracker()
|
|
||||||
assertTrue(tracker.begin(mutation = true))
|
|
||||||
assertFalse(tracker.begin(mutation = true))
|
|
||||||
tracker.end()
|
|
||||||
assertFalse(tracker.busy)
|
|
||||||
assertTrue(tracker.begin(mutation = true))
|
|
||||||
}
|
|
||||||
@Test fun BusyRemainsUntilEveryReadCompletes() {
|
|
||||||
val tracker = OperationTracker()
|
|
||||||
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
|
|
||||||
tracker.end(); assertTrue(tracker.busy)
|
|
||||||
assertFalse(tracker.begin(true))
|
|
||||||
tracker.end(); assertFalse(tracker.busy)
|
|
||||||
}
|
|
||||||
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
|
|
||||||
val tracker = OperationTracker()
|
|
||||||
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
|
|
||||||
tracker.end(); assertFalse(tracker.begin(true))
|
|
||||||
tracker.end(); assertTrue(tracker.begin(true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(39, BuildConfig.VERSION_CODE)
|
assertEquals(28, BuildConfig.VERSION_CODE)
|
||||||
assertEquals("0.19.11-debug", BuildConfig.VERSION_NAME)
|
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package com.korexlabs.dhinspeccion
|
|
||||||
import com.korexlabs.dhinspeccion.data.*
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
class TechnicalValuesTest {
|
|
||||||
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
|
|
||||||
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
|
|
||||||
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
|
|
||||||
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
|
|
||||||
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
|
|
||||||
}
|
|
||||||
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
|
|
||||||
assertNull(technicalInputValue(field("TEXT"), " "))
|
|
||||||
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
|
|
||||||
catch (_: IllegalArgumentException) { }
|
|
||||||
}
|
|
||||||
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
|
|
||||||
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
|
|
||||||
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
|
|
||||||
catch (_: IllegalArgumentException) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Test fun validDatesAndSelectRemainCanonical() {
|
|
||||||
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
|
|
||||||
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
|
|
||||||
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,5 +2,4 @@ plugins {
|
|||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
||||||
id("org.jetbrains.kotlin.kapt") version "2.2.20" apply false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ COPY package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY nest-cli.json tsconfig.json ./
|
COPY nest-cli.json tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY assets ./assets
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
@@ -14,7 +13,6 @@ WORKDIR /app
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/assets ./assets
|
|
||||||
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,78 +0,0 @@
|
|||||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
|
||||||
Upstream-Name: DejaVu fonts
|
|
||||||
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
|
|
||||||
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
|
|
||||||
Source: https://dejavu-fonts.github.io/
|
|
||||||
|
|
||||||
Files: *
|
|
||||||
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
|
||||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
|
||||||
DejaVu changes are in public domain.
|
|
||||||
License: bitstream-vera
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of the fonts accompanying this license ("Fonts") and associated
|
|
||||||
documentation files (the "Font Software"), to reproduce and distribute the
|
|
||||||
Font Software, including without limitation the rights to use, copy, merge,
|
|
||||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
|
||||||
persons to whom the Font Software is furnished to do so, subject to the
|
|
||||||
following conditions:
|
|
||||||
.
|
|
||||||
The above copyright and trademark notices and this permission notice shall
|
|
||||||
be included in all copies of one or more of the Font Software typefaces.
|
|
||||||
.
|
|
||||||
The Font Software may be modified, altered, or added to, and in particular
|
|
||||||
the designs of glyphs or characters in the Fonts may be modified and
|
|
||||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
|
||||||
are renamed to names not containing either the words "Bitstream" or the word
|
|
||||||
"Vera".
|
|
||||||
.
|
|
||||||
This License becomes null and void to the extent applicable to Fonts or Font
|
|
||||||
Software that has been modified and is distributed under the "Bitstream
|
|
||||||
Vera" names.
|
|
||||||
.
|
|
||||||
The Font Software may be sold as part of a larger software package but no
|
|
||||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
|
||||||
.
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
||||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
|
||||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
|
||||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
|
||||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
|
||||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
|
||||||
FONT SOFTWARE.
|
|
||||||
.
|
|
||||||
Except as contained in this notice, the names of Gnome, the Gnome
|
|
||||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
|
||||||
otherwise to promote the sale, use or other dealings in this Font Software
|
|
||||||
without prior written authorization from the Gnome Foundation or Bitstream
|
|
||||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
|
||||||
org.
|
|
||||||
|
|
||||||
Files: debian/*
|
|
||||||
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
|
|
||||||
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
|
|
||||||
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
|
|
||||||
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
|
|
||||||
License: GPL-2+
|
|
||||||
This program is free software; you can redistribute it
|
|
||||||
and/or modify it under the terms of the GNU General Public
|
|
||||||
License as published by the Free Software Foundation; either
|
|
||||||
version 2 of the License, or (at your option) any later
|
|
||||||
version.
|
|
||||||
.
|
|
||||||
This program is distributed in the hope that it will be
|
|
||||||
useful, but WITHOUT ANY WARRANTY; without even the implied
|
|
||||||
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. See the GNU General Public License for more
|
|
||||||
details.
|
|
||||||
.
|
|
||||||
You should have received a copy of the GNU General Public
|
|
||||||
License along with this package; if not, write to the Free
|
|
||||||
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
|
|
||||||
Boston, MA 02110-1301 USA
|
|
||||||
.
|
|
||||||
On Debian systems, the full text of the GNU General Public
|
|
||||||
License version 2 can be found in the file
|
|
||||||
/usr/share/common-licenses/GPL-2'.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Generated
+3
-742
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-11",
|
"version": "0.29.0-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-11",
|
"version": "0.29.0-1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
@@ -21,11 +21,9 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"pdfkit": "^0.20.2",
|
|
||||||
"pg": "^8.0.0",
|
"pg": "^8.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"sharp": "^0.35.4",
|
|
||||||
"typeorm": "^0.3.0"
|
"typeorm": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -34,7 +32,6 @@
|
|||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pdfkit": "^0.17.6",
|
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.0"
|
"typescript": "^5.9.0"
|
||||||
@@ -219,16 +216,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.11.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
|
||||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@epic-web/invariant": {
|
"node_modules/@epic-web/invariant": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||||
@@ -677,506 +664,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/colour": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-wasm32": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/runtime": "^1.11.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||||
@@ -1972,30 +1459,6 @@
|
|||||||
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.21.3 || >=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@noble/hashes": {
|
|
||||||
"version": "1.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
|
||||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.21.3 || >=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@phc/format": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
@@ -2021,15 +1484,6 @@
|
|||||||
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
|
||||||
"version": "0.5.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
|
||||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
@@ -2205,16 +1659,6 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/pdfkit": {
|
|
||||||
"version": "0.17.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
|
||||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -2741,15 +2185,6 @@
|
|||||||
"concat-map": "0.0.1"
|
"concat-map": "0.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brotli": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.8",
|
"version": "4.28.8",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||||
@@ -3370,21 +2805,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dfa": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||||
@@ -3736,6 +3156,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -3768,12 +3189,6 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fflate": {
|
|
||||||
"version": "0.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
|
||||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/file-type": {
|
"node_modules/file-type": {
|
||||||
"version": "21.3.4",
|
"version": "21.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||||
@@ -3813,32 +3228,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fontkit": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@swc/helpers": "^0.5.12",
|
|
||||||
"brotli": "^1.3.2",
|
|
||||||
"clone": "^2.1.2",
|
|
||||||
"dfa": "^1.2.0",
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"restructure": "^3.0.0",
|
|
||||||
"tiny-inflate": "^1.0.3",
|
|
||||||
"unicode-properties": "^1.4.0",
|
|
||||||
"unicode-trie": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fontkit/node_modules/clone": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -4517,25 +3906,6 @@
|
|||||||
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
|
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/linebreak": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "0.0.8",
|
|
||||||
"unicode-trie": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/linebreak/node_modules/base64-js": {
|
|
||||||
"version": "0.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
|
||||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -5018,12 +4388,6 @@
|
|||||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "0.2.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
|
||||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -5111,20 +4475,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pdfkit": {
|
|
||||||
"version": "0.20.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
|
|
||||||
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@noble/ciphers": "^1.3.0",
|
|
||||||
"@noble/hashes": "^1.8.0",
|
|
||||||
"fflate": "^0.8.3",
|
|
||||||
"fontkit": "^2.0.4",
|
|
||||||
"linebreak": "^1.1.0",
|
|
||||||
"png-js": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.23.0",
|
"version": "8.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||||
@@ -5244,14 +4594,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/png-js": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
|
|
||||||
"dependencies": {
|
|
||||||
"fflate": "^0.8.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -5451,12 +4793,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/restructure": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
@@ -5661,55 +4997,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
|
||||||
"version": "0.35.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@img/colour": "^1.1.0",
|
|
||||||
"detect-libc": "^2.1.2",
|
|
||||||
"semver": "^7.8.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.9.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/libvips"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@img/sharp-darwin-arm64": "0.35.4",
|
|
||||||
"@img/sharp-darwin-x64": "0.35.4",
|
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
|
||||||
"@img/sharp-linux-arm": "0.35.4",
|
|
||||||
"@img/sharp-linux-arm64": "0.35.4",
|
|
||||||
"@img/sharp-linux-ppc64": "0.35.4",
|
|
||||||
"@img/sharp-linux-riscv64": "0.35.4",
|
|
||||||
"@img/sharp-linux-s390x": "0.35.4",
|
|
||||||
"@img/sharp-linux-x64": "0.35.4",
|
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
|
||||||
"@img/sharp-win32-arm64": "0.35.4",
|
|
||||||
"@img/sharp-win32-ia32": "0.35.4",
|
|
||||||
"@img/sharp-win32-x64": "0.35.4"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -6150,12 +5437,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tiny-inflate": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/to-buffer": {
|
"node_modules/to-buffer": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
|
||||||
@@ -6606,26 +5887,6 @@
|
|||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicode-properties": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.0",
|
|
||||||
"unicode-trie": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unicode-trie": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pako": "^0.2.5",
|
|
||||||
"tiny-inflate": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
|||||||
+1
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-11",
|
"version": "0.29.0-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,11 +28,9 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"pdfkit": "^0.20.2",
|
|
||||||
"pg": "^8.0.0",
|
"pg": "^8.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"sharp": "^0.35.4",
|
|
||||||
"typeorm": "^0.3.0"
|
"typeorm": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -41,7 +39,6 @@
|
|||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pdfkit": "^0.17.6",
|
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.9.0"
|
"typescript": "^5.9.0"
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
|
||||||
import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
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 { ActAdministrationService } from './act-administration.service';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
|
||||||
|
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
||||||
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||||
|
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
||||||
|
|
||||||
@Controller('act-administration')
|
@Controller('act-administration')
|
||||||
export class ActAdministrationQueueController {
|
export class ActAdministrationQueueController {
|
||||||
@@ -16,8 +21,11 @@ export class ActAdministrationQueueController {
|
|||||||
export class ActAdministrationController {
|
export class ActAdministrationController {
|
||||||
constructor(private readonly administration: ActAdministrationService) {}
|
constructor(private readonly administration: ActAdministrationService) {}
|
||||||
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
||||||
// Historic deadlines and responses remain readable here.
|
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
|
||||||
// New administrative entries are recorded on the related Informe.
|
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
|
||||||
|
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
|
||||||
|
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('act-company-responses')
|
@Controller('act-company-responses')
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
||||||
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
|
||||||
import { RolesController } from './roles/roles.controller';
|
import { RolesController } from './roles/roles.controller';
|
||||||
import { RolesService } from './roles/roles.service';
|
import { RolesService } from './roles/roles.service';
|
||||||
import { UsersController } from './users/users.controller';
|
import { UsersController } from './users/users.controller';
|
||||||
import { UsersService } from './users/users.service';
|
import { UsersService } from './users/users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PhaseADataModule, AuditModule, AuthModule, InspectionReportsModule],
|
imports: [PhaseADataModule, AuditModule, AuthModule],
|
||||||
controllers: [UsersController, RolesController],
|
controllers: [UsersController, RolesController],
|
||||||
providers: [UsersService, RolesService],
|
providers: [UsersService, RolesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ export class CreateUserDto {
|
|||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||||
)
|
)
|
||||||
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@MaxLength(320)
|
@MaxLength(320)
|
||||||
email!: string;
|
email?: string | null;
|
||||||
|
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
|
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
IsEmail,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
MaxLength,
|
|
||||||
MinLength,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
const optionalText = ({ value }: { value: unknown }) =>
|
|
||||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
||||||
|
|
||||||
export class UpdateSelfProfileDto {
|
|
||||||
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
|
||||||
@IsEmail()
|
|
||||||
@MaxLength(320)
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@Transform(optionalText)
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(40)
|
|
||||||
phone?: string | null;
|
|
||||||
|
|
||||||
@Transform(optionalText)
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(160)
|
|
||||||
jobTitle?: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
import {
|
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
MinLength,
|
|
||||||
ValidateIf,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { SmtpSecurityMode } from '../../../database/entities';
|
|
||||||
|
|
||||||
export enum UserSmtpMode {
|
|
||||||
SYSTEM = 'SYSTEM',
|
|
||||||
CUSTOM = 'CUSTOM',
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = ({ value }: { value: unknown }) =>
|
|
||||||
typeof value === 'string' ? value.trim() : value;
|
|
||||||
|
|
||||||
export class UpdateUserSmtpSettingsDto {
|
|
||||||
@IsEnum(UserSmtpMode)
|
|
||||||
mode!: UserSmtpMode;
|
|
||||||
|
|
||||||
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
|
|
||||||
@Transform(trimmed)
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(255)
|
|
||||||
host?: string;
|
|
||||||
|
|
||||||
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(65535)
|
|
||||||
port?: number;
|
|
||||||
|
|
||||||
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
|
|
||||||
@IsEnum(SmtpSecurityMode)
|
|
||||||
securityMode?: SmtpSecurityMode;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(trimmed)
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(255)
|
|
||||||
username?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(512)
|
|
||||||
password?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(trimmed)
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(200)
|
|
||||||
fromName?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled = true;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -10,43 +9,24 @@ import {
|
|||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
|
||||||
UploadedFile,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import type { Response } from 'express';
|
|
||||||
import { AuditService } from '../../audit/audit.service';
|
|
||||||
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||||
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||||
import type {
|
import type {
|
||||||
AuthPrincipal,
|
AuthPrincipal,
|
||||||
RequestWithContext,
|
RequestWithContext,
|
||||||
} from '../../common/http/request-context';
|
} from '../../common/http/request-context';
|
||||||
import { AuditAction } from '../../database/entities';
|
|
||||||
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
|
|
||||||
import {
|
|
||||||
MAX_INSPECTION_SIGNATURE_BYTES,
|
|
||||||
type UploadedInspectionSignatureFile,
|
|
||||||
} from '../../inspection-closing/inspection-signature-file';
|
|
||||||
import { administrationAuditContext } from '../common/administration-audit';
|
|
||||||
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||||
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||||
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||||
import { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
|
|
||||||
import { UpdateUserSmtpSettingsDto } from './dto/update-user-smtp-settings.dto';
|
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(
|
constructor(private readonly users: UsersService) {}
|
||||||
private readonly users: UsersService,
|
|
||||||
private readonly smtp: SmtpDeliveryService,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('users.read')
|
@RequirePermissions('users.read')
|
||||||
@@ -54,112 +34,6 @@ export class UsersController {
|
|||||||
return this.users.list(query);
|
return this.users.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('self/profile')
|
|
||||||
selfProfile(@CurrentAuth() principal: AuthPrincipal) {
|
|
||||||
return this.users.getSelfProfile(principal.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('self/profile')
|
|
||||||
updateSelfProfile(
|
|
||||||
@Body() dto: UpdateSelfProfileDto,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
return this.users.updateSelfProfile(dto, principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('self/signature')
|
|
||||||
selfSignature(@CurrentAuth() principal: AuthPrincipal) {
|
|
||||||
return this.users.getSelfSignature(principal.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('self/signature/content')
|
|
||||||
async selfSignatureContent(
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Res() response: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const signature = await this.users.getSelfSignatureContent(principal.userId);
|
|
||||||
response.setHeader('Content-Type', 'image/png');
|
|
||||||
response.setHeader('Content-Length', String(signature.buffer.length));
|
|
||||||
response.setHeader('Cache-Control', 'private, no-store');
|
|
||||||
response.setHeader('ETag', `"${signature.sha256}"`);
|
|
||||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
response.send(signature.buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('self/signature')
|
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
|
||||||
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
|
|
||||||
}))
|
|
||||||
updateSelfSignature(
|
|
||||||
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
return this.users.updateSelfSignature(file, principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('self/signature')
|
|
||||||
deleteSelfSignature(
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
return this.users.deleteSelfSignature(principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('self/smtp')
|
|
||||||
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
|
|
||||||
return this.smtp.publicUserSettings(principal.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('self/smtp')
|
|
||||||
async updateSelfSmtp(
|
|
||||||
@Body() dto: UpdateUserSmtpSettingsDto,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
const before = await this.smtp.publicUserSettings(principal.userId);
|
|
||||||
const after = await this.smtp.saveUserSettings(principal.userId, dto);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.USER_SMTP_SETTINGS_UPDATED,
|
|
||||||
entityType: 'user_smtp_settings',
|
|
||||||
entityId: principal.userId,
|
|
||||||
beforeData: before as Record<string, unknown>,
|
|
||||||
afterData: after as Record<string, unknown>,
|
|
||||||
metadata: { passwordNeverReturned: true, scope: 'SELF' },
|
|
||||||
});
|
|
||||||
return after;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('self/smtp/test')
|
|
||||||
async testSelfSmtp(
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
const profile = await this.users.getSelfProfile(principal.userId);
|
|
||||||
if (!profile.email) throw new Error('El usuario no tiene email configurado');
|
|
||||||
const sent = await this.smtp.send({
|
|
||||||
to: profile.email,
|
|
||||||
subject: 'DH Inspección · Prueba de correo personal',
|
|
||||||
text: 'Este correo confirma que tu configuración de correo en DH Inspección funciona correctamente.',
|
|
||||||
attachment: {
|
|
||||||
filename: 'dh-inspeccion-prueba-correo.txt',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
content: Buffer.from('DH Inspección · Correo personal OK\n', 'utf8'),
|
|
||||||
},
|
|
||||||
}, principal.userId);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.USER_SMTP_TEST_SENT,
|
|
||||||
entityType: 'user_smtp_settings',
|
|
||||||
entityId: principal.userId,
|
|
||||||
afterData: { recipient: profile.email, messageId: sent.messageId },
|
|
||||||
metadata: { scope: 'SELF' },
|
|
||||||
});
|
|
||||||
return { ok: true, recipient: profile.email, messageId: sent.messageId };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermissions('users.create')
|
@RequirePermissions('users.create')
|
||||||
create(
|
create(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -31,12 +29,7 @@ import type { CreateUserDto } from './dto/create-user.dto';
|
|||||||
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
|
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||||
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||||
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||||
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
|
|
||||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import {
|
|
||||||
inspectInspectionSignatureFile,
|
|
||||||
type UploadedInspectionSignatureFile,
|
|
||||||
} from '../../inspection-closing/inspection-signature-file';
|
|
||||||
|
|
||||||
export interface UserRoleView {
|
export interface UserRoleView {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,14 +37,6 @@ export interface UserRoleView {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserReusableSignatureView {
|
|
||||||
configured: boolean;
|
|
||||||
mimeType: 'image/png' | null;
|
|
||||||
sizeBytes: number | null;
|
|
||||||
imageSha256: string | null;
|
|
||||||
updatedAt: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdministrativeUserView {
|
export interface AdministrativeUserView {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -71,7 +56,6 @@ export interface AdministrativeUserView {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
roles: UserRoleView[];
|
roles: UserRoleView[];
|
||||||
smtpMode: 'SYSTEM' | 'CUSTOM';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserViewRow extends AdministrativeUserView {
|
interface UserViewRow extends AdministrativeUserView {
|
||||||
@@ -92,10 +76,10 @@ function roleSelectionInvalid(): BadRequestException {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function userEmailRequired(): BadRequestException {
|
function inspectorEmailRequired(): BadRequestException {
|
||||||
return new BadRequestException({
|
return new BadRequestException({
|
||||||
code: 'USER_EMAIL_REQUIRED',
|
code: 'INSPECTOR_EMAIL_REQUIRED',
|
||||||
message: 'Cada usuario de Hidrocarburos debe tener un email válido',
|
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +144,6 @@ export class UsersService {
|
|||||||
user_account.password_changed_at AS "passwordChangedAt",
|
user_account.password_changed_at AS "passwordChangedAt",
|
||||||
user_account.created_at AS "createdAt",
|
user_account.created_at AS "createdAt",
|
||||||
user_account.updated_at AS "updatedAt",
|
user_account.updated_at AS "updatedAt",
|
||||||
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
|
|
||||||
COALESCE(
|
COALESCE(
|
||||||
JSONB_AGG(
|
JSONB_AGG(
|
||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
@@ -202,105 +185,6 @@ export class UsersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSelfProfile(userId: string): Promise<AdministrativeUserView> {
|
|
||||||
return this.getById(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSelfSignature(userId: string): Promise<UserReusableSignatureView> {
|
|
||||||
const [row] = (await this.dataSource.query(`
|
|
||||||
SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes",
|
|
||||||
image_sha256 AS "imageSha256", updated_at AS "updatedAt"
|
|
||||||
FROM user_signature_profiles WHERE user_id=$1
|
|
||||||
`, [userId])) as Array<{
|
|
||||||
mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date;
|
|
||||||
}>;
|
|
||||||
return row ? { configured: true, ...row } : {
|
|
||||||
configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> {
|
|
||||||
const [row] = (await this.dataSource.query(`
|
|
||||||
SELECT image_data AS buffer, image_sha256 AS sha256
|
|
||||||
FROM user_signature_profiles WHERE user_id=$1
|
|
||||||
`, [userId])) as Array<{ buffer: Buffer; sha256: string }>;
|
|
||||||
if (!row) throw new NotFoundException({
|
|
||||||
code: 'USER_SIGNATURE_NOT_CONFIGURED',
|
|
||||||
message: 'Todavía no cargaste tu firma de inspector',
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSelfSignature(
|
|
||||||
file: UploadedInspectionSignatureFile | undefined,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<UserReusableSignatureView> {
|
|
||||||
if (!principal.roles.includes('inspector')) throw new ForbiddenException({
|
|
||||||
code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED',
|
|
||||||
message: 'La firma reutilizable está disponible para usuarios con rol Inspector',
|
|
||||||
});
|
|
||||||
const inspected = inspectInspectionSignatureFile(file);
|
|
||||||
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
|
||||||
const before = await this.getSelfSignature(principal.userId);
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
|
||||||
await manager.query(`
|
|
||||||
INSERT INTO user_signature_profiles (
|
|
||||||
user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by
|
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$1)
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
|
||||||
original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type,
|
|
||||||
size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256,
|
|
||||||
image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by,
|
|
||||||
updated_at=CURRENT_TIMESTAMP
|
|
||||||
`, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.USER_UPDATED,
|
|
||||||
entityType: 'user_signature_profile',
|
|
||||||
entityId: principal.userId,
|
|
||||||
beforeData: before as unknown as Record<string, unknown>,
|
|
||||||
afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length },
|
|
||||||
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
|
|
||||||
}, manager);
|
|
||||||
});
|
|
||||||
return this.getSelfSignature(principal.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSelfSignature(
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<UserReusableSignatureView> {
|
|
||||||
const before = await this.getSelfSignature(principal.userId);
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
|
||||||
await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.USER_UPDATED,
|
|
||||||
entityType: 'user_signature_profile',
|
|
||||||
entityId: principal.userId,
|
|
||||||
beforeData: before as unknown as Record<string, unknown>,
|
|
||||||
afterData: { configured: false },
|
|
||||||
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
|
|
||||||
}, manager);
|
|
||||||
});
|
|
||||||
return this.getSelfSignature(principal.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateSelfProfile(
|
|
||||||
dto: UpdateSelfProfileDto,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<AdministrativeUserView> {
|
|
||||||
if (!dto.email?.trim()) throw userEmailRequired();
|
|
||||||
return this.update(
|
|
||||||
principal.userId,
|
|
||||||
{ email: dto.email, phone: dto.phone, jobTitle: dto.jobTitle },
|
|
||||||
principal,
|
|
||||||
request,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
dto: CreateUserDto,
|
dto: CreateUserDto,
|
||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
@@ -311,10 +195,10 @@ export class UsersService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const roles = await this.resolveRoles(manager, dto.roleIds);
|
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||||
if (!dto.email?.trim()) throw userEmailRequired();
|
this.assertInspectorHasEmail(roles, dto.email ?? null);
|
||||||
const user = manager.getRepository(User).create({
|
const user = manager.getRepository(User).create({
|
||||||
username: dto.username.trim().toLowerCase(),
|
username: dto.username.trim().toLowerCase(),
|
||||||
email: dto.email.trim().toLowerCase(),
|
email: dto.email?.trim().toLowerCase() || null,
|
||||||
dni: dto.dni ?? null,
|
dni: dto.dni ?? null,
|
||||||
phone: dto.phone ?? null,
|
phone: dto.phone ?? null,
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
@@ -384,13 +268,15 @@ export class UsersService {
|
|||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const user = await this.lockUser(manager, id);
|
const user = await this.lockUser(manager, id);
|
||||||
const before = await this.loadUserView(manager, id);
|
const before = await this.loadUserView(manager, id);
|
||||||
if (dto.email !== undefined && !dto.email) throw userEmailRequired();
|
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
|
||||||
|
throw inspectorEmailRequired();
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.username !== undefined) {
|
if (dto.username !== undefined) {
|
||||||
user.username = dto.username.trim().toLowerCase();
|
user.username = dto.username.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
if (dto.email !== undefined) {
|
if (dto.email !== undefined) {
|
||||||
user.email = dto.email!.trim().toLowerCase();
|
user.email = dto.email?.trim().toLowerCase() || null;
|
||||||
}
|
}
|
||||||
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
|
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
|
||||||
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
|
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
|
||||||
@@ -400,13 +286,6 @@ export class UsersService {
|
|||||||
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
|
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
|
||||||
user.updatedBy = principal.userId;
|
user.updatedBy = principal.userId;
|
||||||
await manager.getRepository(User).save(user);
|
await manager.getRepository(User).save(user);
|
||||||
if (dto.email !== undefined && user.email) {
|
|
||||||
await manager.query(`
|
|
||||||
UPDATE user_smtp_settings
|
|
||||||
SET from_email=$2,reply_to=$2,updated_by=$1,updated_at=CURRENT_TIMESTAMP
|
|
||||||
WHERE user_id=$1 AND mode='CUSTOM'
|
|
||||||
`, [id, user.email]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.loadUserView(manager, id);
|
const updated = await this.loadUserView(manager, id);
|
||||||
await this.audit.record(
|
await this.audit.record(
|
||||||
@@ -535,6 +414,7 @@ export class UsersService {
|
|||||||
await this.lockUser(manager, id);
|
await this.lockUser(manager, id);
|
||||||
const roles = await this.resolveRoles(manager, dto.roleIds);
|
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||||
const before = await this.loadUserView(manager, id);
|
const before = await this.loadUserView(manager, id);
|
||||||
|
this.assertInspectorHasEmail(roles, before.email);
|
||||||
const beforeIds = before.roles.map((role) => role.id).sort();
|
const beforeIds = before.roles.map((role) => role.id).sort();
|
||||||
const afterIds = roles.map((role) => role.id).sort();
|
const afterIds = roles.map((role) => role.id).sort();
|
||||||
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
||||||
@@ -573,6 +453,12 @@ export class UsersService {
|
|||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
|
||||||
|
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
|
||||||
|
throw inspectorEmailRequired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async insertUserRoles(
|
private async insertUserRoles(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -625,7 +511,6 @@ export class UsersService {
|
|||||||
user_account.password_changed_at AS "passwordChangedAt",
|
user_account.password_changed_at AS "passwordChangedAt",
|
||||||
user_account.created_at AS "createdAt",
|
user_account.created_at AS "createdAt",
|
||||||
user_account.updated_at AS "updatedAt",
|
user_account.updated_at AS "updatedAt",
|
||||||
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
|
|
||||||
COALESCE(
|
COALESCE(
|
||||||
JSONB_AGG(
|
JSONB_AGG(
|
||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
|
|
||||||
type DossierRecord = Record<string, any>;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ActivityDossierService {
|
|
||||||
private readonly logger = new Logger(ActivityDossierService.name);
|
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
|
||||||
|
|
||||||
async dossier(id: string): Promise<Record<string, unknown>> {
|
|
||||||
const assetRows = (await this.dataSource.query(
|
|
||||||
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
|
|
||||||
FROM assets
|
|
||||||
WHERE id = $1`,
|
|
||||||
[id],
|
|
||||||
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
|
|
||||||
const asset = assetRows[0];
|
|
||||||
if (!asset) {
|
|
||||||
throw new NotFoundException({
|
|
||||||
code: 'ASSET_NOT_FOUND',
|
|
||||||
message: 'Activo no encontrado',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedFacets: string[] = [];
|
|
||||||
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
|
|
||||||
try {
|
|
||||||
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
|
|
||||||
} catch (error) {
|
|
||||||
failedFacets.push(facet);
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [
|
|
||||||
visits,
|
|
||||||
acts,
|
|
||||||
findings,
|
|
||||||
evidence,
|
|
||||||
communications,
|
|
||||||
verificationResults,
|
|
||||||
documents,
|
|
||||||
inspectionReports,
|
|
||||||
media,
|
|
||||||
versions,
|
|
||||||
] = await Promise.all([
|
|
||||||
safeQuery('visits', `
|
|
||||||
SELECT visit.id, visit.code, visit.status,
|
|
||||||
visit.planned_start_at AS "plannedStartAt",
|
|
||||||
visit.actual_started_at AS "actualStartedAt",
|
|
||||||
visit.actual_closed_at AS "actualClosedAt",
|
|
||||||
visit.created_at AS "createdAt"
|
|
||||||
FROM inspection_visits visit
|
|
||||||
WHERE visit.scope_asset_id = $1
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_visit_assets visit_asset
|
|
||||||
WHERE visit_asset.visit_id = visit.id
|
|
||||||
AND visit_asset.asset_id = $1
|
|
||||||
AND visit_asset.included = true
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_act_assets act_asset
|
|
||||||
ON act_asset.act_id = act.id AND act_asset.included = true
|
|
||||||
WHERE act.visit_id = visit.id
|
|
||||||
AND act_asset.asset_id = $1
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_findings finding ON finding.act_id = act.id
|
|
||||||
WHERE act.visit_id = visit.id
|
|
||||||
AND finding.asset_id = $1
|
|
||||||
)
|
|
||||||
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
|
|
||||||
LIMIT 200
|
|
||||||
`),
|
|
||||||
safeQuery('acts', `
|
|
||||||
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
|
|
||||||
act.occurred_at AS "occurredAt", act.title, act.summary,
|
|
||||||
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
|
|
||||||
visit.code AS "visitCode"
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_act_assets act_asset
|
|
||||||
WHERE act_asset.act_id = act.id
|
|
||||||
AND act_asset.asset_id = $1
|
|
||||||
AND act_asset.included = true
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_findings finding
|
|
||||||
WHERE finding.act_id = act.id
|
|
||||||
AND finding.asset_id = $1
|
|
||||||
)
|
|
||||||
ORDER BY act.occurred_at DESC
|
|
||||||
LIMIT 200
|
|
||||||
`),
|
|
||||||
safeQuery('findings', `
|
|
||||||
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
|
|
||||||
finding.title, finding.description,
|
|
||||||
finding.correction_due_on AS "correctionDueOn",
|
|
||||||
finding.company_response_received_on AS "companyResponseReceivedOn",
|
|
||||||
finding.next_control_on AS "nextControlOn",
|
|
||||||
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
|
|
||||||
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
|
|
||||||
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
|
|
||||||
visit.id AS "visitId", visit.code AS "visitCode"
|
|
||||||
FROM inspection_findings finding
|
|
||||||
JOIN inspection_acts act ON act.id = finding.act_id
|
|
||||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
||||||
WHERE finding.asset_id = $1
|
|
||||||
ORDER BY finding.created_at DESC
|
|
||||||
LIMIT 500
|
|
||||||
`),
|
|
||||||
safeQuery('evidence', `
|
|
||||||
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
|
|
||||||
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
|
|
||||||
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
|
|
||||||
evidence.created_at AS "createdAt", finding.code AS "findingCode",
|
|
||||||
finding.title AS "findingTitle"
|
|
||||||
FROM inspection_finding_evidence evidence
|
|
||||||
JOIN inspection_findings finding ON finding.id = evidence.finding_id
|
|
||||||
WHERE finding.asset_id = $1
|
|
||||||
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
|
|
||||||
LIMIT 500
|
|
||||||
`),
|
|
||||||
safeQuery('communications', `
|
|
||||||
SELECT communication.id, communication.finding_id AS "findingId",
|
|
||||||
communication.direction, communication.channel, communication.type,
|
|
||||||
communication.occurred_at AS "occurredAt", communication.subject,
|
|
||||||
communication.details, communication.contact_name AS "contactName",
|
|
||||||
communication.created_at AS "createdAt", finding.code AS "findingCode",
|
|
||||||
finding.title AS "findingTitle"
|
|
||||||
FROM inspection_finding_communications communication
|
|
||||||
JOIN inspection_findings finding ON finding.id = communication.finding_id
|
|
||||||
WHERE finding.asset_id = $1
|
|
||||||
ORDER BY communication.occurred_at DESC
|
|
||||||
LIMIT 500
|
|
||||||
`),
|
|
||||||
safeQuery('verificationResults', `
|
|
||||||
SELECT verification_link.id,
|
|
||||||
verification_link.finding_id AS "findingId",
|
|
||||||
verification_link.visit_id AS "visitId",
|
|
||||||
verification_link.target_control_on AS "targetControlOn",
|
|
||||||
verification_link.outcome,
|
|
||||||
verification_link.result_notes AS "resultNotes",
|
|
||||||
verification_link.verified_at AS "verifiedAt",
|
|
||||||
verification_link.result_recorded_at AS "resultRecordedAt",
|
|
||||||
verification_link.rescheduled_control_on AS "rescheduledControlOn",
|
|
||||||
finding.code AS "findingCode", finding.title AS "findingTitle",
|
|
||||||
visit.code AS "visitCode", visit.status AS "visitStatus",
|
|
||||||
(SELECT COUNT(*)::integer
|
|
||||||
FROM inspection_finding_evidence verification_evidence
|
|
||||||
WHERE verification_evidence.finding_id = finding.id
|
|
||||||
AND verification_evidence.verification_visit_id = visit.id
|
|
||||||
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
|
|
||||||
FROM inspection_finding_verification_visits verification_link
|
|
||||||
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
|
|
||||||
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
|
|
||||||
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
|
|
||||||
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
|
|
||||||
LIMIT 500
|
|
||||||
`),
|
|
||||||
safeQuery('documents', `
|
|
||||||
SELECT document.id, document.document_type AS "documentType",
|
|
||||||
document.document_number AS "documentNumber", document.title,
|
|
||||||
document.issuer, document.document_date AS "documentDate",
|
|
||||||
document.external_reference AS "externalReference",
|
|
||||||
link.relation_type AS "relationType", link.notes,
|
|
||||||
link.created_at AS "linkedAt"
|
|
||||||
FROM asset_source_documents link
|
|
||||||
JOIN source_documents document ON document.id = link.document_id
|
|
||||||
WHERE link.asset_id = $1
|
|
||||||
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
|
|
||||||
LIMIT 300
|
|
||||||
`),
|
|
||||||
safeQuery('inspectionReports', `
|
|
||||||
SELECT report.id, report.code, report.status,
|
|
||||||
report.pdf_status AS "pdfStatus", report.title,
|
|
||||||
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
|
|
||||||
act.id AS "actId", act.code AS "actCode",
|
|
||||||
visit.id AS "visitId", visit.code AS "visitCode"
|
|
||||||
FROM inspection_reports report
|
|
||||||
JOIN inspection_acts act ON act.id = report.act_id
|
|
||||||
JOIN inspection_visits visit ON visit.id = report.visit_id
|
|
||||||
WHERE visit.scope_asset_id = $1
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_act_assets act_asset
|
|
||||||
WHERE act_asset.act_id = act.id
|
|
||||||
AND act_asset.asset_id = $1
|
|
||||||
AND act_asset.included = true
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_findings finding
|
|
||||||
WHERE finding.act_id = act.id
|
|
||||||
AND finding.asset_id = $1
|
|
||||||
)
|
|
||||||
ORDER BY report.generated_at DESC
|
|
||||||
LIMIT 200
|
|
||||||
`),
|
|
||||||
safeQuery('media', `
|
|
||||||
SELECT media.id, media.kind, media.original_name AS "originalName",
|
|
||||||
media.title, media.description, media.captured_at AS "capturedAt",
|
|
||||||
media.created_at AS "createdAt", media.source
|
|
||||||
FROM asset_media media
|
|
||||||
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
|
|
||||||
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
|
|
||||||
LIMIT 500
|
|
||||||
`),
|
|
||||||
safeQuery('versions', `
|
|
||||||
SELECT version.id, version.version_number AS "versionNumber",
|
|
||||||
version.change_type AS "changeType", version.changed_fields AS "changedFields",
|
|
||||||
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
|
|
||||||
version.source
|
|
||||||
FROM asset_versions version
|
|
||||||
WHERE version.asset_id = $1
|
|
||||||
ORDER BY version.occurred_at DESC
|
|
||||||
LIMIT 500
|
|
||||||
`),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const timeline: DossierRecord[] = [];
|
|
||||||
const push = (event: DossierRecord) => timeline.push(event);
|
|
||||||
|
|
||||||
versions.forEach((version) => push({
|
|
||||||
id: `version:${String(version.id)}`,
|
|
||||||
kind: 'INVENTORY_CHANGE',
|
|
||||||
occurredAt: version.occurredAt,
|
|
||||||
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
|
|
||||||
? 'Registro incorporado al inventario'
|
|
||||||
: 'Inventario actualizado',
|
|
||||||
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
|
|
||||||
? `Campos: ${(version.changedFields as string[]).join(', ')}`
|
|
||||||
: null,
|
|
||||||
meta: {
|
|
||||||
versionNumber: version.versionNumber,
|
|
||||||
changeType: version.changeType,
|
|
||||||
actorUsername: version.actorUsername,
|
|
||||||
source: version.source,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
visits.forEach((visit) => push({
|
|
||||||
id: `visit:${String(visit.id)}`,
|
|
||||||
kind: 'INSPECTION',
|
|
||||||
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
|
|
||||||
title: `Inspección ${String(visit.code)}`,
|
|
||||||
description: null,
|
|
||||||
href: `/inspecciones/${String(visit.id)}`,
|
|
||||||
meta: { status: visit.status },
|
|
||||||
}));
|
|
||||||
acts.forEach((act) => push({
|
|
||||||
id: `act:${String(act.id)}`,
|
|
||||||
kind: 'ACT',
|
|
||||||
occurredAt: act.occurredAt,
|
|
||||||
title: `Acta ${String(act.code)}`,
|
|
||||||
description: act.title,
|
|
||||||
href: `/inspecciones/actas/${String(act.id)}`,
|
|
||||||
meta: { status: act.status, visitCode: act.visitCode },
|
|
||||||
}));
|
|
||||||
findings.forEach((finding) => {
|
|
||||||
push({
|
|
||||||
id: `finding:${String(finding.id)}`,
|
|
||||||
kind: 'FINDING',
|
|
||||||
occurredAt: finding.createdAt,
|
|
||||||
title: `Hallazgo ${String(finding.code)}`,
|
|
||||||
description: finding.title,
|
|
||||||
href: `/hallazgos/${String(finding.id)}`,
|
|
||||||
meta: { status: finding.status, actCode: finding.actCode },
|
|
||||||
});
|
|
||||||
if (finding.closedAt) {
|
|
||||||
push({
|
|
||||||
id: `finding-close:${String(finding.id)}`,
|
|
||||||
kind: 'FINDING_CLOSED',
|
|
||||||
occurredAt: finding.closedAt,
|
|
||||||
title: `Hallazgo ${String(finding.code)} cerrado`,
|
|
||||||
description: finding.closureNotes,
|
|
||||||
href: `/hallazgos/${String(finding.id)}`,
|
|
||||||
meta: { status: finding.status },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
verificationResults.forEach((verification) => push({
|
|
||||||
id: `verification:${String(verification.id)}`,
|
|
||||||
kind: 'VERIFICATION',
|
|
||||||
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
|
|
||||||
title: verification.outcome === 'RESOLVED'
|
|
||||||
? `Verificación conforme · ${String(verification.findingCode)}`
|
|
||||||
: verification.outcome === 'NOT_RESOLVED'
|
|
||||||
? `Verificación no conforme · ${String(verification.findingCode)}`
|
|
||||||
: `Verificación reprogramada · ${String(verification.findingCode)}`,
|
|
||||||
description: verification.resultNotes,
|
|
||||||
href: `/hallazgos/${String(verification.findingId)}`,
|
|
||||||
meta: {
|
|
||||||
outcome: verification.outcome,
|
|
||||||
visitCode: verification.visitCode,
|
|
||||||
targetControlOn: verification.targetControlOn,
|
|
||||||
rescheduledControlOn: verification.rescheduledControlOn,
|
|
||||||
evidenceCount: verification.evidenceCount,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
communications.forEach((communication) => push({
|
|
||||||
id: `communication:${String(communication.id)}`,
|
|
||||||
kind: 'COMMUNICATION',
|
|
||||||
occurredAt: communication.occurredAt,
|
|
||||||
title: communication.type === 'COMPANY_RESPONSE'
|
|
||||||
? 'Respuesta de la empresa'
|
|
||||||
: String(communication.subject),
|
|
||||||
description: communication.details,
|
|
||||||
href: `/hallazgos/${String(communication.findingId)}`,
|
|
||||||
meta: {
|
|
||||||
findingCode: communication.findingCode,
|
|
||||||
direction: communication.direction,
|
|
||||||
channel: communication.channel,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
evidence.forEach((item) => push({
|
|
||||||
id: `evidence:${String(item.id)}`,
|
|
||||||
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
|
|
||||||
occurredAt: item.capturedAt ?? item.createdAt,
|
|
||||||
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
|
|
||||||
description: item.title ?? item.originalName,
|
|
||||||
href: `/hallazgos/${String(item.findingId)}`,
|
|
||||||
meta: { findingCode: item.findingCode, purpose: item.purpose },
|
|
||||||
}));
|
|
||||||
inspectionReports.forEach((report) => push({
|
|
||||||
id: `inspection-report:${String(report.id)}`,
|
|
||||||
kind: 'REPORT',
|
|
||||||
occurredAt: report.generatedAt,
|
|
||||||
title: `Informe ${String(report.code)}`,
|
|
||||||
description: report.title,
|
|
||||||
href: `/informes/${String(report.id)}`,
|
|
||||||
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
|
|
||||||
}));
|
|
||||||
documents.forEach((document) => push({
|
|
||||||
id: `source-document:${String(document.id)}`,
|
|
||||||
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
|
|
||||||
occurredAt: document.documentDate ?? document.linkedAt,
|
|
||||||
title: String(document.title),
|
|
||||||
description: document.documentNumber
|
|
||||||
? `Documento ${String(document.documentNumber)}`
|
|
||||||
: 'Documento vinculado al inventario',
|
|
||||||
meta: { documentType: document.documentType, relationType: document.relationType },
|
|
||||||
}));
|
|
||||||
media.forEach((item) => push({
|
|
||||||
id: `asset-media:${String(item.id)}`,
|
|
||||||
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
|
|
||||||
occurredAt: item.capturedAt ?? item.createdAt,
|
|
||||||
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
|
|
||||||
description: item.title ?? item.originalName,
|
|
||||||
meta: { source: item.source },
|
|
||||||
}));
|
|
||||||
|
|
||||||
timeline.sort(
|
|
||||||
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
|
|
||||||
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
|
|
||||||
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
|
|
||||||
|
|
||||||
return {
|
|
||||||
asset: {
|
|
||||||
id: asset.id,
|
|
||||||
code: asset.code,
|
|
||||||
name: asset.name,
|
|
||||||
commonName: asset.commonName,
|
|
||||||
},
|
|
||||||
counters: {
|
|
||||||
inspections: visits.length,
|
|
||||||
acts: acts.length,
|
|
||||||
findings: findings.length,
|
|
||||||
verifications: verificationResults.length,
|
|
||||||
openFindings,
|
|
||||||
closedFindings,
|
|
||||||
evidence: evidence.length,
|
|
||||||
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
|
|
||||||
photos: evidence.filter((item) => item.kind === 'PHOTO').length
|
|
||||||
+ media.filter((item) => item.kind === 'PHOTO').length,
|
|
||||||
reports: reports.length + inspectionReports.length,
|
|
||||||
},
|
|
||||||
visits,
|
|
||||||
acts,
|
|
||||||
findings,
|
|
||||||
evidence,
|
|
||||||
communications,
|
|
||||||
verificationResults,
|
|
||||||
documents,
|
|
||||||
inspectionReports,
|
|
||||||
reports,
|
|
||||||
media,
|
|
||||||
versions,
|
|
||||||
timeline: timeline.slice(0, 500),
|
|
||||||
warnings: failedFacets,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -260,7 +260,7 @@ export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
|
|||||||
{
|
{
|
||||||
code: 'equipo',
|
code: 'equipo',
|
||||||
name: 'Equipo',
|
name: 'Equipo',
|
||||||
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.',
|
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
|
||||||
canBeRoot: false,
|
canBeRoot: false,
|
||||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
|
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { AssetRegistryService } from './asset-registry.service';
|
|||||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||||
import { InventoryStructureController } from './inventory-structure.controller';
|
import { InventoryStructureController } from './inventory-structure.controller';
|
||||||
import { InventoryStructureService } from './inventory-structure.service';
|
import { InventoryStructureService } from './inventory-structure.service';
|
||||||
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
|
|
||||||
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
||||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||||
@@ -32,7 +31,6 @@ import { InventoryTechnicalValuesService } from './inventory-technical-values.se
|
|||||||
import { InventoryFunctionService } from './inventory-function.service';
|
import { InventoryFunctionService } from './inventory-function.service';
|
||||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||||
import { InventoryMergeService } from './inventory-merge.service';
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
import { ActivityDossierService } from './activity-dossier.service';
|
|
||||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||||
import { InventoryBrowserController } from './inventory-browser.controller';
|
import { InventoryBrowserController } from './inventory-browser.controller';
|
||||||
import { InventoryBrowserService } from './inventory-browser.service';
|
import { InventoryBrowserService } from './inventory-browser.service';
|
||||||
@@ -61,13 +59,11 @@ import { InventoryBrowserService } from './inventory-browser.service';
|
|||||||
AssetTypesService,
|
AssetTypesService,
|
||||||
AssetsService,
|
AssetsService,
|
||||||
InventoryStructureService,
|
InventoryStructureService,
|
||||||
SimpleInventoryStructureService,
|
|
||||||
InventoryFamilyCatalogService,
|
InventoryFamilyCatalogService,
|
||||||
InventoryTechnicalValuesService,
|
InventoryTechnicalValuesService,
|
||||||
InventoryFunctionService,
|
InventoryFunctionService,
|
||||||
InventoryBrowserService,
|
InventoryBrowserService,
|
||||||
InventoryMergeService,
|
InventoryMergeService,
|
||||||
ActivityDossierService,
|
|
||||||
MergedInventoryDossierService,
|
MergedInventoryDossierService,
|
||||||
AssetGeometriesService,
|
AssetGeometriesService,
|
||||||
AssetHistoryService,
|
AssetHistoryService,
|
||||||
|
|||||||
@@ -142,8 +142,27 @@ export class AssetOperationalRelationsService {
|
|||||||
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
|
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||||
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
|
const [currentOperator] = (await manager.query(`
|
||||||
|
SELECT relation.id,company.name AS "companyName"
|
||||||
|
FROM area_company_relations relation
|
||||||
|
JOIN assets company ON company.id=relation.company_id
|
||||||
|
WHERE relation.area_id=$1
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
AND relation.company_id<>$2
|
||||||
|
ORDER BY relation.valid_from DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE OF relation
|
||||||
|
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
|
||||||
|
if (currentOperator) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
|
||||||
|
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = (await manager.query(`
|
const [row] = (await manager.query(`
|
||||||
INSERT INTO area_company_relations (
|
INSERT INTO area_company_relations (
|
||||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||||
@@ -151,6 +170,9 @@ export class AssetOperationalRelationsService {
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||||
|
|
||||||
|
// F5/F6: changing the Area operator is a temporal relation event only.
|
||||||
|
// Existing Inventory keeps its creation/historical operator snapshot and
|
||||||
|
// physical hierarchy unchanged. Runtime ownership must resolve this row.
|
||||||
const created = await this.loadRelation(manager, row.id);
|
const created = await this.loadRelation(manager, row.id);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
...administrationAuditContext(principal, request),
|
...administrationAuditContext(principal, request),
|
||||||
@@ -159,7 +181,7 @@ export class AssetOperationalRelationsService {
|
|||||||
entityId: row.id,
|
entityId: row.id,
|
||||||
afterData: this.auditView(created),
|
afterData: this.auditView(created),
|
||||||
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||||
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
|
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
|
||||||
: undefined,
|
: undefined,
|
||||||
}, manager);
|
}, manager);
|
||||||
return created;
|
return created;
|
||||||
@@ -190,6 +212,7 @@ export class AssetOperationalRelationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||||
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
|
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
UPDATE area_company_relations
|
UPDATE area_company_relations
|
||||||
@@ -209,7 +232,6 @@ export class AssetOperationalRelationsService {
|
|||||||
afterData: this.auditView(updated),
|
afterData: this.auditView(updated),
|
||||||
metadata: {
|
metadata: {
|
||||||
inventoryHierarchyChanged:false,
|
inventoryHierarchyChanged:false,
|
||||||
operatorSnapshotPreserved:true,
|
|
||||||
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||||
},
|
},
|
||||||
}, manager);
|
}, manager);
|
||||||
|
|||||||
@@ -756,12 +756,10 @@ export class AssetsService {
|
|||||||
const definitions = await this.loadDefinitions(manager, type.id);
|
const definitions = await this.loadDefinitions(manager, type.id);
|
||||||
const values = validateAssetAttributeValues(definitions, dto.attributes);
|
const values = validateAssetAttributeValues(definitions, dto.attributes);
|
||||||
const asset = manager.getRepository(Asset).create({
|
const asset = manager.getRepository(Asset).create({
|
||||||
id: dto.clientGeneratedId,
|
|
||||||
assetTypeId: type.id,
|
assetTypeId: type.id,
|
||||||
parentId: dto.parentId,
|
parentId: dto.parentId,
|
||||||
operationalAreaId: dto.operationalAreaId,
|
operationalAreaId: dto.operationalAreaId,
|
||||||
operatorCompanyId: dto.operatorCompanyId,
|
operatorCompanyId: dto.operatorCompanyId,
|
||||||
inventoryFamilyId: dto.inventoryFamilyId ?? null,
|
|
||||||
code: dto.code,
|
code: dto.code,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
commonName: dto.commonName ?? null,
|
commonName: dto.commonName ?? null,
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ import { Transform } from 'class-transformer';
|
|||||||
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class CreateFieldDiscoveryDto {
|
export class CreateFieldDiscoveryDto {
|
||||||
@IsOptional()
|
|
||||||
@IsUUID('4')
|
|
||||||
clientGeneratedId?: string;
|
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
visitId!: string;
|
visitId!: string;
|
||||||
|
|
||||||
@@ -40,10 +36,6 @@ export class CreateFieldDiscoveryDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
operatorCompanyId!: string;
|
operatorCompanyId!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID('4')
|
|
||||||
inventoryFamilyId?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export class InventoryFamilyCatalogService {
|
|||||||
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
|
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
|
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
|
||||||
if (!inserted) throw new Error('No se pudo crear el tipo de Inventario');
|
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
|
||||||
await this.replaceParents(manager, inserted.id, parentIds);
|
await this.replaceParents(manager, inserted.id, parentIds);
|
||||||
const created = await this.family(inserted.id,false,manager);
|
const created = await this.family(inserted.id,false,manager);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
@@ -186,7 +186,7 @@ export class InventoryFamilyCatalogService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isUniqueViolation(error)) throw new ConflictException({
|
if (this.isUniqueViolation(error)) throw new ConflictException({
|
||||||
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
|
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
|
||||||
message:'Ya existe un campo técnico con ese código en el tipo',
|
message:'Ya existe un campo técnico con ese código en la clasificación',
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -328,7 +328,7 @@ export class InventoryFamilyCatalogService {
|
|||||||
${lockClause}
|
${lockClause}
|
||||||
`),[familyId])) as FamilyRow[];
|
`),[familyId])) as FamilyRow[];
|
||||||
if (!rows[0]) throw new NotFoundException({
|
if (!rows[0]) throw new NotFoundException({
|
||||||
code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo de Inventario no existe',
|
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
|
||||||
});
|
});
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
@@ -343,7 +343,7 @@ export class InventoryFamilyCatalogService {
|
|||||||
if (level==='INSTALLATION') {
|
if (level==='INSTALLATION') {
|
||||||
if (ids.length) throw new BadRequestException({
|
if (ids.length) throw new BadRequestException({
|
||||||
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
|
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
|
||||||
message:'Un tipo de Instalación no lleva compatibilidades padre',
|
message:'Una clasificación de Instalación no lleva compatibilidades padre',
|
||||||
});
|
});
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -352,7 +352,7 @@ export class InventoryFamilyCatalogService {
|
|||||||
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
|
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
|
||||||
});
|
});
|
||||||
if (ownId && ids.includes(ownId)) throw new BadRequestException({
|
if (ownId && ids.includes(ownId)) throw new BadRequestException({
|
||||||
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo',
|
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
|
||||||
});
|
});
|
||||||
const [count]=(await manager.query(`
|
const [count]=(await manager.query(`
|
||||||
SELECT COUNT(*)::integer AS total FROM inventory_families
|
SELECT COUNT(*)::integer AS total FROM inventory_families
|
||||||
@@ -434,7 +434,7 @@ export class InventoryFamilyCatalogService {
|
|||||||
if (!unique.has(identity)) unique.set(identity,clean);
|
if (!unique.has(identity)) unique.set(identity,clean);
|
||||||
}
|
}
|
||||||
if (unique.size>100) throw new ConflictException({
|
if (unique.size>100) throw new ConflictException({
|
||||||
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'El tipo admite hasta 100 campos de información',
|
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
|
||||||
});
|
});
|
||||||
return [...unique.values()];
|
return [...unique.values()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
|
|||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
|
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
|
||||||
import { InventoryStructureService } from './inventory-structure.service';
|
import { InventoryStructureService } from './inventory-structure.service';
|
||||||
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
|
|
||||||
|
|
||||||
@Controller('inventory-structure')
|
@Controller('inventory-structure')
|
||||||
export class InventoryStructureController {
|
export class InventoryStructureController {
|
||||||
constructor(
|
constructor(private readonly inventoryStructure: InventoryStructureService) {}
|
||||||
private readonly inventoryStructure: InventoryStructureService,
|
|
||||||
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('assets.read')
|
@RequirePermissions('assets.read')
|
||||||
@@ -28,17 +24,6 @@ export class InventoryStructureController {
|
|||||||
return this.inventoryStructure.parents(kind, search);
|
return this.inventoryStructure.parents(kind, search);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('simple')
|
|
||||||
@RequirePermissions('assets.create')
|
|
||||||
createSimple(
|
|
||||||
@Body() dto: CreateInventoryStructureDto,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
return this.simpleInventoryStructure.create(dto, principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermissions('assets.create')
|
@RequirePermissions('assets.create')
|
||||||
create(
|
create(
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export class InventoryStructureService {
|
|||||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
|
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
|
||||||
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||||
family
|
family
|
||||||
? `Tipo: ${family.code} · ${family.name}`
|
? `Clasificación técnica: ${family.code} · ${family.name}`
|
||||||
: yacimientoContext
|
: yacimientoContext
|
||||||
? `Tipo de concesión: ${yacimientoContext.concessionName}`
|
? `Tipo de concesión: ${yacimientoContext.concessionName}`
|
||||||
: null,
|
: null,
|
||||||
@@ -458,13 +458,13 @@ export class InventoryStructureService {
|
|||||||
if (!expectedLevel) {
|
if (!expectedLevel) {
|
||||||
if (familyId) throw new BadRequestException({
|
if (familyId) throw new BadRequestException({
|
||||||
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||||
message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación',
|
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!familyId) throw new BadRequestException({
|
if (!familyId) throw new BadRequestException({
|
||||||
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
||||||
message: `Elegí el tipo de la ${kind.toLowerCase()}`,
|
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
|
||||||
});
|
});
|
||||||
const rows = (await manager.query(`
|
const rows = (await manager.query(`
|
||||||
SELECT family.id,family.code,family.name,family.level,
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
@@ -479,15 +479,15 @@ export class InventoryStructureService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, [familyId])) as FamilyRow[];
|
`, [familyId])) as FamilyRow[];
|
||||||
const family = rows[0];
|
const family = rows[0];
|
||||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación no existe' });
|
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
|
||||||
if (family.level !== expectedLevel) throw new BadRequestException({
|
if (family.level !== expectedLevel) throw new BadRequestException({
|
||||||
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||||
message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado',
|
message: 'La clasificación técnica no corresponde al nivel seleccionado',
|
||||||
});
|
});
|
||||||
if (kind === 'SUBINSTALACION') {
|
if (kind === 'SUBINSTALACION') {
|
||||||
if (!parent?.inventoryFamilyId) throw new BadRequestException({
|
if (!parent?.inventoryFamilyId) throw new BadRequestException({
|
||||||
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
|
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||||
message:'La Instalación padre debe tener un Tipo de instalación válido',
|
message:'La Instalación padre debe tener una clasificación técnica válida',
|
||||||
});
|
});
|
||||||
const [compatible]=(await manager.query(`
|
const [compatible]=(await manager.query(`
|
||||||
SELECT 1 AS ok FROM inventory_family_parent_rules
|
SELECT 1 AS ok FROM inventory_family_parent_rules
|
||||||
@@ -496,7 +496,7 @@ export class InventoryStructureService {
|
|||||||
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
|
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
|
||||||
if (!compatible) throw new BadRequestException({
|
if (!compatible) throw new BadRequestException({
|
||||||
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
||||||
message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada',
|
message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return family;
|
return family;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class InventoryTechnicalValuesService {
|
|||||||
for (const [definitionId,raw] of Object.entries(dto.values)) {
|
for (const [definitionId,raw] of Object.entries(dto.values)) {
|
||||||
const definition=definitionById.get(definitionId);
|
const definition=definitionById.get(definitionId);
|
||||||
if (!definition || !definition.isActive) throw new BadRequestException({
|
if (!definition || !definition.isActive) throw new BadRequestException({
|
||||||
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen al tipo actual',
|
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
|
||||||
});
|
});
|
||||||
const value=this.normalize(definition,raw);
|
const value=this.normalize(definition,raw);
|
||||||
if (value!==undefined) normalized[definitionId]=value;
|
if (value!==undefined) normalized[definitionId]=value;
|
||||||
@@ -67,7 +67,7 @@ export class InventoryTechnicalValuesService {
|
|||||||
const asset=rows[0];
|
const asset=rows[0];
|
||||||
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
|
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
|
||||||
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
|
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
|
||||||
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene Tipo de instalación/subinstalación y no admite campos técnicos por rubro',
|
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
|
||||||
});
|
});
|
||||||
const definitions=(await manager.query(`
|
const definitions=(await manager.query(`
|
||||||
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
|
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ActivityDossierService } from './activity-dossier.service';
|
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
import { InventoryFunctionService } from './inventory-function.service';
|
import { InventoryFunctionService } from './inventory-function.service';
|
||||||
import { InventoryMergeService } from './inventory-merge.service';
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
|
|
||||||
type LooseRecord = Record<string, any>;
|
type LooseRecord = Record<string, any>;
|
||||||
|
|
||||||
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
|
|
||||||
|
|
||||||
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
|
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const result: T[] = [];
|
const result: T[] = [];
|
||||||
@@ -32,22 +29,11 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
|
|||||||
export class MergedInventoryDossierService {
|
export class MergedInventoryDossierService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly assets: AssetsService,
|
private readonly assets: AssetsService,
|
||||||
private readonly activity: ActivityDossierService,
|
|
||||||
private readonly merges: InventoryMergeService,
|
private readonly merges: InventoryMergeService,
|
||||||
private readonly functions: InventoryFunctionService,
|
private readonly functions: InventoryFunctionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
|
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
|
||||||
const requestedAsset = await this.assets.getById(requestedAssetId);
|
|
||||||
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
|
|
||||||
|
|
||||||
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
|
|
||||||
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
|
|
||||||
// abrir su Actividad sin depender del subsistema de merge.
|
|
||||||
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
|
|
||||||
return await this.activity.dossier(requestedAssetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
|
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
|
||||||
const canonical = mergeStatus.canonical as LooseRecord;
|
const canonical = mergeStatus.canonical as LooseRecord;
|
||||||
const requested = mergeStatus.requested as LooseRecord;
|
const requested = mergeStatus.requested as LooseRecord;
|
||||||
@@ -58,7 +44,7 @@ export class MergedInventoryDossierService {
|
|||||||
.filter((value, index, all) => all.indexOf(value) === index);
|
.filter((value, index, all) => all.indexOf(value) === index);
|
||||||
|
|
||||||
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
|
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
|
||||||
const dossier = await this.activity.dossier(assetId) as LooseRecord;
|
const dossier = await this.assets.dossier(assetId) as LooseRecord;
|
||||||
const identity = dossier.asset as LooseRecord;
|
const identity = dossier.asset as LooseRecord;
|
||||||
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
|
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
|
||||||
return { assetId, identity, dossier, functionDossier };
|
return { assetId, identity, dossier, functionDossier };
|
||||||
@@ -159,9 +145,6 @@ export class MergedInventoryDossierService {
|
|||||||
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
|
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
|
||||||
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
|
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
|
||||||
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
|
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
|
||||||
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
|
|
||||||
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
asset: {
|
asset: {
|
||||||
@@ -211,7 +194,6 @@ export class MergedInventoryDossierService {
|
|||||||
media,
|
media,
|
||||||
versions,
|
versions,
|
||||||
timeline: timeline.slice(0, 1000),
|
timeline: timeline.slice(0, 1000),
|
||||||
warnings,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
|
||||||
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
||||||
import {
|
|
||||||
AssetDataOrigin,
|
|
||||||
AssetInformationStatus,
|
|
||||||
AssetOperationalStatus,
|
|
||||||
AssetVersionChangeType,
|
|
||||||
AuditAction,
|
|
||||||
} from '../database/entities';
|
|
||||||
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
|
|
||||||
import { AssetHistoryService } from './asset-history.service';
|
|
||||||
|
|
||||||
type StructureTypeRow = { id: string; code: string; name: string };
|
|
||||||
type ParentRow = {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
typeCode: string;
|
|
||||||
inventoryFamilyId: string | null;
|
|
||||||
operationalAreaId: string | null;
|
|
||||||
operatorCompanyId: string | null;
|
|
||||||
};
|
|
||||||
type FamilyRow = {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
|
|
||||||
DEPARTAMENTO: 'departamento',
|
|
||||||
AREA: 'area',
|
|
||||||
YACIMIENTO: 'yacimiento',
|
|
||||||
INSTALACION: 'instalacion',
|
|
||||||
SUBINSTALACION: 'subinstalacion',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
|
||||||
EMPRESA: null,
|
|
||||||
DEPARTAMENTO: null,
|
|
||||||
AREA: 'departamento',
|
|
||||||
YACIMIENTO: 'area',
|
|
||||||
INSTALACION: 'yacimiento',
|
|
||||||
SUBINSTALACION: 'instalacion',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimpleInventoryStructureService {
|
|
||||||
constructor(
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly history: AssetHistoryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async create(
|
|
||||||
dto: CreateInventoryStructureDto,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
|
||||||
const type = await this.requireStructureType(manager, dto.kind);
|
|
||||||
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
|
||||||
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
|
||||||
|
|
||||||
let operationalAreaId: string | null = null;
|
|
||||||
let operatorCompanyId: string | null = null;
|
|
||||||
|
|
||||||
if (dto.kind === 'YACIMIENTO') {
|
|
||||||
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
|
|
||||||
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
|
|
||||||
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
|
|
||||||
operationalAreaId = parent.id;
|
|
||||||
operatorCompanyId = dto.operatorCompanyId;
|
|
||||||
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
|
|
||||||
operationalAreaId = parent?.operationalAreaId ?? null;
|
|
||||||
operatorCompanyId = parent?.operatorCompanyId ?? null;
|
|
||||||
if (!operationalAreaId || !operatorCompanyId) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
|
|
||||||
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
|
|
||||||
message: 'Empresa operadora sólo corresponde al Yacimiento',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
|
||||||
const rows = (await manager.query(`
|
|
||||||
INSERT INTO assets (
|
|
||||||
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
|
|
||||||
code,name,common_name,description,information_status,operational_status,
|
|
||||||
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
|
||||||
) VALUES (
|
|
||||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,NULL,$5::uuid,
|
|
||||||
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
|
|
||||||
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
|
|
||||||
) RETURNING id
|
|
||||||
`, [
|
|
||||||
type.id,
|
|
||||||
parent?.id ?? null,
|
|
||||||
operationalAreaId,
|
|
||||||
operatorCompanyId,
|
|
||||||
family?.id ?? null,
|
|
||||||
generatedCode,
|
|
||||||
dto.name,
|
|
||||||
AssetInformationStatus.DRAFT,
|
|
||||||
AssetOperationalStatus.UNKNOWN,
|
|
||||||
AssetDataOrigin.MANUAL,
|
|
||||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
|
|
||||||
`inventory-simple:${dto.kind.toLowerCase()}`,
|
|
||||||
family ? `Tipo: ${family.name}` : null,
|
|
||||||
principal.userId,
|
|
||||||
])) as Array<{ id: string }>;
|
|
||||||
const id = rows[0]?.id;
|
|
||||||
if (!id) throw new Error('No se pudo crear el registro');
|
|
||||||
|
|
||||||
if (dto.kind === 'EMPRESA') {
|
|
||||||
await manager.query(`
|
|
||||||
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
|
|
||||||
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
|
|
||||||
ON CONFLICT (asset_id) DO UPDATE
|
|
||||||
SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
|
|
||||||
`, [id, dto.name, principal.userId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionNumber = await this.history.capture(
|
|
||||||
manager,
|
|
||||||
id,
|
|
||||||
AssetVersionChangeType.CREATED,
|
|
||||||
principal,
|
|
||||||
request,
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.query(`
|
|
||||||
INSERT INTO asset_context_history (
|
|
||||||
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
|
||||||
change_reason,asset_version_number,source,request_id,created_by
|
|
||||||
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
|
||||||
`, [
|
|
||||||
id,
|
|
||||||
parent?.id ?? null,
|
|
||||||
operationalAreaId,
|
|
||||||
operatorCompanyId,
|
|
||||||
dto.kind === 'YACIMIENTO'
|
|
||||||
? 'Alta de Yacimiento con Empresa operadora vinculada al Área'
|
|
||||||
: 'Alta simple de Inventario',
|
|
||||||
versionNumber,
|
|
||||||
request.requestId,
|
|
||||||
principal.userId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const created = await this.loadView(manager, id);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.ASSET_CREATED,
|
|
||||||
entityType: 'asset',
|
|
||||||
entityId: id,
|
|
||||||
afterData: created as Record<string, unknown>,
|
|
||||||
metadata: {
|
|
||||||
versionNumber,
|
|
||||||
inventoryStructureKind: dto.kind,
|
|
||||||
inventoryFamilyId: family?.id ?? null,
|
|
||||||
inventoryFamilyCode: family?.code ?? null,
|
|
||||||
operatorCompanyId,
|
|
||||||
simpleInventoryFlow: true,
|
|
||||||
},
|
|
||||||
}, manager);
|
|
||||||
return created;
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isUniqueViolation(error)) {
|
|
||||||
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un registro con ese código' });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
|
||||||
const sql = kind === 'EMPRESA'
|
|
||||||
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
|
|
||||||
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
|
|
||||||
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind, 'EMPRESA'>]];
|
|
||||||
const rows = (await manager.query(sql, params)) as StructureTypeRow[];
|
|
||||||
if (!rows[0]) throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', message: `El nivel ${kind} no está configurado` });
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireParent(
|
|
||||||
manager: EntityManager,
|
|
||||||
kind: InventoryStructureKind,
|
|
||||||
parentId: string | null,
|
|
||||||
): Promise<ParentRow | null> {
|
|
||||||
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
|
||||||
if (!expectedType) {
|
|
||||||
if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
|
|
||||||
|
|
||||||
const rows = (await manager.query(`
|
|
||||||
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
|
|
||||||
asset.inventory_family_id AS "inventoryFamilyId",
|
|
||||||
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId",
|
|
||||||
asset.operator_company_id AS "operatorCompanyId"
|
|
||||||
FROM assets asset
|
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
||||||
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
|
||||||
FOR KEY SHARE OF asset
|
|
||||||
`, [parentId])) as ParentRow[];
|
|
||||||
const parent = rows[0];
|
|
||||||
if (!parent || parent.typeCode !== expectedType) {
|
|
||||||
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
|
|
||||||
}
|
|
||||||
return parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
|
|
||||||
const rows = await manager.query(`
|
|
||||||
SELECT 1
|
|
||||||
FROM area_company_relations relation
|
|
||||||
JOIN assets company ON company.id=relation.company_id
|
|
||||||
JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
|
||||||
WHERE relation.area_id=$1::uuid
|
|
||||||
AND relation.company_id=$2::uuid
|
|
||||||
AND relation.relation_role='OPERATOR'
|
|
||||||
AND relation.valid_until IS NULL
|
|
||||||
AND company.information_status<>'INACTIVE'
|
|
||||||
AND company_type.operational_role='COMPANY'
|
|
||||||
AND company_type.is_active=true
|
|
||||||
LIMIT 1
|
|
||||||
FOR KEY SHARE OF relation
|
|
||||||
`, [areaId, companyId]);
|
|
||||||
if (!rows[0]) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
|
|
||||||
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireFamily(
|
|
||||||
manager: EntityManager,
|
|
||||||
kind: InventoryStructureKind,
|
|
||||||
familyId: string | null,
|
|
||||||
parent: ParentRow | null,
|
|
||||||
): Promise<FamilyRow | null> {
|
|
||||||
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
|
|
||||||
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
|
|
||||||
|
|
||||||
const rows = (await manager.query(`
|
|
||||||
SELECT id,code,name,level::text AS level
|
|
||||||
FROM inventory_families
|
|
||||||
WHERE id=$1::uuid AND is_active=true
|
|
||||||
LIMIT 1
|
|
||||||
FOR KEY SHARE
|
|
||||||
`, [familyId])) as FamilyRow[];
|
|
||||||
const family = rows[0];
|
|
||||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
|
|
||||||
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
|
|
||||||
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
|
|
||||||
|
|
||||||
if (kind === 'SUBINSTALACION') {
|
|
||||||
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
|
|
||||||
const compatible = await manager.query(`
|
|
||||||
SELECT 1 FROM inventory_family_parent_rules
|
|
||||||
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
|
|
||||||
LIMIT 1
|
|
||||||
`, [family.id, parent.inventoryFamilyId]);
|
|
||||||
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
|
|
||||||
}
|
|
||||||
return family;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
|
||||||
const prefix = kind === 'EMPRESA' ? 'EMP'
|
|
||||||
: kind === 'DEPARTAMENTO' ? 'DEP'
|
|
||||||
: kind === 'AREA' ? 'AREA'
|
|
||||||
: kind === 'YACIMIENTO' ? 'YAC'
|
|
||||||
: kind === 'INSTALACION' ? 'INST'
|
|
||||||
: 'SUB';
|
|
||||||
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
|
||||||
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
|
|
||||||
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadView(manager: EntityManager, id: string) {
|
|
||||||
const rows = await manager.query(`
|
|
||||||
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
|
|
||||||
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
|
||||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
|
||||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
|
||||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
|
||||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
|
|
||||||
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',family.id,'code',family.code,'name',family.name,'level',family.level) END AS "inventoryFamily",
|
|
||||||
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
|
|
||||||
FROM assets asset
|
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
||||||
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
|
||||||
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
|
||||||
LEFT JOIN assets company ON company.id=asset.operator_company_id
|
|
||||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
|
||||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
|
||||||
WHERE asset.id=$1::uuid
|
|
||||||
`, [id]);
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -118,9 +118,10 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
email = (await prompt.question('Email: ')).trim().toLowerCase();
|
email = (await prompt.question('Email (optional): ')).trim().toLowerCase();
|
||||||
if (
|
if (
|
||||||
!email || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
email &&
|
||||||
|
(email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
||||||
) {
|
) {
|
||||||
throw new Error('Email is invalid');
|
throw new Error('Email is invalid');
|
||||||
}
|
}
|
||||||
@@ -188,7 +189,7 @@ async function main(): Promise<void> {
|
|||||||
OR ($2::text IS NOT NULL AND LOWER(email) = $2)
|
OR ($2::text IS NOT NULL AND LOWER(email) = $2)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[username, email],
|
[username, email || null],
|
||||||
)) as unknown[];
|
)) as unknown[];
|
||||||
if (duplicate.length > 0) {
|
if (duplicate.length > 0) {
|
||||||
throw new Error('Username or email already exists');
|
throw new Error('Username or email already exists');
|
||||||
@@ -201,7 +202,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const user = manager.getRepository(User).create({
|
const user = manager.getRepository(User).create({
|
||||||
username,
|
username,
|
||||||
email,
|
email: email || null,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
|
|||||||
@@ -103,8 +103,6 @@ export enum AuditAction {
|
|||||||
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
|
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
|
||||||
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
|
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
|
||||||
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
|
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
|
||||||
USER_SMTP_SETTINGS_UPDATED = 'USER_SMTP_SETTINGS_UPDATED',
|
|
||||||
USER_SMTP_TEST_SENT = 'USER_SMTP_TEST_SENT',
|
|
||||||
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
|
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
|
||||||
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
|
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
|
||||||
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
|
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
|
||||||
|
|||||||
@@ -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, nullable: true })
|
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT })
|
||||||
urgency!: InspectionActUrgency | null;
|
urgency!: InspectionActUrgency;
|
||||||
|
|
||||||
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
|
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
|
||||||
deadlineDays!: number | null;
|
deadlineDays!: number | null;
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export class User extends TimestampedEntity {
|
|||||||
@Column({ type: 'varchar', length: 80 })
|
@Column({ type: 'varchar', length: 80 })
|
||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 320 })
|
@Column({ type: 'varchar', length: 320, nullable: true })
|
||||||
email!: string;
|
email!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 32, nullable: true })
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
dni!: string | null;
|
dni!: string | null;
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class F7SimpleInventoryContext1790110200000 implements MigrationInterface {
|
|
||||||
name = 'F7SimpleInventoryContext1790110200000';
|
|
||||||
|
|
||||||
public async up(q: QueryRunner): Promise<void> {
|
|
||||||
await q.query(`
|
|
||||||
UPDATE asset_types
|
|
||||||
SET description='Yacimiento. Pertenece a un Área, tiene una única Empresa operadora vigente y puede recibir Hallazgos.',
|
|
||||||
updated_at=CURRENT_TIMESTAMP
|
|
||||||
WHERE lower(code)='yacimiento'
|
|
||||||
`);
|
|
||||||
|
|
||||||
await q.query(`
|
|
||||||
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
|
|
||||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
||||||
DECLARE
|
|
||||||
kind text;
|
|
||||||
asset_role asset_type_operational_role;
|
|
||||||
parent_kind text;
|
|
||||||
parent_area uuid;
|
|
||||||
parent_company uuid;
|
|
||||||
parent_family uuid;
|
|
||||||
company_role asset_type_operational_role;
|
|
||||||
family_level text;
|
|
||||||
BEGIN
|
|
||||||
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
|
||||||
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
|
|
||||||
|
|
||||||
IF asset_role='COMPANY'::asset_type_operational_role THEN
|
|
||||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
|
|
||||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
|
||||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
RETURN NEW;
|
|
||||||
ELSIF kind='departamento' THEN
|
|
||||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
|
|
||||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
|
||||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
|
|
||||||
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
|
|
||||||
INTO parent_kind,parent_area,parent_company,parent_family
|
|
||||||
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
|
||||||
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
|
|
||||||
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
|
|
||||||
|
|
||||||
IF kind='area' THEN
|
|
||||||
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
|
|
||||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
|
||||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
ELSIF kind='yacimiento' THEN
|
|
||||||
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
|
|
||||||
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere una Empresa operadora'; END IF;
|
|
||||||
SELECT type.operational_role INTO company_role
|
|
||||||
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
|
|
||||||
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
|
|
||||||
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa operadora del Yacimiento no es válida';
|
|
||||||
END IF;
|
|
||||||
IF NOT EXISTS(
|
|
||||||
SELECT 1 FROM area_company_relations relation
|
|
||||||
WHERE relation.area_id=NEW.parent_id
|
|
||||||
AND relation.company_id=NEW.operator_company_id
|
|
||||||
AND relation.relation_role='OPERATOR'::area_organization_role
|
|
||||||
AND relation.valid_until IS NULL
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa del Yacimiento debe estar vinculada como explotadora del Área';
|
|
||||||
END IF;
|
|
||||||
IF NEW.concession_type_id IS NOT NULL
|
|
||||||
AND NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de concesión indicado no es válido';
|
|
||||||
END IF;
|
|
||||||
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre';
|
|
||||||
END IF;
|
|
||||||
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
ELSIF kind='instalacion' THEN
|
|
||||||
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
|
|
||||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
|
||||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
|
|
||||||
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Yacimiento debe tener una Empresa operadora'; END IF;
|
|
||||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
|
||||||
ELSIF kind='subinstalacion' THEN
|
|
||||||
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
|
|
||||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
|
||||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
|
|
||||||
IF NOT EXISTS(
|
|
||||||
SELECT 1 FROM inventory_family_parent_rules rule
|
|
||||||
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no está habilitado dentro del Tipo de instalación padre';
|
|
||||||
END IF;
|
|
||||||
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación debe pertenecer a un Yacimiento con Empresa operadora'; END IF;
|
|
||||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END $$
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(q: QueryRunner): Promise<void> {
|
|
||||||
await q.query(`
|
|
||||||
UPDATE asset_types
|
|
||||||
SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',
|
|
||||||
updated_at=CURRENT_TIMESTAMP
|
|
||||||
WHERE lower(code)='yacimiento'
|
|
||||||
`);
|
|
||||||
|
|
||||||
await q.query(`
|
|
||||||
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
|
|
||||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
||||||
DECLARE
|
|
||||||
kind text;
|
|
||||||
asset_role asset_type_operational_role;
|
|
||||||
parent_kind text;
|
|
||||||
parent_area uuid;
|
|
||||||
parent_company uuid;
|
|
||||||
parent_family uuid;
|
|
||||||
company_role asset_type_operational_role;
|
|
||||||
family_level text;
|
|
||||||
BEGIN
|
|
||||||
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
|
||||||
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
|
|
||||||
|
|
||||||
IF asset_role='COMPANY'::asset_type_operational_role THEN
|
|
||||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
|
|
||||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
|
||||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
RETURN NEW;
|
|
||||||
ELSIF kind='departamento' THEN
|
|
||||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
|
|
||||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
|
||||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
|
|
||||||
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
|
|
||||||
INTO parent_kind,parent_area,parent_company,parent_family
|
|
||||||
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
|
||||||
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
|
|
||||||
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
|
|
||||||
|
|
||||||
IF kind='area' THEN
|
|
||||||
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
|
|
||||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
|
||||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
ELSIF kind='yacimiento' THEN
|
|
||||||
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
|
|
||||||
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
|
|
||||||
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
|
|
||||||
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
|
|
||||||
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
|
|
||||||
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
|
|
||||||
END IF;
|
|
||||||
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
|
|
||||||
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
|
||||||
ELSIF kind='instalacion' THEN
|
|
||||||
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
|
|
||||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
|
||||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
|
|
||||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
|
||||||
ELSIF kind='subinstalacion' THEN
|
|
||||||
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
|
|
||||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
|
||||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
|
|
||||||
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
|
|
||||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
|
|
||||||
END IF;
|
|
||||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END $$
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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'`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
function quoteIdentifier(identifier: string): string {
|
|
||||||
return `"${identifier.replaceAll('"', '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class F62UserSmtpAndActRepresentative1790117400000 implements MigrationInterface {
|
|
||||||
name = 'F62UserSmtpAndActRepresentative1790117400000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM users WHERE email IS NULL OR btrim(email)='') THEN
|
|
||||||
RAISE EXCEPTION 'USER_EMAIL_REQUIRED_BEFORE_F62';
|
|
||||||
END IF;
|
|
||||||
END $$
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`ALTER TABLE users ALTER COLUMN email SET NOT NULL`);
|
|
||||||
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE user_smtp_settings (
|
|
||||||
user_id uuid PRIMARY KEY,
|
|
||||||
mode varchar(16) NOT NULL DEFAULT 'SYSTEM',
|
|
||||||
host varchar(255),
|
|
||||||
port integer,
|
|
||||||
security_mode varchar(24),
|
|
||||||
username varchar(255),
|
|
||||||
password_enc text,
|
|
||||||
from_name varchar(200),
|
|
||||||
from_email varchar(320),
|
|
||||||
reply_to varchar(320),
|
|
||||||
enabled boolean NOT NULL DEFAULT true,
|
|
||||||
updated_by uuid,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT chk_user_smtp_mode CHECK (mode IN ('SYSTEM','CUSTOM')),
|
|
||||||
CONSTRAINT chk_user_smtp_security CHECK (security_mode IS NULL OR security_mode IN ('NONE','STARTTLS','TLS')),
|
|
||||||
CONSTRAINT chk_user_smtp_port CHECK (port IS NULL OR (port > 0 AND port <= 65535)),
|
|
||||||
CONSTRAINT chk_user_smtp_custom_complete CHECK (
|
|
||||||
mode='SYSTEM' OR (
|
|
||||||
LENGTH(TRIM(COALESCE(host,''))) > 0
|
|
||||||
AND port IS NOT NULL
|
|
||||||
AND security_mode IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(COALESCE(from_email,''))) > 0
|
|
||||||
)
|
|
||||||
),
|
|
||||||
CONSTRAINT fk_user_smtp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_user_smtp_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_act_responsibles
|
|
||||||
DROP CONSTRAINT chk_inspection_act_responsibles_details
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_act_responsibles
|
|
||||||
ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK (
|
|
||||||
(
|
|
||||||
attendance_status = 'PRESENT'
|
|
||||||
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
|
|
||||||
AND document_type IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
|
|
||||||
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
|
|
||||||
AND LENGTH(TRIM(COALESCE(email, ''))) > 0
|
|
||||||
AND absence_reason IS NULL
|
|
||||||
) OR (
|
|
||||||
attendance_status = 'ABSENT'
|
|
||||||
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const appRole = process.env.DB_APP_USER;
|
|
||||||
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
|
|
||||||
const applicationRole = quoteIdentifier(appRole);
|
|
||||||
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE user_smtp_settings TO ${applicationRole}`);
|
|
||||||
await queryRunner.query(`REVOKE DELETE ON TABLE user_smtp_settings FROM ${applicationRole}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_act_responsibles
|
|
||||||
DROP CONSTRAINT chk_inspection_act_responsibles_details
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_act_responsibles
|
|
||||||
ADD CONSTRAINT chk_inspection_act_responsibles_details CHECK (
|
|
||||||
(
|
|
||||||
attendance_status = 'PRESENT'
|
|
||||||
AND LENGTH(TRIM(COALESCE(full_name, ''))) > 0
|
|
||||||
AND document_type IS NOT NULL
|
|
||||||
AND LENGTH(TRIM(COALESCE(document_number, ''))) > 0
|
|
||||||
AND LENGTH(TRIM(COALESCE(position, ''))) > 0
|
|
||||||
AND absence_reason IS NULL
|
|
||||||
) OR (
|
|
||||||
attendance_status = 'ABSENT'
|
|
||||||
AND LENGTH(TRIM(COALESCE(absence_reason, ''))) >= 10
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS user_smtp_settings`);
|
|
||||||
await queryRunner.query(`ALTER TABLE users ALTER COLUMN email DROP NOT NULL`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F4 cambió el código visible del Acta a ACT-NNNNN-DD-MM-YY, pero el CHECK
|
|
||||||
* histórico de Hallazgos seguía aceptando sólo ACTA-YYYY-NNNNNN-HNNN.
|
|
||||||
* El servicio genera el Hallazgo a partir del código real del Acta, por lo que
|
|
||||||
* PostgreSQL rechazaba altas válidas de campo con un 500.
|
|
||||||
*/
|
|
||||||
export class F65FieldFindingCodeContract1790121000000 implements MigrationInterface {
|
|
||||||
name = 'F65FieldFindingCodeContract1790121000000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_findings
|
|
||||||
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_findings
|
|
||||||
ADD CONSTRAINT chk_inspection_findings_code CHECK (
|
|
||||||
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})-H[0-9]{3}$'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
UPDATE inspection_findings finding
|
|
||||||
SET code = 'ACTA-' || act.act_year::text || '-' || LPAD(act.act_number::text, 6, '0')
|
|
||||||
|| '-H' || LPAD(finding.finding_number::text, 3, '0')
|
|
||||||
FROM inspection_acts act
|
|
||||||
WHERE finding.act_id = act.id
|
|
||||||
AND finding.code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}-H[0-9]{3}$'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_findings
|
|
||||||
DROP CONSTRAINT IF EXISTS chk_inspection_findings_code
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_findings
|
|
||||||
ADD CONSTRAINT chk_inspection_findings_code CHECK (
|
|
||||||
code ~ '^ACTA-[0-9]{4}-[0-9]{6}-H[0-9]{3}$'
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
function quoteIdentifier(identifier: string): string {
|
|
||||||
return `"${identifier.replaceAll('"', '""')}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class F67UserReusableSignature1790124600000 implements MigrationInterface {
|
|
||||||
name = 'F67UserReusableSignature1790124600000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE user_signature_profiles (
|
|
||||||
user_id uuid PRIMARY KEY,
|
|
||||||
original_name varchar(255) NOT NULL,
|
|
||||||
mime_type varchar(100) NOT NULL,
|
|
||||||
size_bytes integer NOT NULL,
|
|
||||||
image_sha256 char(64) NOT NULL,
|
|
||||||
image_data bytea NOT NULL,
|
|
||||||
updated_by uuid,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT chk_user_signature_profiles_png CHECK (mime_type='image/png'),
|
|
||||||
CONSTRAINT chk_user_signature_profiles_size CHECK (
|
|
||||||
size_bytes BETWEEN 1 AND 1048576
|
|
||||||
AND OCTET_LENGTH(image_data)=size_bytes
|
|
||||||
),
|
|
||||||
CONSTRAINT chk_user_signature_profiles_sha CHECK (
|
|
||||||
image_sha256 ~ '^[0-9a-f]{64}$'
|
|
||||||
),
|
|
||||||
CONSTRAINT fk_user_signature_profiles_user FOREIGN KEY (user_id)
|
|
||||||
REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_user_signature_profiles_updated_by FOREIGN KEY (updated_by)
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
const appRole = process.env.DB_APP_USER;
|
|
||||||
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
|
|
||||||
const applicationRole = quoteIdentifier(appRole);
|
|
||||||
await queryRunner.query(`
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE
|
|
||||||
ON TABLE user_signature_profiles
|
|
||||||
TO ${applicationRole}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query('DROP TABLE user_signature_profiles');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class F68OfflineEvidenceIdempotency1790128200000 implements MigrationInterface {
|
|
||||||
name = 'F68OfflineEvidenceIdempotency1790128200000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE inspection_finding_evidence
|
|
||||||
ADD COLUMN client_operation_id uuid
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE UNIQUE INDEX uq_inspection_finding_evidence_client_operation
|
|
||||||
ON inspection_finding_evidence (client_operation_id)
|
|
||||||
WHERE client_operation_id IS NOT NULL
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE asset_field_capture_events
|
|
||||||
ADD COLUMN client_operation_id uuid
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE UNIQUE INDEX uq_asset_field_capture_events_client_operation
|
|
||||||
ON asset_field_capture_events (client_operation_id)
|
|
||||||
WHERE client_operation_id IS NOT NULL
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query('DROP INDEX uq_asset_field_capture_events_client_operation');
|
|
||||||
await queryRunner.query('ALTER TABLE asset_field_capture_events DROP COLUMN client_operation_id');
|
|
||||||
await queryRunner.query('DROP INDEX uq_inspection_finding_evidence_client_operation');
|
|
||||||
await queryRunner.query('ALTER TABLE inspection_finding_evidence DROP COLUMN client_operation_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class F69ConsolidatedActDocument1790131800000 implements MigrationInterface {
|
|
||||||
name = 'F69ConsolidatedActDocument1790131800000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE inspection_act_consolidated_pdf_artifacts (
|
|
||||||
act_id uuid PRIMARY KEY REFERENCES inspection_acts(id) ON DELETE CASCADE,
|
|
||||||
stored_name varchar(255) NOT NULL,
|
|
||||||
original_name varchar(255) NOT NULL,
|
|
||||||
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
|
||||||
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
|
||||||
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE inspection_report_consolidated_word_artifacts (
|
|
||||||
report_id uuid PRIMARY KEY REFERENCES inspection_reports(id) ON DELETE CASCADE,
|
|
||||||
stored_name varchar(255) NOT NULL,
|
|
||||||
original_name varchar(255) NOT NULL,
|
|
||||||
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
|
||||||
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
|
||||||
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_artifacts');
|
|
||||||
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_artifacts');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class F69ConsolidatedDocumentRevisions1790135400000 implements MigrationInterface {
|
|
||||||
name = 'F69ConsolidatedDocumentRevisions1790135400000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE inspection_act_consolidated_pdf_revisions (
|
|
||||||
act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE CASCADE,
|
|
||||||
template_version smallint NOT NULL CHECK (template_version >= 1),
|
|
||||||
stored_name varchar(255) NOT NULL,
|
|
||||||
original_name varchar(255) NOT NULL,
|
|
||||||
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
|
||||||
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
|
||||||
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (act_id,template_version)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
INSERT INTO inspection_act_consolidated_pdf_revisions(
|
|
||||||
act_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
|
|
||||||
SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at
|
|
||||||
FROM inspection_act_consolidated_pdf_artifacts
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE inspection_report_consolidated_word_revisions (
|
|
||||||
report_id uuid NOT NULL REFERENCES inspection_reports(id) ON DELETE CASCADE,
|
|
||||||
template_version smallint NOT NULL CHECK (template_version >= 1),
|
|
||||||
stored_name varchar(255) NOT NULL,
|
|
||||||
original_name varchar(255) NOT NULL,
|
|
||||||
size_bytes integer NOT NULL CHECK (size_bytes > 0),
|
|
||||||
sha256 char(64) NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
|
|
||||||
generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (report_id,template_version)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
INSERT INTO inspection_report_consolidated_word_revisions(
|
|
||||||
report_id,template_version,stored_name,original_name,size_bytes,sha256,generated_at)
|
|
||||||
SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at
|
|
||||||
FROM inspection_report_consolidated_word_artifacts
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query('DROP TABLE inspection_report_consolidated_word_revisions');
|
|
||||||
await queryRunner.query('DROP TABLE inspection_act_consolidated_pdf_revisions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -10,15 +11,15 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { InspectionActUrgency } from '../../database/entities';
|
||||||
|
|
||||||
export class CreateInspectionActDto {
|
export class CreateInspectionActDto {
|
||||||
@IsOptional()
|
|
||||||
@IsUUID('4')
|
|
||||||
clientGeneratedId?: string;
|
|
||||||
|
|
||||||
@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,6 +4,7 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -11,12 +12,17 @@ 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()
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ export class InspectionActsController {
|
|||||||
return this.acts.listGlobal(query);
|
return this.acts.listGlobal(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/field-media')
|
|
||||||
@RequirePermissions('inspection_acts.read', 'assets.read_media')
|
|
||||||
fieldMedia(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
|
||||||
return this.acts.listFieldMedia(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermissions('inspection_acts.read')
|
@RequirePermissions('inspection_acts.read')
|
||||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export interface InspectionActListItem {
|
|||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
observations: string | null;
|
observations: string | null;
|
||||||
urgency: InspectionActUrgency | null;
|
urgency: InspectionActUrgency;
|
||||||
deadlineDays: number | null;
|
deadlineDays: number | null;
|
||||||
deadlineDayType: InspectionDeadlineDayType | null;
|
deadlineDayType: InspectionDeadlineDayType | null;
|
||||||
deadlineBasis: InspectionDeadlineBasis | null;
|
deadlineBasis: InspectionDeadlineBasis | null;
|
||||||
@@ -278,48 +278,6 @@ export class InspectionActsService {
|
|||||||
return this.dataSource.transaction((manager) => this.loadView(manager, id));
|
return this.dataSource.transaction((manager) => this.loadView(manager, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFieldMedia(id: string) {
|
|
||||||
const [act] = await this.dataSource.query(
|
|
||||||
'SELECT id FROM inspection_acts WHERE id=$1::uuid',
|
|
||||||
[id],
|
|
||||||
) as Array<{ id: string }>;
|
|
||||||
if (!act) throw actNotFound();
|
|
||||||
const data = await this.dataSource.query(`
|
|
||||||
SELECT
|
|
||||||
media.id,
|
|
||||||
media.asset_id AS "assetId",
|
|
||||||
asset.code AS "assetCode",
|
|
||||||
asset.name AS "assetName",
|
|
||||||
media.kind,
|
|
||||||
media.original_name AS "originalName",
|
|
||||||
media.mime_type AS "mimeType",
|
|
||||||
media.size_bytes::double precision AS "sizeBytes",
|
|
||||||
media.sha256,
|
|
||||||
media.title,
|
|
||||||
media.description,
|
|
||||||
media.captured_at AS "capturedAt",
|
|
||||||
media.latitude::double precision AS latitude,
|
|
||||||
media.longitude::double precision AS longitude,
|
|
||||||
media.accuracy_m::double precision AS "accuracyM",
|
|
||||||
media.source,
|
|
||||||
capture.device_captured_at AS "fieldCapturedAt",
|
|
||||||
capture.created_at AS "createdAt"
|
|
||||||
FROM inspection_acts act
|
|
||||||
INNER JOIN inspection_act_assets link
|
|
||||||
ON link.act_id=act.id AND link.included=true
|
|
||||||
INNER JOIN asset_field_capture_events capture
|
|
||||||
ON capture.visit_id=act.visit_id
|
|
||||||
AND capture.asset_id=link.asset_id
|
|
||||||
AND capture.event_type='PHOTO'
|
|
||||||
INNER JOIN asset_media media
|
|
||||||
ON media.id=capture.media_id AND media.deleted_at IS NULL
|
|
||||||
INNER JOIN assets asset ON asset.id=media.asset_id
|
|
||||||
WHERE act.id=$1::uuid
|
|
||||||
ORDER BY capture.device_captured_at DESC,capture.created_at DESC,media.id
|
|
||||||
`, [id]);
|
|
||||||
return { data };
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
visitId: string,
|
visitId: string,
|
||||||
dto: CreateInspectionActDto,
|
dto: CreateInspectionActDto,
|
||||||
@@ -331,22 +289,6 @@ export class InspectionActsService {
|
|||||||
const visit = await this.lockVisit(manager, visitId);
|
const visit = await this.lockVisit(manager, visitId);
|
||||||
this.assertVisitOpen(visit);
|
this.assertVisitOpen(visit);
|
||||||
await this.assertActorAssigned(manager, visitId, principal);
|
await this.assertActorAssigned(manager, visitId, principal);
|
||||||
if (dto.clientGeneratedId) {
|
|
||||||
const [existing] = await manager.query(`
|
|
||||||
SELECT id, visit_id AS "visitId"
|
|
||||||
FROM inspection_acts
|
|
||||||
WHERE id=$1
|
|
||||||
`, [dto.clientGeneratedId]) as Array<{ id: string; visitId: string }>;
|
|
||||||
if (existing) {
|
|
||||||
if (existing.visitId !== visitId) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_ACT_CLIENT_ID_REUSED',
|
|
||||||
message: 'El identificador offline del Acta ya fue utilizado en otra inspección',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.loadView(manager, existing.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.assertVisitHasNoDraftAct(manager, visitId);
|
await this.assertVisitHasNoDraftAct(manager, visitId);
|
||||||
await this.assertVisitAssets(manager, visitId, dto.assetIds);
|
await this.assertVisitAssets(manager, visitId, dto.assetIds);
|
||||||
const occurredAt = new Date(dto.occurredAt);
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
@@ -363,7 +305,6 @@ export class InspectionActsService {
|
|||||||
`, [occurredAt])) as Array<{ date_part: string }>;
|
`, [occurredAt])) as Array<{ date_part: string }>;
|
||||||
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
|
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
|
||||||
const act = manager.getRepository(InspectionAct).create({
|
const act = manager.getRepository(InspectionAct).create({
|
||||||
id: dto.clientGeneratedId,
|
|
||||||
visitId,
|
visitId,
|
||||||
actYear,
|
actYear,
|
||||||
actNumber,
|
actNumber,
|
||||||
@@ -373,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: null,
|
urgency: dto.urgency,
|
||||||
deadlineDays: null,
|
deadlineDays: null,
|
||||||
deadlineDayType: null,
|
deadlineDayType: null,
|
||||||
deadlineBasis: null,
|
deadlineBasis: null,
|
||||||
@@ -438,6 +379,7 @@ 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;
|
||||||
@@ -787,13 +729,10 @@ export class InspectionActsService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const [row] = (await manager.query(`
|
const [row] = (await manager.query(`
|
||||||
WITH updated AS (
|
UPDATE inspection_acts
|
||||||
UPDATE inspection_acts
|
SET current_version = current_version + 1
|
||||||
SET current_version = current_version + 1
|
WHERE id = $1
|
||||||
WHERE id = $1
|
RETURNING current_version AS "versionNumber"
|
||||||
RETURNING current_version
|
|
||||||
)
|
|
||||||
SELECT current_version AS "versionNumber" FROM updated
|
|
||||||
`, [act.id])) as Array<{ versionNumber: number }>;
|
`, [act.id])) as Array<{ versionNumber: number }>;
|
||||||
const versionNumber = Number(row.versionNumber);
|
const versionNumber = Number(row.versionNumber);
|
||||||
act.currentVersion = versionNumber;
|
act.currentVersion = versionNumber;
|
||||||
|
|||||||
@@ -213,14 +213,14 @@ export class CompanySignatureInviteService {
|
|||||||
|
|
||||||
if (!publicUrl) {
|
if (!publicUrl) {
|
||||||
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
|
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
|
||||||
} else if (!(await this.smtp.configured(principal.userId))) {
|
} else if (!(await this.smtp.configured())) {
|
||||||
deliveryError = 'SMTP no configurado';
|
deliveryError = 'SMTP no configurado';
|
||||||
} else {
|
} else {
|
||||||
const body = [
|
const body = [
|
||||||
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
|
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
|
||||||
`Inspección: ${created.inspectionCode}.`,
|
`Inspección: ${created.inspectionCode}.`,
|
||||||
'',
|
'',
|
||||||
'El enlace permite firmar en conformidad, firmar en disconformidad o registrar una negativa a firmar.',
|
'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.',
|
||||||
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
|
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
|
||||||
'',
|
'',
|
||||||
`Enlace seguro: ${publicUrl}`,
|
`Enlace seguro: ${publicUrl}`,
|
||||||
@@ -236,7 +236,7 @@ export class CompanySignatureInviteService {
|
|||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
content: Buffer.from(body, 'utf8'),
|
content: Buffer.from(body, 'utf8'),
|
||||||
},
|
},
|
||||||
}, principal.userId);
|
});
|
||||||
emailSent = true;
|
emailSent = true;
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_act_company_signature_invites
|
UPDATE inspection_act_company_signature_invites
|
||||||
@@ -264,6 +264,7 @@ export class CompanySignatureInviteService {
|
|||||||
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
||||||
const locked = invite.lockedSnapshot ?? {};
|
const locked = invite.lockedSnapshot ?? {};
|
||||||
const act = this.record(locked.act);
|
const act = this.record(locked.act);
|
||||||
|
const inventories = this.records(locked.inventories);
|
||||||
const findings = this.records(locked.findings).map((finding) => ({
|
const findings = this.records(locked.findings).map((finding) => ({
|
||||||
id: finding.id,
|
id: finding.id,
|
||||||
code: finding.code,
|
code: finding.code,
|
||||||
@@ -295,6 +296,12 @@ export class CompanySignatureInviteService {
|
|||||||
documentNumber: invite.recipientDocumentNumber,
|
documentNumber: invite.recipientDocumentNumber,
|
||||||
position: invite.recipientPosition,
|
position: invite.recipientPosition,
|
||||||
},
|
},
|
||||||
|
inventories: inventories.map((inventory) => ({
|
||||||
|
id: inventory.id,
|
||||||
|
code: inventory.code,
|
||||||
|
name: inventory.name,
|
||||||
|
typeName: inventory.typeName ?? inventory.typeCode ?? null,
|
||||||
|
})),
|
||||||
findings,
|
findings,
|
||||||
consent: REMOTE_COMPANY_CONSENT,
|
consent: REMOTE_COMPANY_CONSENT,
|
||||||
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsEnum } from 'class-validator';
|
|
||||||
import { InspectionActUrgency } from '../../database/entities';
|
|
||||||
|
|
||||||
export class PrepareInspectionActDto {
|
|
||||||
@IsEnum(InspectionActUrgency)
|
|
||||||
urgency!: InspectionActUrgency;
|
|
||||||
}
|
|
||||||
@@ -54,9 +54,7 @@ export class UpsertInspectionResponsibleDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
position?: string;
|
position?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
|
@IsOptional()
|
||||||
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
|
|
||||||
))
|
|
||||||
@Transform(trimOrUndefined)
|
@Transform(trimOrUndefined)
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@MaxLength(320)
|
@MaxLength(320)
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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';
|
||||||
@@ -53,11 +52,10 @@ 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, dto, principal, request);
|
return this.closing.prepare(actId, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/inspector')
|
@Post('signatures/inspector')
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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';
|
||||||
@@ -45,7 +44,7 @@ import {
|
|||||||
type UploadedInspectionSignatureFile,
|
type UploadedInspectionSignatureFile,
|
||||||
} from './inspection-signature-file';
|
} from './inspection-signature-file';
|
||||||
|
|
||||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
|
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4';
|
||||||
const CONSENT_VERSION = 'F4-1';
|
const CONSENT_VERSION = 'F4-1';
|
||||||
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.';
|
||||||
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.';
|
||||||
@@ -103,14 +102,6 @@ interface StoredSignature extends SignatureView {
|
|||||||
storedName: string | null;
|
storedName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReusableInspectorSignature {
|
|
||||||
originalName: string;
|
|
||||||
mimeType: 'image/png';
|
|
||||||
sizeBytes: number;
|
|
||||||
imageSha256: string;
|
|
||||||
imageData: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClosureRecord {
|
interface ClosureRecord {
|
||||||
actId: string;
|
actId: string;
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
@@ -139,7 +130,7 @@ export interface InspectionClosureView {
|
|||||||
code: string;
|
code: string;
|
||||||
status: InspectionActStatus;
|
status: InspectionActStatus;
|
||||||
visitId: string;
|
visitId: string;
|
||||||
urgency: InspectionActUrgency | null;
|
urgency: InspectionActUrgency;
|
||||||
deadlineDays: number | null;
|
deadlineDays: number | null;
|
||||||
deadlineDayType: InspectionDeadlineDayType | null;
|
deadlineDayType: InspectionDeadlineDayType | null;
|
||||||
deadlineBasis: InspectionDeadlineBasis | null;
|
deadlineBasis: InspectionDeadlineBasis | null;
|
||||||
@@ -160,7 +151,6 @@ export interface InspectionClosureView {
|
|||||||
actualClosedAt: Date | null;
|
actualClosedAt: Date | null;
|
||||||
};
|
};
|
||||||
responsible: ResponsibleView | null;
|
responsible: ResponsibleView | null;
|
||||||
representativeSuggestion: ResponsibleView | null;
|
|
||||||
closure: null | {
|
closure: null | {
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
preparedSha256: string;
|
preparedSha256: string;
|
||||||
@@ -278,7 +268,6 @@ 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> {
|
||||||
@@ -296,16 +285,16 @@ export class InspectionClosingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const policy = await this.deadlinePolicy(manager);
|
const policy = await this.deadlinePolicy(manager);
|
||||||
const deadlineDays = dto.urgency === InspectionActUrgency.URGENT
|
const deadlineDays = act.urgency === InspectionActUrgency.URGENT
|
||||||
? policy.urgentDays
|
? policy.urgentDays
|
||||||
: policy.nonUrgentDays;
|
: policy.nonUrgentDays;
|
||||||
const deadlineDayType = dto.urgency === InspectionActUrgency.URGENT
|
const deadlineDayType = act.urgency === InspectionActUrgency.URGENT
|
||||||
? policy.urgentDayType
|
? policy.urgentDayType
|
||||||
: policy.nonUrgentDayType;
|
: policy.nonUrgentDayType;
|
||||||
const deadlineBasis = dto.urgency === InspectionActUrgency.URGENT
|
const deadlineBasis = act.urgency === InspectionActUrgency.URGENT
|
||||||
? InspectionDeadlineBasis.ACT_DATE
|
? InspectionDeadlineBasis.ACT_DATE
|
||||||
: InspectionDeadlineBasis.GEDO_DATE;
|
: InspectionDeadlineBasis.GEDO_DATE;
|
||||||
const deadlineBaseAt = dto.urgency === InspectionActUrgency.URGENT
|
const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT
|
||||||
? act.occurredAt
|
? act.occurredAt
|
||||||
: null;
|
: null;
|
||||||
const deadlineAt = deadlineBaseAt
|
const deadlineAt = deadlineBaseAt
|
||||||
@@ -314,27 +303,22 @@ export class InspectionClosingService {
|
|||||||
const lockedAt = new Date();
|
const lockedAt = new Date();
|
||||||
|
|
||||||
const [updated] = (await manager.query(`
|
const [updated] = (await manager.query(`
|
||||||
WITH updated AS (
|
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,
|
current_version=current_version+1,
|
||||||
locked_by=$9,
|
updated_by=$8,
|
||||||
current_version=current_version+1,
|
updated_at=$7
|
||||||
updated_by=$9,
|
WHERE id=$1
|
||||||
updated_at=$8
|
RETURNING current_version AS "versionNumber"
|
||||||
WHERE id=$1
|
|
||||||
RETURNING current_version
|
|
||||||
)
|
|
||||||
SELECT current_version AS "versionNumber" FROM updated
|
|
||||||
`, [
|
`, [
|
||||||
actId,
|
actId,
|
||||||
dto.urgency,
|
|
||||||
deadlineDays,
|
deadlineDays,
|
||||||
deadlineDayType,
|
deadlineDayType,
|
||||||
deadlineBasis,
|
deadlineBasis,
|
||||||
@@ -388,7 +372,7 @@ export class InspectionClosingService {
|
|||||||
afterData: {
|
afterData: {
|
||||||
status: InspectionActStatus.LOCKED,
|
status: InspectionActStatus.LOCKED,
|
||||||
lockedSha256: preparedSha256,
|
lockedSha256: preparedSha256,
|
||||||
urgency: dto.urgency,
|
urgency: act.urgency,
|
||||||
deadlineDays,
|
deadlineDays,
|
||||||
deadlineDayType,
|
deadlineDayType,
|
||||||
deadlineBasis,
|
deadlineBasis,
|
||||||
@@ -472,13 +456,6 @@ export class InspectionClosingService {
|
|||||||
message: 'La negativa a firmar requiere identificar al responsable que se negó',
|
message: 'La negativa a firmar requiere identificar al responsable que se negó',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (dto.status === InspectionActSignatureStatus.ABSENT
|
|
||||||
&& responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
code: 'INSPECTION_COMPANY_ABSENCE_REQUIRES_ABSENT_RESPONSIBLE',
|
|
||||||
message: 'La ausencia sólo puede confirmarse cuando el representante fue registrado como ausente',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (await this.hasCompanyOutcome(manager, actId)) {
|
if (await this.hasCompanyOutcome(manager, actId)) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
|
code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED',
|
||||||
@@ -550,15 +527,22 @@ export class InspectionClosingService {
|
|||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
): Promise<InspectionClosureView> {
|
): Promise<InspectionClosureView> {
|
||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
let generatedInspectorSignaturePath: string | null = null;
|
const sealed = await this.dataSource.transaction(async (manager) => {
|
||||||
let sealed: InspectionClosureView;
|
|
||||||
try {
|
|
||||||
sealed = await this.dataSource.transaction(async (manager) => {
|
|
||||||
const { act, visit } = await this.lockContext(manager, actId);
|
const { act, visit } = await this.lockContext(manager, actId);
|
||||||
this.assertLockedForManifestation(act);
|
this.assertLockedForManifestation(act);
|
||||||
await this.assertActorAssigned(manager, visit.id, principal, true);
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
||||||
const closure = await this.requireClosure(manager, actId);
|
const closure = await this.requireClosure(manager, actId);
|
||||||
let signatures = await this.loadSignatures(manager, actId);
|
const signatures = await this.loadSignatures(manager, actId);
|
||||||
|
const inspectorSigned = signatures.some((item) =>
|
||||||
|
item.signerType === InspectionActSignerType.INSPECTOR
|
||||||
|
&& item.status === InspectionActSignatureStatus.SIGNED,
|
||||||
|
);
|
||||||
|
if (!inspectorSigned) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED',
|
||||||
|
message: 'Se requiere la firma del inspector para sellar el acta',
|
||||||
|
});
|
||||||
|
}
|
||||||
const companyOutcomes = signatures.filter((item) =>
|
const companyOutcomes = signatures.filter((item) =>
|
||||||
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
|
item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE,
|
||||||
);
|
);
|
||||||
@@ -568,17 +552,6 @@ export class InspectionClosingService {
|
|||||||
message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa',
|
message: 'Debe existir exactamente una firma, disidencia, negativa o ausencia documentada del responsable de la empresa',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const inspectorSigned = signatures.some((item) =>
|
|
||||||
item.signerType === InspectionActSignerType.INSPECTOR
|
|
||||||
&& item.status === InspectionActSignatureStatus.SIGNED,
|
|
||||||
);
|
|
||||||
if (!inspectorSigned) {
|
|
||||||
const created = await this.createInspectorSignatureFromProfile(
|
|
||||||
manager, actId, closure.preparedSha256, principal, request,
|
|
||||||
);
|
|
||||||
generatedInspectorSignaturePath = created.filePath;
|
|
||||||
signatures = await this.loadSignatures(manager, actId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverSealedAt = new Date();
|
const serverSealedAt = new Date();
|
||||||
const deviceSealedAt = new Date(dto.clientClosedAt);
|
const deviceSealedAt = new Date(dto.clientClosedAt);
|
||||||
@@ -641,21 +614,18 @@ export class InspectionClosingService {
|
|||||||
WHERE act_id=$1
|
WHERE act_id=$1
|
||||||
`, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
|
`, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]);
|
||||||
const [updated] = (await manager.query(`
|
const [updated] = (await manager.query(`
|
||||||
WITH updated AS (
|
UPDATE inspection_acts
|
||||||
UPDATE inspection_acts
|
SET status='SEALED',
|
||||||
SET status='SEALED',
|
sealed_at=$2,
|
||||||
sealed_at=$2,
|
sealed_by=$3,
|
||||||
sealed_by=$3,
|
closed_at=$2,
|
||||||
closed_at=$2,
|
closed_by=$3,
|
||||||
closed_by=$3,
|
closure_sha256=$4,
|
||||||
closure_sha256=$4,
|
current_version=current_version+1,
|
||||||
current_version=current_version+1,
|
updated_by=$3,
|
||||||
updated_by=$3,
|
updated_at=$2
|
||||||
updated_at=$2
|
WHERE id=$1
|
||||||
WHERE id=$1
|
RETURNING current_version AS "versionNumber"
|
||||||
RETURNING current_version
|
|
||||||
)
|
|
||||||
SELECT current_version AS "versionNumber" FROM updated
|
|
||||||
`, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
|
`, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
INSERT INTO inspection_act_versions (
|
INSERT INTO inspection_act_versions (
|
||||||
@@ -689,11 +659,7 @@ export class InspectionClosingService {
|
|||||||
}, manager);
|
}, manager);
|
||||||
await this.reports.ensureFrozenReport(manager, actId, principal, request);
|
await this.reports.ensureFrozenReport(manager, actId, principal, request);
|
||||||
return this.loadView(manager, actId);
|
return this.loadView(manager, actId);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
if (generatedInspectorSignaturePath) await unlink(generatedInspectorSignaturePath).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
await this.reports.ensureWordForAct(actId);
|
await this.reports.ensureWordForAct(actId);
|
||||||
return sealed;
|
return sealed;
|
||||||
}
|
}
|
||||||
@@ -896,8 +862,6 @@ export class InspectionClosingService {
|
|||||||
'id',act.id,
|
'id',act.id,
|
||||||
'code',act.code,
|
'code',act.code,
|
||||||
'status',act.status,
|
'status',act.status,
|
||||||
'actYear',act.act_year,
|
|
||||||
'actNumber',act.act_number,
|
|
||||||
'occurredAt',act.occurred_at,
|
'occurredAt',act.occurred_at,
|
||||||
'title',act.title,
|
'title',act.title,
|
||||||
'summary',act.summary,
|
'summary',act.summary,
|
||||||
@@ -912,7 +876,6 @@ export class InspectionClosingService {
|
|||||||
'id',visit.id,
|
'id',visit.id,
|
||||||
'code',visit.code,
|
'code',visit.code,
|
||||||
'status',visit.status,
|
'status',visit.status,
|
||||||
'scopeAssetId',visit.scope_asset_id,
|
|
||||||
'operationalAreaId',visit.operational_area_id,
|
'operationalAreaId',visit.operational_area_id,
|
||||||
'operatorCompanyId',visit.operator_company_id,
|
'operatorCompanyId',visit.operator_company_id,
|
||||||
'leadInspectorUserId',visit.lead_inspector_user_id,
|
'leadInspectorUserId',visit.lead_inspector_user_id,
|
||||||
@@ -925,74 +888,43 @@ export class InspectionClosingService {
|
|||||||
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
`, [actId]) as Array<{ act: Record<string, unknown> }>;
|
||||||
if (!act) throw actNotFound();
|
if (!act) throw actNotFound();
|
||||||
const responsible = await this.requireResponsible(manager, actId);
|
const responsible = await this.requireResponsible(manager, actId);
|
||||||
const [contextRow] = await manager.query(`
|
|
||||||
SELECT
|
|
||||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS company,
|
|
||||||
CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',department.id,'code',department.code,'name',department.name) END AS department,
|
|
||||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS area,
|
|
||||||
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',scope.id,'code',scope.code,'name',scope.name,'typeCode',scope_type.code,'typeName',scope_type.name) END AS scope,
|
|
||||||
CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',lead.id,'username',lead.username,'firstName',lead.first_name,'lastName',lead.last_name,'email',lead.email) END AS "leadInspector"
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
|
||||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
|
||||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
|
||||||
LEFT JOIN assets department ON department.id=area.parent_id
|
|
||||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
|
||||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
|
|
||||||
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
|
|
||||||
WHERE act.id=$1
|
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
|
||||||
const inspectors = await manager.query(`
|
|
||||||
SELECT member.id,member.username,member.first_name AS "firstName",member.last_name AS "lastName",member.email
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
|
||||||
JOIN inspection_visit_members link ON link.visit_id=act.visit_id AND link.included=true
|
|
||||||
JOIN users member ON member.id=link.user_id
|
|
||||||
WHERE act.id=$1
|
|
||||||
ORDER BY CASE WHEN member.id=visit.lead_inspector_user_id THEN 0 ELSE 1 END,
|
|
||||||
member.last_name,member.first_name,member.username
|
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
|
||||||
const context = { ...(contextRow ?? {}), inspectors };
|
|
||||||
const inventories = await manager.query(`
|
const inventories = await manager.query(`
|
||||||
WITH RECURSIVE selected AS (
|
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion",
|
||||||
SELECT asset.id,asset.code,asset.name,asset.common_name,asset.parent_id,asset.current_version,type.code AS type_code,type.name AS type_name,
|
type.code AS "typeCode",type.name AS "typeName"
|
||||||
family.code AS family_code,family.name AS family_name
|
FROM inspection_act_assets link
|
||||||
FROM inspection_act_assets link
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
JOIN assets asset ON asset.id=link.asset_id
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
WHERE link.act_id=$1 AND link.included=true
|
||||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
ORDER BY asset.code,asset.id
|
||||||
WHERE link.act_id=$1 AND link.included=true
|
|
||||||
AND EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.act_id=link.act_id AND finding.asset_id=asset.id AND finding.status<>'VOIDED')
|
|
||||||
), lineage AS (
|
|
||||||
SELECT selected.id AS root_id,selected.id,selected.code,selected.name,selected.type_code,selected.type_name,selected.parent_id,0 AS depth FROM selected
|
|
||||||
UNION ALL
|
|
||||||
SELECT lineage.root_id,parent.id,parent.code,parent.name,parent_type.code,parent_type.name,parent.parent_id,lineage.depth+1
|
|
||||||
FROM lineage JOIN assets parent ON parent.id=lineage.parent_id JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
|
||||||
WHERE lineage.depth<8
|
|
||||||
)
|
|
||||||
SELECT selected.id,selected.code,selected.name,selected.common_name AS "commonName",selected.current_version AS "currentVersion",
|
|
||||||
selected.type_code AS "typeCode",selected.type_name AS "typeName",
|
|
||||||
selected.family_code AS "installationTypeCode",selected.family_name AS "installationTypeName",
|
|
||||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',lineage.id,'code',lineage.code,'name',lineage.name,'typeCode',lineage.type_code,'typeName',lineage.type_name) ORDER BY lineage.depth DESC) FROM lineage WHERE lineage.root_id=selected.id),'[]'::jsonb) AS path
|
|
||||||
FROM selected ORDER BY selected.code,selected.id
|
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
const findings = await manager.query(`
|
const findings = await manager.query(`
|
||||||
SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status,
|
SELECT
|
||||||
finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description,
|
finding.id,
|
||||||
finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision",
|
finding.finding_number AS "findingNumber",
|
||||||
finding.suggested_severity AS "suggestedSeverity",finding.severity,
|
finding.code,
|
||||||
finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
finding.status,
|
||||||
finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion",
|
finding.asset_id AS "assetId",
|
||||||
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',catalog.id,'code',catalog.code,'sourceNumber',catalog.source_number,'title',catalog.title,'categoryName',category.name,'revision',finding.catalog_revision) END AS catalog,
|
finding.catalog_item_id AS "catalogItemId",
|
||||||
CASE WHEN antecedent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',antecedent.id,'code',antecedent.code,'title',antecedent.title) END AS recurrence
|
finding.title,
|
||||||
|
finding.description,
|
||||||
|
finding.legal_basis AS "legalBasis",
|
||||||
|
finding.severity,
|
||||||
|
finding.is_recurrence AS "isRecurrence",
|
||||||
|
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||||
|
finding.correction_due_on AS "correctionDueOn",
|
||||||
|
finding.current_version AS "currentVersion"
|
||||||
FROM inspection_findings finding
|
FROM inspection_findings finding
|
||||||
LEFT JOIN finding_catalog_items catalog ON catalog.id=finding.catalog_item_id
|
|
||||||
LEFT JOIN finding_categories category ON category.id=catalog.category_id
|
|
||||||
LEFT JOIN inspection_findings antecedent ON antecedent.id=finding.recurrence_of_finding_id
|
|
||||||
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||||
ORDER BY finding.finding_number,finding.id
|
ORDER BY finding.finding_number,finding.id
|
||||||
`, [actId]) as Array<Record<string, unknown>>;
|
`, [actId]) as Array<Record<string, unknown>>;
|
||||||
return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings };
|
return {
|
||||||
|
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
||||||
|
lockedAt: lockedAt.toISOString(),
|
||||||
|
act: act.act,
|
||||||
|
responsible,
|
||||||
|
inventories,
|
||||||
|
findings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
private async deadlinePolicy(manager: EntityManager): Promise<DeadlinePolicy> {
|
||||||
@@ -1077,7 +1009,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 | null; deadlineDays: number | null;
|
urgency: InspectionActUrgency; 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;
|
||||||
@@ -1086,7 +1018,6 @@ export class InspectionClosingService {
|
|||||||
}>;
|
}>;
|
||||||
if (!context) throw actNotFound();
|
if (!context) throw actNotFound();
|
||||||
const responsible = await this.loadResponsible(manager, actId);
|
const responsible = await this.loadResponsible(manager, actId);
|
||||||
const representativeSuggestion = responsible ? null : await this.loadResponsibleSuggestion(manager, actId);
|
|
||||||
const closure = await this.loadClosure(manager, actId);
|
const closure = await this.loadClosure(manager, actId);
|
||||||
const signatures = await this.loadSignatures(manager, actId);
|
const signatures = await this.loadSignatures(manager, actId);
|
||||||
return {
|
return {
|
||||||
@@ -1116,7 +1047,6 @@ export class InspectionClosingService {
|
|||||||
actualClosedAt: context.visitActualClosedAt,
|
actualClosedAt: context.visitActualClosedAt,
|
||||||
},
|
},
|
||||||
responsible,
|
responsible,
|
||||||
representativeSuggestion,
|
|
||||||
closure: closure ? {
|
closure: closure ? {
|
||||||
schemaVersion: closure.schemaVersion,
|
schemaVersion: closure.schemaVersion,
|
||||||
preparedSha256: closure.preparedSha256,
|
preparedSha256: closure.preparedSha256,
|
||||||
@@ -1149,30 +1079,12 @@ export class InspectionClosingService {
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadResponsibleSuggestion(manager: EntityManager, actId: string): Promise<ResponsibleView | null> {
|
|
||||||
const [row] = await manager.query(`
|
|
||||||
SELECT responsible.act_id AS "actId",responsible.attendance_status AS "attendanceStatus",
|
|
||||||
responsible.full_name AS "fullName",responsible.document_type AS "documentType",
|
|
||||||
responsible.document_number AS "documentNumber",responsible.position,responsible.email,responsible.phone,
|
|
||||||
responsible.absence_reason AS "absenceReason",responsible.updated_by AS "updatedBy",
|
|
||||||
responsible.created_at AS "createdAt",responsible.updated_at AS "updatedAt"
|
|
||||||
FROM inspection_acts current_act
|
|
||||||
JOIN inspection_acts previous_act
|
|
||||||
ON previous_act.visit_id=current_act.visit_id AND previous_act.id<>current_act.id
|
|
||||||
JOIN inspection_act_responsibles responsible ON responsible.act_id=previous_act.id
|
|
||||||
WHERE current_act.id=$1 AND responsible.attendance_status='PRESENT'
|
|
||||||
ORDER BY responsible.updated_at DESC,previous_act.created_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`, [actId]) as ResponsibleView[];
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> {
|
private async requireResponsible(manager: EntityManager, actId: string): Promise<ResponsibleView> {
|
||||||
const responsible = await this.loadResponsible(manager, actId);
|
const responsible = await this.loadResponsible(manager, actId);
|
||||||
if (!responsible) {
|
if (!responsible) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
|
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
|
||||||
message: 'Debe identificarse al representante de la empresa o documentar su ausencia antes de cerrar el Acta',
|
message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return responsible;
|
return responsible;
|
||||||
@@ -1234,82 +1146,6 @@ export class InspectionClosingService {
|
|||||||
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
|
return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requireReusableInspectorSignature(
|
|
||||||
manager: EntityManager,
|
|
||||||
userId: string,
|
|
||||||
): Promise<ReusableInspectorSignature> {
|
|
||||||
const [row] = await manager.query(`
|
|
||||||
SELECT original_name AS "originalName", mime_type AS "mimeType",
|
|
||||||
size_bytes AS "sizeBytes", image_sha256 AS "imageSha256",
|
|
||||||
image_data AS "imageData"
|
|
||||||
FROM user_signature_profiles
|
|
||||||
WHERE user_id=$1
|
|
||||||
`, [userId]) as ReusableInspectorSignature[];
|
|
||||||
if (!row) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED',
|
|
||||||
message: 'Para cerrar definitivamente el Acta, cargá tu firma de inspector desde Mi perfil en el Dashboard',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createInspectorSignatureFromProfile(
|
|
||||||
manager: EntityManager,
|
|
||||||
actId: string,
|
|
||||||
preparedSha256: string,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<{ filePath: string }> {
|
|
||||||
const profile = await this.requireReusableInspectorSignature(manager, principal.userId);
|
|
||||||
const id = randomUUID();
|
|
||||||
const storedName = `${id}.png`;
|
|
||||||
const filePath = resolve(this.signatureRoot, storedName);
|
|
||||||
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(filePath, profile.imageData, { flag: 'wx', mode: 0o600 });
|
|
||||||
const signedAt = new Date();
|
|
||||||
const source = this.signatureSource(principal);
|
|
||||||
const signerName = `${principal.firstName} ${principal.lastName}`.trim();
|
|
||||||
const payload = {
|
|
||||||
actId, lockedSha256: preparedSha256, signerType: InspectionActSignerType.INSPECTOR,
|
|
||||||
signerUserId: principal.userId, signerName, position: 'Inspector/a',
|
|
||||||
status: InspectionActSignatureStatus.SIGNED, imageSha256: profile.imageSha256,
|
|
||||||
consentText: INSPECTOR_CONSENT, consentVersion: CONSENT_VERSION,
|
|
||||||
consentAcceptedAt: signedAt.toISOString(), signedAt: signedAt.toISOString(),
|
|
||||||
source, signatureMode: 'PROFILE_REUSABLE', uploadedBy: principal.userId,
|
|
||||||
};
|
|
||||||
const signaturePayloadSha256 = sha256CanonicalJson(payload);
|
|
||||||
try {
|
|
||||||
await manager.query(`
|
|
||||||
INSERT INTO inspection_act_signatures (
|
|
||||||
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,position,status,
|
|
||||||
original_name,stored_name,mime_type,size_bytes,image_sha256,consent_text,consent_version,
|
|
||||||
consent_accepted_at,client_signed_at,signed_at,latitude,longitude,accuracy_m,device_label,source,
|
|
||||||
prepared_sha256,signature_payload_sha256,uploaded_by,created_at
|
|
||||||
) VALUES (
|
|
||||||
$1,$2,'INSPECTOR',$3,$4,NULL,NULL,'Inspector/a','SIGNED',
|
|
||||||
$5,$6,$7,$8,$9,$10,$11,$12,NULL,$12,NULL,NULL,NULL,$13,$14,$15,$16,$3,$12
|
|
||||||
)
|
|
||||||
`, [
|
|
||||||
id, actId, principal.userId, signerName, profile.originalName, storedName, profile.mimeType,
|
|
||||||
profile.sizeBytes, profile.imageSha256, INSPECTOR_CONSENT, CONSENT_VERSION, signedAt,
|
|
||||||
'Firma guardada en Mi perfil', source, preparedSha256, signaturePayloadSha256,
|
|
||||||
]);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED,
|
|
||||||
entityType: 'inspection_act_signature',
|
|
||||||
entityId: id,
|
|
||||||
afterData: payload,
|
|
||||||
metadata: { actId, immutable: true, signatureMode: 'PROFILE_REUSABLE' },
|
|
||||||
}, manager);
|
|
||||||
return { filePath };
|
|
||||||
} catch (error) {
|
|
||||||
await unlink(filePath).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> {
|
private async loadStoredSignature(manager: EntityManager, id: string): Promise<StoredSignature> {
|
||||||
const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[];
|
const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[];
|
||||||
if (!row) throw signatureNotFound();
|
if (!row) throw signatureNotFound();
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ import {
|
|||||||
} from '../../database/entities';
|
} from '../../database/entities';
|
||||||
|
|
||||||
export class CreateInspectionEvidenceDto {
|
export class CreateInspectionEvidenceDto {
|
||||||
@IsOptional()
|
|
||||||
@IsUUID('4')
|
|
||||||
operationId?: string;
|
|
||||||
|
|
||||||
@IsEnum(InspectionEvidenceKind)
|
@IsEnum(InspectionEvidenceKind)
|
||||||
kind!: InspectionEvidenceKind;
|
kind!: InspectionEvidenceKind;
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ const optionalText = ({ value }: { value: unknown }) =>
|
|||||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
|
||||||
export class CreateInspectionFindingDto {
|
export class CreateInspectionFindingDto {
|
||||||
@IsOptional()
|
|
||||||
@IsUUID('4')
|
|
||||||
clientGeneratedId?: string;
|
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class F3FindingCatalogResolverService {
|
|||||||
inventoryFamily: null,
|
inventoryFamily: null,
|
||||||
catalogSource: 'INVENTORY_FAMILY' as const,
|
catalogSource: 'INVENTORY_FAMILY' as const,
|
||||||
typeConfigured: false,
|
typeConfigured: false,
|
||||||
configurationReason: 'El elemento todavía no tiene tipo de instalación/subinstalación activo.',
|
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
|
||||||
categories: [],
|
categories: [],
|
||||||
items: [],
|
items: [],
|
||||||
other,
|
other,
|
||||||
@@ -120,7 +120,7 @@ export class F3FindingCatalogResolverService {
|
|||||||
},
|
},
|
||||||
catalogSource: 'INVENTORY_FAMILY' as const,
|
catalogSource: 'INVENTORY_FAMILY' as const,
|
||||||
typeConfigured: true,
|
typeConfigured: true,
|
||||||
configurationReason: `Hallazgos asociados al tipo ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
||||||
categories,
|
categories,
|
||||||
items,
|
items,
|
||||||
other,
|
other,
|
||||||
|
|||||||
@@ -153,22 +153,6 @@ export class InspectionEvidenceService {
|
|||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
}
|
}
|
||||||
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
|
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
|
||||||
if (dto.operationId) {
|
|
||||||
const [existing] = await this.dataSource.query(
|
|
||||||
`${this.evidenceSelect()} WHERE evidence.client_operation_id = $1`,
|
|
||||||
[dto.operationId],
|
|
||||||
) as StoredInspectionEvidence[];
|
|
||||||
if (existing) {
|
|
||||||
if (existing.findingId !== findingId) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_EVIDENCE_OPERATION_REUSED',
|
|
||||||
message: 'La operación offline ya fue utilizada para otra evidencia',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { storedName: _storedName, ...view } = existing;
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const inspected = inspectInspectionEvidenceFile(file, dto.kind);
|
const inspected = inspectInspectionEvidenceFile(file, dto.kind);
|
||||||
this.validatePurpose(dto, inspected.mimeType);
|
this.validatePurpose(dto, inspected.mimeType);
|
||||||
|
|
||||||
@@ -226,12 +210,12 @@ export class InspectionEvidenceService {
|
|||||||
id, finding_id, communication_id, verification_visit_id, kind, purpose,
|
id, finding_id, communication_id, verification_visit_id, kind, purpose,
|
||||||
original_name, stored_name, mime_type, size_bytes, sha256,
|
original_name, stored_name, mime_type, size_bytes, sha256,
|
||||||
title, description, captured_at, latitude, longitude, accuracy_m,
|
title, description, captured_at, latitude, longitude, accuracy_m,
|
||||||
device_label, source, uploaded_by, client_operation_id
|
device_label, source, uploaded_by
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11,
|
$7, $8, $9, $10, $11,
|
||||||
$12, $13, $14, $15, $16, $17,
|
$12, $13, $14, $15, $16, $17,
|
||||||
$18, $19, $20, $21
|
$18, $19, $20
|
||||||
)
|
)
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
@@ -254,7 +238,6 @@ export class InspectionEvidenceService {
|
|||||||
dto.deviceLabel?.trim() || null,
|
dto.deviceLabel?.trim() || null,
|
||||||
source,
|
source,
|
||||||
principal.userId,
|
principal.userId,
|
||||||
dto.operationId ?? null,
|
|
||||||
]);
|
]);
|
||||||
const created = await this.loadEvidence(manager, id);
|
const created = await this.loadEvidence(manager, id);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
|
|||||||
@@ -398,22 +398,6 @@ export class InspectionFindingsService {
|
|||||||
const visit = await this.lockVisit(manager, act.visitId);
|
const visit = await this.lockVisit(manager, act.visitId);
|
||||||
this.assertActEditable(act, visit);
|
this.assertActEditable(act, visit);
|
||||||
await this.assertActorAssigned(manager, visit.id, principal);
|
await this.assertActorAssigned(manager, visit.id, principal);
|
||||||
if (dto.clientGeneratedId) {
|
|
||||||
const [existing] = await manager.query(`
|
|
||||||
SELECT id, act_id AS "actId", asset_id AS "assetId"
|
|
||||||
FROM inspection_findings
|
|
||||||
WHERE id=$1
|
|
||||||
`, [dto.clientGeneratedId]) as Array<{ id: string; actId: string; assetId: string }>;
|
|
||||||
if (existing) {
|
|
||||||
if (existing.actId !== actId || existing.assetId !== dto.assetId) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_FINDING_CLIENT_ID_REUSED',
|
|
||||||
message: 'El identificador offline del Hallazgo ya fue utilizado en otro contexto',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.loadView(manager, existing.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.assertActAsset(manager, actId, dto.assetId);
|
await this.assertActAsset(manager, actId, dto.assetId);
|
||||||
|
|
||||||
const catalog = dto.catalogItemId
|
const catalog = dto.catalogItemId
|
||||||
@@ -434,7 +418,6 @@ export class InspectionFindingsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const finding = manager.getRepository(InspectionFinding).create({
|
const finding = manager.getRepository(InspectionFinding).create({
|
||||||
id: dto.clientGeneratedId,
|
|
||||||
actId,
|
actId,
|
||||||
assetId: dto.assetId,
|
assetId: dto.assetId,
|
||||||
catalogItemId: catalog?.id ?? null,
|
catalogItemId: catalog?.id ?? null,
|
||||||
@@ -629,13 +612,6 @@ export class InspectionFindingsService {
|
|||||||
if (Object.keys(dto).length === 0) {
|
if (Object.keys(dto).length === 0) {
|
||||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
}
|
}
|
||||||
if (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined
|
|
||||||
|| dto.companyCommittedCorrectionOn !== undefined) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_FINDING_COMPANY_RESPONSE_IN_REPORT',
|
|
||||||
message: 'La respuesta de la empresa se registra en el Informe relacionado, conservando este Hallazgo como antecedente.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const finding = await this.lockFinding(manager, id);
|
const finding = await this.lockFinding(manager, id);
|
||||||
this.assertFindingOpen(finding);
|
this.assertFindingOpen(finding);
|
||||||
@@ -1190,13 +1166,10 @@ export class InspectionFindingsService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const [row] = await manager.query(`
|
const [row] = await manager.query(`
|
||||||
WITH updated AS (
|
UPDATE inspection_findings
|
||||||
UPDATE inspection_findings
|
SET current_version = current_version + 1
|
||||||
SET current_version = current_version + 1
|
WHERE id = $1
|
||||||
WHERE id = $1
|
RETURNING current_version AS "versionNumber"
|
||||||
RETURNING current_version
|
|
||||||
)
|
|
||||||
SELECT current_version AS "versionNumber" FROM updated
|
|
||||||
`, [finding.id]) as Array<{ versionNumber: number }>;
|
`, [finding.id]) as Array<{ versionNumber: number }>;
|
||||||
const versionNumber = Number(row.versionNumber);
|
const versionNumber = Number(row.versionNumber);
|
||||||
finding.currentVersion = versionNumber;
|
finding.currentVersion = versionNumber;
|
||||||
|
|||||||
@@ -1,300 +1,212 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
import PDFDocument = require('pdfkit');
|
|
||||||
|
|
||||||
export interface ActPdfImage {
|
|
||||||
id: string;
|
|
||||||
findingId?: string;
|
|
||||||
assetId?: string;
|
|
||||||
signerName?: string;
|
|
||||||
title?: string;
|
|
||||||
capturedAt?: string | Date | null;
|
|
||||||
sha256: string;
|
|
||||||
buffer: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActPdfContext {
|
|
||||||
companyName?: string | null;
|
|
||||||
departmentName?: string | null;
|
|
||||||
areaName?: string | null;
|
|
||||||
scopeName?: string | null;
|
|
||||||
scopeCode?: string | null;
|
|
||||||
scopeTypeName?: string | null;
|
|
||||||
scopeTypeCode?: string | null;
|
|
||||||
yacimientoName?: string | null;
|
|
||||||
yacimientoCode?: string | null;
|
|
||||||
leadInspectorName?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function asArray(value: unknown): Array<Record<string, unknown>> {
|
function asArray(value: unknown): Array<Record<string, unknown>> {
|
||||||
return Array.isArray(value) ? value.map(asRecord) : [];
|
return Array.isArray(value) ? value.map(asRecord) : [];
|
||||||
}
|
}
|
||||||
function text(value: unknown, fallback = ''): string {
|
|
||||||
return String(value ?? '').trim() || fallback;
|
function text(value: unknown, fallback = '-'): string {
|
||||||
|
const out = String(value ?? '').trim();
|
||||||
|
return out || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function date(value: unknown): string {
|
function date(value: unknown): string {
|
||||||
const parsed = new Date(String(value ?? ''));
|
const parsed = new Date(String(value ?? ''));
|
||||||
return Number.isFinite(parsed.getTime())
|
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-';
|
||||||
? new Intl.DateTimeFormat('es-AR', {
|
|
||||||
timeZone: 'America/Argentina/Mendoza', day: '2-digit', month: '2-digit', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
||||||
}).format(parsed)
|
|
||||||
: '-';
|
|
||||||
}
|
|
||||||
function fullName(value: Record<string, unknown>): string {
|
|
||||||
return `${text(value.firstName)} ${text(value.lastName)}`.trim() || text(value.username);
|
|
||||||
}
|
|
||||||
function isPlaceholder(value: unknown): boolean {
|
|
||||||
return text(value).startsWith('Acta de inspección en curso. Los Hallazgos');
|
|
||||||
}
|
|
||||||
function urgency(value: unknown): string {
|
|
||||||
return text(value) === 'URGENT' ? 'Urgente' : text(value) === 'NON_URGENT' ? 'No urgente' : '-';
|
|
||||||
}
|
|
||||||
function dayType(value: unknown): string {
|
|
||||||
return text(value) === 'BUSINESS' ? 'días hábiles' : text(value) === 'CALENDAR' ? 'días corridos' : '';
|
|
||||||
}
|
|
||||||
function deadlineBasis(value: unknown): string {
|
|
||||||
return text(value) === 'ACT_DATE' ? 'Desde la fecha del Acta' : text(value) === 'GEDO_DATE' ? 'Desde la notificación formal' : '';
|
|
||||||
}
|
|
||||||
function manifestation(value: unknown): string {
|
|
||||||
return text(value) === 'CONFORMITY' ? 'Firma sin disconformidad' : text(value) === 'DISSENT' ? 'Firma en disconformidad' : '';
|
|
||||||
}
|
|
||||||
function signatureStatus(value: unknown): string {
|
|
||||||
return text(value) === 'SIGNED' ? 'Firmó' : text(value) === 'REFUSED' ? 'Se negó a firmar' : text(value) === 'ABSENT' ? 'Ausente' : 'No firmó';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// %PDF-1.4 is the document-version contract for consolidated Actas.
|
function clean(value: string): string {
|
||||||
export async function buildInspectionActPdf(
|
return value
|
||||||
snapshot: Record<string, unknown>,
|
.normalize('NFKD')
|
||||||
images: ActPdfImage[] = [],
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
fallbackContext: ActPdfContext = {},
|
.replace(/[–—]/g, '-')
|
||||||
): Promise<{ buffer: Buffer; sha256: string }> {
|
.replace(/[“”]/g, '"')
|
||||||
|
.replace(/[‘’]/g, "'")
|
||||||
|
.replace(/[^\x20-\xFF]/g, '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePdf(value: string): string {
|
||||||
|
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(value: string, max = 92): string[] {
|
||||||
|
const words = clean(value).split(/\s+/).filter(Boolean);
|
||||||
|
const out: string[] = [];
|
||||||
|
let line = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const next = line ? `${line} ${word}` : word;
|
||||||
|
if (next.length > max && line) {
|
||||||
|
out.push(line);
|
||||||
|
line = word;
|
||||||
|
} else {
|
||||||
|
line = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) out.push(line);
|
||||||
|
return out.length ? out : ['-'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockedSnapshot(snapshot: Record<string, unknown>): {
|
||||||
|
locked: Record<string, unknown>;
|
||||||
|
signatures: Array<Record<string, unknown>>;
|
||||||
|
finalSha256: unknown;
|
||||||
|
} {
|
||||||
const sealed = asRecord(snapshot);
|
const sealed = asRecord(snapshot);
|
||||||
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
||||||
|
return {
|
||||||
|
locked,
|
||||||
|
signatures: asArray(sealed.signatures),
|
||||||
|
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyLabel(value: unknown): string {
|
||||||
|
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayTypeLabel(value: unknown): string {
|
||||||
|
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lines(snapshot: Record<string, unknown>): string[] {
|
||||||
|
const source = lockedSnapshot(snapshot);
|
||||||
|
const locked = source.locked;
|
||||||
const act = asRecord(locked.act);
|
const act = asRecord(locked.act);
|
||||||
const inspection = asRecord(act.inspection ?? act.visit);
|
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
||||||
const context = asRecord(locked.context);
|
|
||||||
const company = asRecord(context.company);
|
|
||||||
const department = asRecord(context.department);
|
|
||||||
const area = asRecord(context.area);
|
|
||||||
const scope = asRecord(context.scope);
|
|
||||||
const leadInspector = asRecord(context.leadInspector);
|
|
||||||
const inspectors = asArray(context.inspectors);
|
|
||||||
const responsible = asRecord(locked.responsible);
|
const responsible = asRecord(locked.responsible);
|
||||||
const inventories = asArray(locked.inventories);
|
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||||
const findings = asArray(locked.findings);
|
const findings = asArray(locked.findings);
|
||||||
const signatures = asArray(sealed.signatures);
|
const signatures = source.signatures;
|
||||||
const seal = asRecord(sealed.seal);
|
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
||||||
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
||||||
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
|
||||||
|
|
||||||
const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
|
const deadlineText = act.deadlineAt
|
||||||
doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
||||||
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
|
: act.deadlineBasis === 'GEDO_DATE'
|
||||||
const chunks: Buffer[] = [];
|
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
||||||
doc.on('data', (part: Buffer) => chunks.push(part));
|
: '-';
|
||||||
const done = new Promise<Buffer>((complete, reject) => { doc.on('end', () => complete(Buffer.concat(chunks))); doc.on('error', reject); });
|
|
||||||
const blue = '#162D69';
|
|
||||||
const ink = '#202939';
|
|
||||||
const muted = '#5A667C';
|
|
||||||
|
|
||||||
const header = () => {
|
const out: string[] = [
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
'ACTA DE INSPECCION',
|
||||||
doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
|
'',
|
||||||
if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] });
|
`Acta: ${text(act.code)}`,
|
||||||
doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke();
|
`Inspeccion: ${text(inspection.code)}`,
|
||||||
doc.y = 146;
|
`Fecha: ${date(act.occurredAt)}`,
|
||||||
};
|
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
||||||
doc.on('pageAdded', header);
|
`Plazo: ${deadlineText}`,
|
||||||
header();
|
`Responsable empresa: ${text(responsible.fullName)}`,
|
||||||
|
`Cargo: ${text(responsible.position)}`,
|
||||||
|
'',
|
||||||
|
'RESUMEN',
|
||||||
|
...wrap(text(act.summary)),
|
||||||
|
'',
|
||||||
|
'OBSERVACIONES',
|
||||||
|
...wrap(text(act.observations)),
|
||||||
|
'',
|
||||||
|
'INVENTARIO INSPECCIONADO',
|
||||||
|
];
|
||||||
|
|
||||||
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
if (!inventories.length) out.push('-');
|
||||||
const section = (label: string) => {
|
for (const item of inventories) {
|
||||||
need(46);
|
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
||||||
doc.moveDown(0.9);
|
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(12).text(label.toUpperCase());
|
|
||||||
doc.moveTo(54, doc.y + 3).lineTo(540, doc.y + 3).strokeColor('#D5DCE8').stroke();
|
|
||||||
doc.moveDown(0.55);
|
|
||||||
};
|
|
||||||
const body = (value: unknown) => {
|
|
||||||
need(24);
|
|
||||||
doc.font('body').fillColor(ink).fontSize(10.2).text(text(value, '-'), { lineGap: 3 });
|
|
||||||
doc.moveDown(0.4);
|
|
||||||
};
|
|
||||||
const label = (name: string, value: unknown, allowEmpty = false) => {
|
|
||||||
const rendered = text(value);
|
|
||||||
if (!rendered && !allowEmpty) return;
|
|
||||||
need(24);
|
|
||||||
doc.font('body-bold').fillColor(ink).fontSize(9.8).text(`${name}: `, { continued: true });
|
|
||||||
doc.font('body').text(rendered || '-');
|
|
||||||
doc.moveDown(0.28);
|
|
||||||
};
|
|
||||||
const subheading = (value: string) => {
|
|
||||||
need(32);
|
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(10.8).text(value);
|
|
||||||
doc.moveDown(0.25);
|
|
||||||
};
|
|
||||||
const image = (entry: ActPdfImage, caption: string) => {
|
|
||||||
need(235);
|
|
||||||
const y = doc.y;
|
|
||||||
doc.image(entry.buffer, 58, y, { fit: [470, 190] });
|
|
||||||
doc.y = y + 195;
|
|
||||||
doc.font('body').fontSize(8).fillColor(muted).text(`${caption} · SHA-256 ${entry.sha256}`, 58, doc.y, { width: 475 });
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
};
|
|
||||||
|
|
||||||
doc.font('body-bold').fillColor(ink).fontSize(18.5).text(`ACTA DE INSPECCIÓN ${text(act.code)}`);
|
|
||||||
doc.moveDown(0.15);
|
|
||||||
doc.font('body').fillColor(muted).fontSize(9).text('Documento consolidado de actuación inspectiva');
|
|
||||||
|
|
||||||
section('1. Identificación del Acta');
|
|
||||||
label('Código del Acta', act.code);
|
|
||||||
if (act.actNumber || act.actYear) label('Número / Año', `${text(act.actNumber, '-')} / ${text(act.actYear, '-')}`);
|
|
||||||
label('Inspección', inspection.code);
|
|
||||||
label('Fecha y hora de actuación', date(act.occurredAt));
|
|
||||||
label('Estado documental', 'Sellada');
|
|
||||||
label('Versión de esquema', locked.schemaVersion ?? sealed.schemaVersion);
|
|
||||||
|
|
||||||
section('2. Contexto territorial y operativo');
|
|
||||||
label('Empresa / Operadora', company.name ?? fallbackContext.companyName);
|
|
||||||
label('Departamento', department.name ?? fallbackContext.departmentName);
|
|
||||||
label('Área', area.name ?? fallbackContext.areaName);
|
|
||||||
const pathYacimientos = inventories.flatMap((inventory) => asArray(inventory.path))
|
|
||||||
.filter((node) => text(node.typeCode).toLowerCase() === 'yacimiento');
|
|
||||||
const scopeTypeCode = text(scope.typeCode ?? fallbackContext.scopeTypeCode).toLowerCase();
|
|
||||||
const yacimientoNames = Array.from(new Set([
|
|
||||||
...(scopeTypeCode === 'yacimiento' ? [text(scope.name ?? fallbackContext.scopeName)] : []),
|
|
||||||
...pathYacimientos.map((node) => text(node.name)),
|
|
||||||
text(fallbackContext.yacimientoName),
|
|
||||||
].filter(Boolean)));
|
|
||||||
const yacimientoCodes = Array.from(new Set([
|
|
||||||
...(scopeTypeCode === 'yacimiento' ? [text(scope.code ?? fallbackContext.scopeCode)] : []),
|
|
||||||
...pathYacimientos.map((node) => text(node.code)),
|
|
||||||
text(fallbackContext.yacimientoCode),
|
|
||||||
].filter(Boolean)));
|
|
||||||
if (yacimientoNames.length) label(yacimientoNames.length > 1 ? 'Yacimientos' : 'Yacimiento', yacimientoNames.join(', '));
|
|
||||||
if (yacimientoCodes.length) label(yacimientoCodes.length > 1 ? 'Códigos de yacimiento' : 'Código de yacimiento', yacimientoCodes.join(', '));
|
|
||||||
if (scopeTypeCode && scopeTypeCode !== 'yacimiento') {
|
|
||||||
const scopeLabel = `${text(scope.typeName ?? fallbackContext.scopeTypeName)} - ${text(scope.name ?? fallbackContext.scopeName)}${text(scope.code ?? fallbackContext.scopeCode) ? ` [${text(scope.code ?? fallbackContext.scopeCode)}]` : ''}`;
|
|
||||||
label('Alcance de la inspección', scopeLabel);
|
|
||||||
}
|
|
||||||
label('Inicio efectivo de la inspección', date(inspection.actualStartedAt));
|
|
||||||
|
|
||||||
section('3. Intervinientes');
|
|
||||||
const leadName = fullName(leadInspector) || fallbackContext.leadInspectorName || '';
|
|
||||||
label('Inspector/a responsable', leadName);
|
|
||||||
const otherInspectors = inspectors.map(fullName).filter((name) => name && name !== leadName);
|
|
||||||
if (otherInspectors.length) label('Otros inspectores actuantes', otherInspectors.join(', '));
|
|
||||||
label('Situación del representante', text(responsible.attendanceStatus) === 'ABSENT' ? 'Ausente' : 'Presente');
|
|
||||||
label('Representante de la empresa', responsible.fullName);
|
|
||||||
const document = [text(responsible.documentType), text(responsible.documentNumber)].filter(Boolean).join(' ');
|
|
||||||
label('Documento', document);
|
|
||||||
label('Cargo o función', responsible.position);
|
|
||||||
label('Correo electrónico', responsible.email);
|
|
||||||
label('Teléfono', responsible.phone);
|
|
||||||
if (text(responsible.attendanceStatus) === 'ABSENT') label('Motivo de ausencia', responsible.absenceReason);
|
|
||||||
|
|
||||||
section('4. Datos del Acta');
|
|
||||||
label('Objeto / Denominación', act.title);
|
|
||||||
label('Urgencia', urgency(act.urgency));
|
|
||||||
if (act.deadlineDays != null) {
|
|
||||||
const duration = `${text(act.deadlineDays)} ${dayType(act.deadlineDayType)}`.trim();
|
|
||||||
label('Plazo', duration);
|
|
||||||
label('Cómputo del plazo', deadlineBasis(act.deadlineBasis));
|
|
||||||
if (act.deadlineBaseAt) label('Fecha base', date(act.deadlineBaseAt));
|
|
||||||
if (act.deadlineAt) label('Vencimiento', date(act.deadlineAt));
|
|
||||||
}
|
|
||||||
subheading('Descripción de lo actuado');
|
|
||||||
if (text(act.summary) && !isPlaceholder(act.summary)) body(act.summary);
|
|
||||||
else body(`Se realizó la inspección ${text(inspection.code)}. El contenido constatado se detalla en los hallazgos registrados a continuación.`);
|
|
||||||
if (text(act.observations)) {
|
|
||||||
subheading('Observaciones generales');
|
|
||||||
body(act.observations);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section('5. Hallazgos');
|
out.push('', 'HALLAZGOS');
|
||||||
if (!findings.length) body('No se registraron hallazgos en esta Acta.');
|
if (!findings.length) out.push('Sin hallazgos registrados.');
|
||||||
for (const finding of findings) {
|
for (const item of findings) {
|
||||||
const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {};
|
const recurrence = item.isRecurrence
|
||||||
const catalog = asRecord(finding.catalog);
|
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
||||||
const recurrence = asRecord(finding.recurrence);
|
: '';
|
||||||
const path = asArray(inventory.path);
|
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
||||||
const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > ');
|
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
||||||
const number = text(finding.findingNumber, '?');
|
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
||||||
need(100);
|
|
||||||
doc.font('body-bold').fillColor(blue).fontSize(12).text(`HALLAZGO N° ${number} · ${text(finding.code)}`);
|
|
||||||
doc.moveDown(0.2);
|
|
||||||
label('Elemento afectado', `${text(inventory.typeName)} - ${text(inventory.name)}${text(inventory.code) ? ` [${text(inventory.code)}]` : ''}`);
|
|
||||||
if (text(inventory.installationTypeName)) label('Tipo de instalación', inventory.installationTypeName);
|
|
||||||
if (route) label('Ubicación / Ruta jerárquica', route);
|
|
||||||
label('Denominación del hallazgo', finding.title);
|
|
||||||
subheading('Qué se constató');
|
|
||||||
body(finding.description);
|
|
||||||
if (text(catalog.id)) {
|
|
||||||
const source = [text(catalog.categoryName), text(catalog.title), text(catalog.code), catalog.sourceNumber ? `Ítem ${text(catalog.sourceNumber)}` : ''].filter(Boolean).join(' · ');
|
|
||||||
label('Referencia de catálogo', source);
|
|
||||||
} else {
|
|
||||||
label('Referencia de catálogo', 'OTROS / hallazgo cargado en campo');
|
|
||||||
}
|
|
||||||
if (text(finding.glossary)) label('Criterio / Referencia técnica', finding.glossary);
|
|
||||||
if (text(finding.legalBasis)) label('Normativa / Base legal', finding.legalBasis);
|
|
||||||
if (finding.severity != null) label('Gravedad', `${text(finding.severity)}/10`);
|
|
||||||
if (finding.isRecurrence) {
|
|
||||||
label('Reincidencia', 'Sí');
|
|
||||||
if (text(recurrence.code) || text(recurrence.title)) label('Antecedente relacionado', `${text(recurrence.code)}${text(recurrence.title) ? ` - ${text(recurrence.title)}` : ''}`);
|
|
||||||
} else {
|
|
||||||
label('Reincidencia', 'No');
|
|
||||||
}
|
|
||||||
const photos = images.filter((item) => item.findingId === text(finding.id));
|
|
||||||
if (photos.length) {
|
|
||||||
subheading(`Evidencia fotográfica (${photos.length})`);
|
|
||||||
for (const photo of photos) image(photo, `Fotografía del hallazgo ${text(finding.code)}${photo.capturedAt ? ` · ${date(photo.capturedAt)}` : ''}`);
|
|
||||||
} else {
|
|
||||||
label('Evidencia fotográfica', 'Sin fotografías asociadas');
|
|
||||||
}
|
|
||||||
doc.moveDown(0.7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section('6. Firmas y manifestaciones');
|
out.push('', 'FIRMAS Y CONSTANCIAS');
|
||||||
for (const signature of signatures) {
|
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
||||||
const name = text(signature.signerName);
|
for (const signature of inspectorSignatures) {
|
||||||
const inspector = text(signature.signerType) === 'INSPECTOR';
|
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
||||||
const role = inspector ? 'Inspector/a' : 'Representante de la empresa';
|
}
|
||||||
subheading(`${role}: ${name}`);
|
if (!companySignature) {
|
||||||
const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' ');
|
out.push('Manifestacion de empresa: pendiente.');
|
||||||
if (signedDocument) label('Documento', signedDocument);
|
} else if (text(companySignature.status, '') === 'SIGNED') {
|
||||||
label('Cargo o función', signature.position);
|
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
||||||
label('Resultado', signatureStatus(signature.status));
|
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad');
|
||||||
if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation));
|
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||||
if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement);
|
} else {
|
||||||
if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.reason);
|
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
||||||
label('Fecha y hora de la constancia', date(signature.signedAt ?? signature.createdAt));
|
|
||||||
if (text(signature.source)) label('Origen de la constancia', text(signature.source) === 'ANDROID' ? 'Aplicación móvil' : 'Dashboard web');
|
|
||||||
const signatureImage = images.find((item) => item.signerName === name && item.sha256 === text(signature.imageSha256));
|
|
||||||
if (signatureImage) {
|
|
||||||
need(100);
|
|
||||||
const y = doc.y;
|
|
||||||
doc.image(signatureImage.buffer, 60, y, { fit: [230, 60] });
|
|
||||||
doc.y = y + 66;
|
|
||||||
}
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
section('7. Integridad y cierre');
|
out.push(
|
||||||
label('Contenido bloqueado', date(locked.lockedAt));
|
'',
|
||||||
label('Sellado en servidor', date(seal.serverSealedAt));
|
'INTEGRIDAD',
|
||||||
label('Fecha informada por dispositivo', date(seal.deviceSealedAt));
|
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
||||||
label('Modo de cierre', seal.uploadMode);
|
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
||||||
body(`SHA-256 del cierre: ${hash}`);
|
);
|
||||||
body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`);
|
return out;
|
||||||
need(28);
|
}
|
||||||
doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y);
|
|
||||||
|
|
||||||
doc.end();
|
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
||||||
const buffer = await done;
|
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from(`${id} 0 obj\n`, 'ascii'),
|
||||||
|
data,
|
||||||
|
Buffer.from('\nendobj\n', 'ascii'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
||||||
|
const all = lines(snapshot);
|
||||||
|
const chunks: Array<string[]> = [];
|
||||||
|
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
||||||
|
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||||
|
|
||||||
|
const pageCount = chunks.length;
|
||||||
|
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
||||||
|
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
||||||
|
const objects: Buffer[] = [];
|
||||||
|
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
||||||
|
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
||||||
|
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||||
|
|
||||||
|
chunks.forEach((chunk, index) => {
|
||||||
|
const content = chunk
|
||||||
|
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
|
||||||
|
.join('\n');
|
||||||
|
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
|
||||||
|
objects.push(objectBuffer(
|
||||||
|
pageIds[index]!,
|
||||||
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
|
||||||
|
));
|
||||||
|
objects.push(objectBuffer(
|
||||||
|
contentIds[index]!,
|
||||||
|
Buffer.concat([
|
||||||
|
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
|
||||||
|
stream,
|
||||||
|
Buffer.from('\nendstream', 'ascii'),
|
||||||
|
]),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
||||||
|
const offsets: number[] = [0];
|
||||||
|
let position = header.length;
|
||||||
|
for (const object of objects) {
|
||||||
|
offsets.push(position);
|
||||||
|
position += object.length;
|
||||||
|
}
|
||||||
|
const xrefOffset = position;
|
||||||
|
const xref = [
|
||||||
|
`xref\n0 ${objects.length + 1}\n`,
|
||||||
|
'0000000000 65535 f \n',
|
||||||
|
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
|
||||||
|
].join('');
|
||||||
|
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||||
|
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
|
||||||
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import { isAbsolute, parse, resolve } from 'node:path';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } from './inspection-act-pdf-builder';
|
import { buildInspectionActPdf } from './inspection-act-pdf-builder';
|
||||||
import { renderableInspectionImage } from './inspection-document-images';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InspectionActPdfService {
|
export class InspectionActPdfService {
|
||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
private readonly evidenceRoot: string;
|
|
||||||
private readonly assetRoot: string;
|
|
||||||
private readonly signatureRoot: string;
|
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||||
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
|
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
|
||||||
@@ -20,9 +16,6 @@ export class InspectionActPdfService {
|
|||||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
|
||||||
this.root = resolve(configured);
|
this.root = resolve(configured);
|
||||||
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
|
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
|
||||||
this.evidenceRoot = resolve(config.get<string>('INSPECTION_EVIDENCE_ROOT') ?? '/app/storage/asset-media/inspection-findings');
|
|
||||||
this.assetRoot = resolve(config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media');
|
|
||||||
this.signatureRoot = resolve(config.get<string>('INSPECTION_SIGNATURE_ROOT') ?? '/app/storage/asset-media/inspection-signatures');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensure(actId: string): Promise<void> {
|
async ensure(actId: string): Promise<void> {
|
||||||
@@ -66,7 +59,7 @@ export class InspectionActPdfService {
|
|||||||
`, [actId]);
|
`, [actId]);
|
||||||
try {
|
try {
|
||||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||||
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
|
const built = buildInspectionActPdf(snapshot);
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${actId}.pdf`;
|
const storedName = `${actId}.pdf`;
|
||||||
const originalName = `${row.code}.pdf`;
|
const originalName = `${row.code}.pdf`;
|
||||||
@@ -89,154 +82,6 @@ export class InspectionActPdfService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The original sealed PDF remains immutable for historic delivery and audit.
|
|
||||||
// A consolidated presentation is stored separately and frozen at its first generation.
|
|
||||||
async consolidatedContent(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
|
||||||
const [existing] = await this.dataSource.query(`
|
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
|
||||||
size_bytes AS "sizeBytes",sha256
|
|
||||||
FROM inspection_act_consolidated_pdf_revisions
|
|
||||||
WHERE act_id=$1 AND template_version=3
|
|
||||||
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (existing) {
|
|
||||||
const buffer = await this.verifiedImage(this.root, existing);
|
|
||||||
return { buffer, originalName: existing.originalName, mimeType: 'application/pdf' };
|
|
||||||
}
|
|
||||||
const [row] = await this.dataSource.query(`
|
|
||||||
SELECT act.code,act.closure_sha256 AS "closureSha256",closure.final_snapshot AS "finalSnapshot"
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_act_closures closure ON closure.act_id=act.id
|
|
||||||
WHERE act.id=$1 AND act.status='SEALED'
|
|
||||||
`, [actId]) as Array<{ code: string; closureSha256: string; finalSnapshot: Record<string, unknown> }>;
|
|
||||||
if (!row) throw new NotFoundException({
|
|
||||||
code: 'INSPECTION_ACT_NOT_SEALED',
|
|
||||||
message: 'El Acta debe estar firmada y sellada para generar el documento consolidado',
|
|
||||||
});
|
|
||||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
|
||||||
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
|
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
||||||
const storedName = `${actId}-consolidado-${built.sha256.slice(0, 24)}.pdf`;
|
|
||||||
const originalName = `${row.code}-consolidada.pdf`;
|
|
||||||
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code !== 'EEXIST') throw error;
|
|
||||||
const previous = await this.verifiedImage(this.root, {
|
|
||||||
storedName, sizeBytes: built.buffer.length, sha256: built.sha256,
|
|
||||||
});
|
|
||||||
if (!previous.equals(built.buffer)) throw this.storageError();
|
|
||||||
});
|
|
||||||
await this.dataSource.query(`
|
|
||||||
INSERT INTO inspection_act_consolidated_pdf_revisions(act_id,template_version,stored_name,original_name,size_bytes,sha256)
|
|
||||||
VALUES($1,3,$2,$3,$4,$5) ON CONFLICT (act_id,template_version) DO NOTHING
|
|
||||||
`, [actId, storedName, originalName, built.buffer.length, built.sha256]);
|
|
||||||
const [saved] = await this.dataSource.query(`
|
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
|
||||||
size_bytes AS "sizeBytes",sha256
|
|
||||||
FROM inspection_act_consolidated_pdf_revisions WHERE act_id=$1 AND template_version=3
|
|
||||||
`, [actId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (!saved) throw this.storageError();
|
|
||||||
return {
|
|
||||||
buffer: await this.verifiedImage(this.root, saved),
|
|
||||||
originalName: saved.originalName, mimeType: 'application/pdf',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async consolidatedRevisionContent(actId: string, version: number): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
|
||||||
if (![1, 2, 3].includes(version)) throw new NotFoundException('Versión documental inexistente');
|
|
||||||
const [row] = await this.dataSource.query(`
|
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
|
||||||
size_bytes AS "sizeBytes",sha256
|
|
||||||
FROM inspection_act_consolidated_pdf_revisions
|
|
||||||
WHERE act_id=$1 AND template_version=$2
|
|
||||||
`, [actId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (!row) throw new NotFoundException('Versión documental inexistente');
|
|
||||||
return { buffer: await this.verifiedImage(this.root, row), originalName: row.originalName, mimeType: 'application/pdf' };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async actContext(actId: string): Promise<ActPdfContext> {
|
|
||||||
const [row] = await this.dataSource.query(`
|
|
||||||
SELECT company.name AS "companyName",department.name AS "departmentName",area.name AS "areaName",
|
|
||||||
scope.name AS "scopeName",scope.code AS "scopeCode",scope_type.name AS "scopeTypeName",scope_type.code AS "scopeTypeCode",
|
|
||||||
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.name END,yacimiento_from_findings.name) AS "yacimientoName",
|
|
||||||
COALESCE(CASE WHEN lower(scope_type.code)='yacimiento' THEN scope.code END,yacimiento_from_findings.code) AS "yacimientoCode",
|
|
||||||
btrim(concat_ws(' ',lead.first_name,lead.last_name)) AS "leadInspectorName"
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
|
||||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
|
||||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
|
||||||
LEFT JOIN assets department ON department.id=area.parent_id
|
|
||||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
|
||||||
LEFT JOIN asset_types scope_type ON scope_type.id=scope.asset_type_id
|
|
||||||
LEFT JOIN users lead ON lead.id=visit.lead_inspector_user_id
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
WITH RECURSIVE ancestors AS (
|
|
||||||
SELECT asset.id,asset.parent_id,asset.code,asset.name,asset.asset_type_id
|
|
||||||
FROM inspection_findings finding JOIN assets asset ON asset.id=finding.asset_id
|
|
||||||
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
|
|
||||||
UNION
|
|
||||||
SELECT parent.id,parent.parent_id,parent.code,parent.name,parent.asset_type_id
|
|
||||||
FROM ancestors JOIN assets parent ON parent.id=ancestors.parent_id
|
|
||||||
)
|
|
||||||
SELECT string_agg(DISTINCT ancestors.name, ', ' ORDER BY ancestors.name) AS name,
|
|
||||||
string_agg(DISTINCT ancestors.code, ', ' ORDER BY ancestors.code) AS code
|
|
||||||
FROM ancestors JOIN asset_types ancestor_type ON ancestor_type.id=ancestors.asset_type_id
|
|
||||||
WHERE lower(ancestor_type.code)='yacimiento'
|
|
||||||
) yacimiento_from_findings ON true
|
|
||||||
WHERE act.id=$1
|
|
||||||
`, [actId]) as ActPdfContext[];
|
|
||||||
return row ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifiedImage(root: string, row: { storedName: string; sha256: string; sizeBytes: number }): Promise<Buffer> {
|
|
||||||
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw new Error('Ruta inválida de evidencia del Acta');
|
|
||||||
const path = resolve(root, row.storedName);
|
|
||||||
if (!path.startsWith(`${root}/`)) throw new Error('Ruta inválida de evidencia del Acta');
|
|
||||||
const file = await stat(path);
|
|
||||||
if (!file.isFile() || file.size !== Number(row.sizeBytes)) throw new Error('Evidencia incompleta del Acta');
|
|
||||||
const buffer = await readFile(path);
|
|
||||||
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw new Error('Hash de evidencia distinto del registrado');
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fieldImages(actId: string, includeAssetPhotos = true): Promise<ActPdfImage[]> {
|
|
||||||
type ImageRow = { id: string; findingId?: string; assetId?: string; signerName?: string; title?: string; capturedAt?: Date; storedName: string; sha256: string; sizeBytes: number };
|
|
||||||
const findings = await this.dataSource.query(`
|
|
||||||
SELECT evidence.id, finding.id AS "findingId", evidence.title,
|
|
||||||
evidence.captured_at AS "capturedAt", evidence.stored_name AS "storedName",
|
|
||||||
evidence.sha256, evidence.size_bytes AS "sizeBytes"
|
|
||||||
FROM inspection_findings finding
|
|
||||||
JOIN inspection_acts act ON act.id=finding.act_id
|
|
||||||
JOIN inspection_finding_evidence evidence ON evidence.finding_id=finding.id
|
|
||||||
WHERE act.id=$1 AND evidence.kind='PHOTO' AND evidence.purpose='OBSERVATION'
|
|
||||||
AND evidence.created_at<=act.locked_at
|
|
||||||
ORDER BY finding.finding_number,evidence.captured_at,evidence.id
|
|
||||||
`, [actId]) as ImageRow[];
|
|
||||||
const assets = includeAssetPhotos ? await this.dataSource.query(`
|
|
||||||
SELECT media.id,asset.id AS "assetId", asset.name AS title,
|
|
||||||
capture.device_captured_at AS "capturedAt",media.stored_name AS "storedName",
|
|
||||||
media.sha256,media.size_bytes AS "sizeBytes"
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true
|
|
||||||
JOIN assets asset ON asset.id=link.asset_id
|
|
||||||
JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id
|
|
||||||
AND capture.asset_id=asset.id AND capture.event_type='PHOTO'
|
|
||||||
JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL AND media.kind='PHOTO'
|
|
||||||
WHERE act.id=$1 AND capture.created_at<=act.locked_at
|
|
||||||
ORDER BY capture.device_captured_at,media.id
|
|
||||||
`, [actId]) as ImageRow[] : [];
|
|
||||||
const signatures = await this.dataSource.query(`
|
|
||||||
SELECT signature.id,signature.signer_name AS "signerName",signature.stored_name AS "storedName",
|
|
||||||
signature.image_sha256 AS sha256,signature.size_bytes AS "sizeBytes"
|
|
||||||
FROM inspection_act_signatures signature WHERE signature.act_id=$1
|
|
||||||
AND signature.status='SIGNED' AND signature.stored_name IS NOT NULL
|
|
||||||
ORDER BY signature.created_at,signature.id
|
|
||||||
`, [actId]) as ImageRow[];
|
|
||||||
const result: ActPdfImage[] = [];
|
|
||||||
for (const row of findings) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.evidenceRoot, row)) });
|
|
||||||
for (const row of assets) result.push({ ...row, buffer: await renderableInspectionImage(await this.verifiedImage(this.assetRoot, row)) });
|
|
||||||
for (const row of signatures) result.push({ ...row, buffer: await this.verifiedImage(this.signatureRoot, row) });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT original_name AS "originalName",stored_name AS "storedName",
|
SELECT original_name AS "originalName",stored_name AS "storedName",
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export class InspectionDocumentDeliveryService {
|
|||||||
email = company?.email ?? null;
|
email = company?.email ?? null;
|
||||||
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||||
const [inspector] = await this.dataSource.query(`
|
const [inspector] = await this.dataSource.query(`
|
||||||
SELECT email FROM users WHERE id=$1 AND status='ACTIVE'
|
SELECT email FROM users WHERE id=$1 AND is_active=true
|
||||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||||
email = inspector?.email ?? null;
|
email = inspector?.email ?? null;
|
||||||
} else {
|
} else {
|
||||||
@@ -305,14 +305,7 @@ export class InspectionDocumentDeliveryService {
|
|||||||
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
|
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [sender] = await this.dataSource.query(`
|
if (!await this.smtp.configured()) {
|
||||||
SELECT visit.lead_inspector_user_id AS "userId"
|
|
||||||
FROM inspection_acts act
|
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
|
||||||
WHERE act.id=$1
|
|
||||||
`, [row.actId]) as Array<{ userId: string | null }>;
|
|
||||||
const senderUserId = sender?.userId ?? undefined;
|
|
||||||
if (!await this.smtp.configured(senderUserId)) {
|
|
||||||
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
|
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,7 +348,7 @@ export class InspectionDocumentDeliveryService {
|
|||||||
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
|
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
|
||||||
const sent = await this.smtp.send({
|
const sent = await this.smtp.send({
|
||||||
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
|
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
|
||||||
}, senderUserId);
|
});
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
const sharp = require('sharp') as typeof import('sharp').default;
|
|
||||||
|
|
||||||
// PDFKit and the Word image renderer accept JPEG/PNG. The source bytes and
|
|
||||||
// their recorded SHA-256 remain untouched; WebP is only converted for display.
|
|
||||||
export async function renderableInspectionImage(source: Buffer): Promise<Buffer> {
|
|
||||||
const isWebp = source.length >= 12
|
|
||||||
&& source.subarray(0, 4).toString('ascii') === 'RIFF'
|
|
||||||
&& source.subarray(8, 12).toString('ascii') === 'WEBP';
|
|
||||||
if (!isWebp) return source;
|
|
||||||
return sharp(source)
|
|
||||||
.resize({ width: 1600, height: 1200, fit: 'inside', withoutEnlargement: true })
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
interface ZipEntry {
|
interface ZipEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,10 +13,6 @@ interface ReportWordInput {
|
|||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
executiveSummary?: string | null;
|
executiveSummary?: string | null;
|
||||||
reportDescription?: string | null;
|
reportDescription?: string | null;
|
||||||
companyName?: string | null;
|
|
||||||
areaName?: string | null;
|
|
||||||
scopeName?: string | null;
|
|
||||||
photos?: Array<{ id: string; findingId?: string; assetId?: string; title?: string; sha256: string; buffer: Buffer }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const crcTable = (() => {
|
const crcTable = (() => {
|
||||||
@@ -74,7 +68,7 @@ function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function labelValue(label: string, value: string): string {
|
function labelValue(label: string, value: string): string {
|
||||||
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
return `<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${xmlEscape(label)}: </w:t></w:r><w:r><w:t xml:space="preserve">${xmlEscape(value)}</w:t></w:r></w:p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function table(headers: string[], rows: string[][]): string {
|
function table(headers: string[], rows: string[][]): string {
|
||||||
@@ -108,93 +102,73 @@ function deadline(act: Record<string, unknown>): string {
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageDimensions(buffer: Buffer): { width: number; height: number } {
|
function documentXml(input: ReportWordInput): string {
|
||||||
if (buffer.subarray(0, 4).toString('hex') === '89504e47') return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
||||||
let offset = 2;
|
|
||||||
while (offset + 9 < buffer.length) {
|
|
||||||
if (buffer[offset] !== 0xff) break;
|
|
||||||
const marker = buffer[offset + 1]!;
|
|
||||||
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
|
|
||||||
const size = buffer.readUInt16BE(offset + 2);
|
|
||||||
if (size < 2) break;
|
|
||||||
offset += size + 2;
|
|
||||||
}
|
|
||||||
return { width: 800, height: 500 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawing(relationship: number, image: Buffer, name: string, maxWidth = 4572000, maxHeight = 2057400, right = false): string {
|
|
||||||
const dimensions = imageDimensions(image);
|
|
||||||
const scale = Math.min(maxWidth / dimensions.width, maxHeight / dimensions.height);
|
|
||||||
const cx = Math.round(dimensions.width * scale);
|
|
||||||
const cy = Math.round(dimensions.height * scale);
|
|
||||||
const alt = xmlEscape(name);
|
|
||||||
return `<w:p>${right ? '<w:pPr><w:jc w:val="right"/></w:pPr>' : ''}<w:r><w:drawing><wp:inline><wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relationship}" name="${alt}" descr="${alt}"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="${alt}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId${relationship}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function documentXml(input: ReportWordInput, logo: Buffer): string {
|
|
||||||
const snapshot = f4Snapshot(input);
|
const snapshot = f4Snapshot(input);
|
||||||
const photos = input.photos ?? [];
|
const findingRows = snapshot.findings.map((item) => [
|
||||||
const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
|
text(item.code),
|
||||||
const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
|
text(item.title),
|
||||||
const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`;
|
text(item.description),
|
||||||
const entries: string[] = [
|
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
||||||
paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
|
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
||||||
paragraph('DIRECCIÓN DE HIDROCARBUROS'),
|
]);
|
||||||
drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
|
const inventoryRows = snapshot.inventories.map((item) => [
|
||||||
paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
|
text(item.code),
|
||||||
paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
|
text(item.name),
|
||||||
paragraph('Sr. Director de Hidrocarburos'),
|
text(item.typeName ?? item.typeCode),
|
||||||
labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
|
]);
|
||||||
labelValue('Inspector/a', authors || 'No consignado'),
|
const signatureRows = snapshot.signatures.map((item) => [
|
||||||
labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
|
text(item.signerType),
|
||||||
|
text(item.signerName),
|
||||||
|
text(item.status),
|
||||||
|
text(item.companyManifestation, ''),
|
||||||
|
isoDate(item.signedAt ?? item.createdAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const executive = input.executiveSummary?.trim()
|
||||||
|
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
|
||||||
|
const description = input.reportDescription?.trim()
|
||||||
|
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
||||||
|
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
|
||||||
|
labelValue('Informe', input.code),
|
||||||
|
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||||
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
||||||
labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
|
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
|
||||||
labelValue('Empresa inspeccionada', text(input.companyName)),
|
labelValue('Urgencia', urgency(snapshot.act.urgency)),
|
||||||
paragraph('OBJETIVOS', 'Heading1'),
|
labelValue('Plazo', deadline(snapshot.act)),
|
||||||
labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
|
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
||||||
labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
|
paragraph('Resumen ejecutivo', 'Heading1'),
|
||||||
paragraph('MARCO LEGAL', 'Heading1'),
|
paragraph(executive),
|
||||||
...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
|
paragraph('Descripción / análisis técnico', 'Heading1'),
|
||||||
paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
|
paragraph(description),
|
||||||
paragraph(input.reportDescription?.trim() || `Según el Acta ${text(snapshot.act.code)}, se documentaron ${quantity} durante la inspección. Se detallan las observaciones y evidencias consignadas a continuación.`),
|
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
||||||
paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
|
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
||||||
];
|
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
||||||
if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
|
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
||||||
const shownPhotos = new Set<number>();
|
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
|
||||||
for (const finding of snapshot.findings) {
|
labelValue('Cargo', text(snapshot.responsible.position)),
|
||||||
entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
|
paragraph('Inventario inspeccionado', 'Heading1'),
|
||||||
entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
|
inventoryRows.length
|
||||||
entries.push(paragraph(text(finding.description)));
|
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
||||||
if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
|
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
||||||
if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
|
paragraph('Hallazgos', 'Heading1'),
|
||||||
for (let index = 0; index < photos.length; index++) {
|
findingRows.length
|
||||||
const photo = photos[index]!;
|
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
||||||
if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
|
: paragraph('El Acta no contiene Hallazgos.'),
|
||||||
shownPhotos.add(index);
|
paragraph('Firmas y manifestaciones', 'Heading1'),
|
||||||
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
|
signatureRows.length
|
||||||
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
|
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
||||||
}
|
: paragraph('No se registraron firmas en la instantánea sellada.'),
|
||||||
}
|
paragraph('Integridad de la fuente', 'Heading1'),
|
||||||
const otherPhotos = photos.map((photo, index) => ({ photo, index }))
|
labelValue('Hash de la fuente del INF', input.frozenSha256),
|
||||||
.filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index));
|
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
||||||
if (otherPhotos.length) {
|
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
|
||||||
entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1'));
|
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
|
||||||
for (const { photo, index } of otherPhotos) {
|
].join('');
|
||||||
entries.push(labelValue('Instalación', text(photo.title)));
|
|
||||||
entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación'));
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
||||||
entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries.push(
|
|
||||||
paragraph('CONCLUSIONES', 'Heading1'),
|
|
||||||
paragraph(input.executiveSummary?.trim() || `La inspección registró ${quantity}. Su seguimiento y la respuesta de la empresa se documentan en el Informe.`),
|
|
||||||
paragraph('ACTA FUENTE E INTEGRIDAD', 'Heading1'),
|
|
||||||
labelValue('Acta sellada', text(snapshot.source.actCode ?? snapshot.act.code)),
|
|
||||||
labelValue('SHA-256 del cierre del Acta', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
|
||||||
labelValue('SHA-256 de la fuente del Informe', input.frozenSha256),
|
|
||||||
);
|
|
||||||
const body = entries.join('');
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildZip(entries: ZipEntry[]): Buffer {
|
function buildZip(entries: ZipEntry[]): Buffer {
|
||||||
@@ -256,13 +230,10 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
|
export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buffer; sha256: string } {
|
||||||
const logo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
|
|
||||||
const photos = input.photos ?? [];
|
|
||||||
const mediaRelationships = photos.map((photo, index) => `<Relationship Id="rId${index + 3}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}"/>`).join('');
|
|
||||||
const entries: ZipEntry[] = [
|
const entries: ZipEntry[] = [
|
||||||
{
|
{
|
||||||
name: '[Content_Types].xml',
|
name: '[Content_Types].xml',
|
||||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>', 'utf8'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '_rels/.rels',
|
name: '_rels/.rels',
|
||||||
@@ -270,15 +241,13 @@ export function buildInspectionReportWord(input: ReportWordInput): { buffer: Buf
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'word/_rels/document.xml.rels',
|
name: 'word/_rels/document.xml.rels',
|
||||||
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</Relationships>`, 'utf8'),
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>', 'utf8'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'word/styles.xml',
|
name: 'word/styles.xml',
|
||||||
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:sz w:val="20"/></w:rPr><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr><w:pPr><w:spacing w:before="160" w:after="180"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="26"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="260" w:after="130"/></w:pPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr><w:pPr><w:keepNext/><w:spacing w:before="180" w:after="100"/></w:pPr></w:style></w:styles>', 'utf8'),
|
data: Buffer.from('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><w:sz w:val="20"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="34"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="28"/></w:rPr></w:style><w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:rPr><w:b/><w:sz w:val="22"/></w:rPr></w:style></w:styles>', 'utf8'),
|
||||||
},
|
},
|
||||||
{ name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), 'utf8') },
|
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') },
|
||||||
{ name: 'word/media/logo-mendoza.png', data: logo },
|
|
||||||
...photos.map((photo, index) => ({ name: `word/media/photo-${index + 1}.${photo.buffer.subarray(0,4).toString('hex') === '89504e47' ? 'png' : 'jpg'}`, data: photo.buffer })),
|
|
||||||
{
|
{
|
||||||
name: 'docProps/core.xml',
|
name: 'docProps/core.xml',
|
||||||
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
|
data: Buffer.from(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${xmlEscape(input.title)}</dc:title><dc:creator>DH Inspección</dc:creator><cp:lastModifiedBy>DH Inspección</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${input.generatedAt.toISOString()}</dcterms:created></cp:coreProperties>`, 'utf8'),
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ import { Injectable, InternalServerErrorException, NotFoundException } from '@ne
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { buildInspectionReportWord } from './inspection-report-word-builder';
|
import { buildInspectionReportWord } from './inspection-report-word-builder';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
|
||||||
|
|
||||||
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
const WORD_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
|
||||||
interface WordRow {
|
interface WordRow {
|
||||||
id: string;
|
id: string;
|
||||||
actId: string;
|
|
||||||
companyName: string | null;
|
|
||||||
areaName: string | null;
|
|
||||||
scopeName: string | null;
|
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
executiveSummary: string | null;
|
executiveSummary: string | null;
|
||||||
@@ -35,7 +30,7 @@ interface WordRow {
|
|||||||
export class InspectionReportWordService {
|
export class InspectionReportWordService {
|
||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, config: ConfigService) {
|
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
|
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
|
||||||
?? '/app/storage/asset-media/inspection-reports-word';
|
?? '/app/storage/asset-media/inspection-reports-word';
|
||||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
||||||
@@ -53,7 +48,6 @@ export class InspectionReportWordService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
|
|
||||||
const built = buildInspectionReportWord({
|
const built = buildInspectionReportWord({
|
||||||
code: row.code,
|
code: row.code,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
@@ -62,10 +56,6 @@ export class InspectionReportWordService {
|
|||||||
frozenSnapshot: row.frozenSnapshot,
|
frozenSnapshot: row.frozenSnapshot,
|
||||||
executiveSummary: row.executiveSummary,
|
executiveSummary: row.executiveSummary,
|
||||||
reportDescription: row.reportDescription,
|
reportDescription: row.reportDescription,
|
||||||
companyName: row.companyName,
|
|
||||||
areaName: row.areaName,
|
|
||||||
scopeName: row.scopeName,
|
|
||||||
photos,
|
|
||||||
});
|
});
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
||||||
@@ -124,69 +114,6 @@ export class InspectionReportWordService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The prior editable Word and its revision history stay available unchanged.
|
|
||||||
async consolidatedContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
|
||||||
const [existing] = await this.dataSource.query(`
|
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
|
||||||
size_bytes AS "sizeBytes",sha256
|
|
||||||
FROM inspection_report_consolidated_word_revisions
|
|
||||||
WHERE report_id=$1 AND template_version=2
|
|
||||||
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (existing) return this.verifiedConsolidated(existing);
|
|
||||||
const row = await this.load(reportId);
|
|
||||||
const photos = (await this.actPdf.fieldImages(row.actId)).filter((image) => image.findingId || image.assetId);
|
|
||||||
const built = buildInspectionReportWord({
|
|
||||||
code: row.code, title: row.title, generatedAt: new Date(row.generatedAt),
|
|
||||||
frozenSha256: row.frozenSha256, frozenSnapshot: row.frozenSnapshot,
|
|
||||||
executiveSummary: row.executiveSummary, reportDescription: row.reportDescription,
|
|
||||||
companyName: row.companyName, areaName: row.areaName, scopeName: row.scopeName, photos,
|
|
||||||
});
|
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
||||||
const storedName = `${row.id}-consolidado-${built.sha256.slice(0, 24)}.docx`;
|
|
||||||
const originalName = `${row.code}-consolidado.docx`;
|
|
||||||
await writeFile(resolve(this.root, storedName), built.buffer, { flag: 'wx', mode: 0o600 }).catch(async (error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code !== 'EEXIST') throw error;
|
|
||||||
const previous = await this.verifiedConsolidated({
|
|
||||||
storedName, originalName, sizeBytes: built.buffer.length, sha256: built.sha256,
|
|
||||||
});
|
|
||||||
if (!(await readFile(previous.filePath)).equals(built.buffer)) throw this.storageError();
|
|
||||||
});
|
|
||||||
await this.dataSource.query(`
|
|
||||||
INSERT INTO inspection_report_consolidated_word_revisions(report_id,template_version,stored_name,original_name,size_bytes,sha256)
|
|
||||||
VALUES($1,2,$2,$3,$4,$5) ON CONFLICT (report_id,template_version) DO NOTHING
|
|
||||||
`, [reportId, storedName, originalName, built.buffer.length, built.sha256]);
|
|
||||||
const [saved] = await this.dataSource.query(`
|
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
|
||||||
size_bytes AS "sizeBytes",sha256
|
|
||||||
FROM inspection_report_consolidated_word_revisions WHERE report_id=$1 AND template_version=2
|
|
||||||
`, [reportId]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (!saved) throw this.storageError();
|
|
||||||
return this.verifiedConsolidated(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async consolidatedRevisionContent(reportId: string, version: number): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
|
||||||
if (![1, 2].includes(version)) throw new NotFoundException('Versión documental inexistente');
|
|
||||||
const [row] = await this.dataSource.query(`
|
|
||||||
SELECT stored_name AS "storedName",original_name AS "originalName",
|
|
||||||
size_bytes AS "sizeBytes",sha256
|
|
||||||
FROM inspection_report_consolidated_word_revisions
|
|
||||||
WHERE report_id=$1 AND template_version=$2
|
|
||||||
`, [reportId, version]) as Array<{ storedName: string; originalName: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (!row) throw new NotFoundException('Versión documental inexistente');
|
|
||||||
return this.verifiedConsolidated(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifiedConsolidated(row: { storedName: string; originalName: string; sizeBytes: number; sha256: string }): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
|
||||||
if (!/^[A-Za-z0-9_.-]+$/.test(row.storedName)) throw this.storageError();
|
|
||||||
const filePath = resolve(this.root, row.storedName);
|
|
||||||
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
|
||||||
const file = await stat(filePath).catch(() => null);
|
|
||||||
if (!file?.isFile() || file.size !== Number(row.sizeBytes)) throw this.storageError();
|
|
||||||
const buffer = await readFile(filePath);
|
|
||||||
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
|
|
||||||
return { filePath, originalName: row.originalName, mimeType: WORD_MIME };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
||||||
if (
|
if (
|
||||||
row.wordStatus !== 'READY'
|
row.wordStatus !== 'READY'
|
||||||
@@ -223,26 +150,21 @@ export class InspectionReportWordService {
|
|||||||
|
|
||||||
private async load(reportId: string): Promise<WordRow> {
|
private async load(reportId: string): Promise<WordRow> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT report.id,report.act_id AS "actId",report.code,report.title,
|
SELECT id,code,title,
|
||||||
company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
|
executive_summary AS "executiveSummary",
|
||||||
report.executive_summary AS "executiveSummary",
|
report_description AS "reportDescription",
|
||||||
report.report_description AS "reportDescription",
|
generated_at AS "generatedAt",
|
||||||
report.generated_at AS "generatedAt",
|
frozen_sha256 AS "frozenSha256",
|
||||||
report.frozen_sha256 AS "frozenSha256",
|
frozen_snapshot AS "frozenSnapshot",
|
||||||
report.frozen_snapshot AS "frozenSnapshot",
|
word_status AS "wordStatus",
|
||||||
report.word_status AS "wordStatus",
|
word_original_name AS "wordOriginalName",
|
||||||
report.word_original_name AS "wordOriginalName",
|
word_stored_name AS "wordStoredName",
|
||||||
report.word_stored_name AS "wordStoredName",
|
word_mime_type AS "wordMimeType",
|
||||||
report.word_mime_type AS "wordMimeType",
|
|
||||||
word_size_bytes::integer AS "wordSizeBytes",
|
word_size_bytes::integer AS "wordSizeBytes",
|
||||||
report.word_sha256 AS "wordSha256",
|
word_sha256 AS "wordSha256",
|
||||||
report.generated_by AS "generatedBy"
|
generated_by AS "generatedBy"
|
||||||
FROM inspection_reports report
|
FROM inspection_reports
|
||||||
JOIN inspection_visits visit ON visit.id=report.visit_id
|
WHERE id=$1
|
||||||
LEFT JOIN assets company ON company.id=visit.operator_company_id
|
|
||||||
LEFT JOIN assets area ON area.id=visit.operational_area_id
|
|
||||||
LEFT JOIN assets scope ON scope.id=visit.scope_asset_id
|
|
||||||
WHERE report.id=$1
|
|
||||||
`, [reportId]) as WordRow[];
|
`, [reportId]) as WordRow[];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
|
|||||||
@@ -258,23 +258,6 @@ export class InspectionReportWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async followUpContent(reportId: string, followUpId: string): Promise<{ filePath: string; originalName: string; mimeType: string; sizeBytes: number }> {
|
|
||||||
const [item] = await this.dataSource.query(`
|
|
||||||
SELECT original_name AS "originalName",stored_name AS "storedName",
|
|
||||||
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
|
|
||||||
FROM inspection_report_follow_ups WHERE report_id=$1 AND id=$2 AND stored_name IS NOT NULL
|
|
||||||
`, [reportId, followUpId]) as Array<{ originalName: string; storedName: string; mimeType: string; sizeBytes: number; sha256: string }>;
|
|
||||||
if (!item) throw new NotFoundException({ code: 'REPORT_FOLLOW_UP_FILE_NOT_FOUND', message: 'Adjunto de seguimiento inexistente' });
|
|
||||||
if (!/^followup-[a-f0-9-]+\.[a-z0-9]+$/.test(item.storedName)) throw this.reportStorageError();
|
|
||||||
const filePath = resolve(this.root, item.storedName);
|
|
||||||
if (!filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
|
|
||||||
const file = await stat(filePath).catch(() => null);
|
|
||||||
if (!file?.isFile() || file.size !== item.sizeBytes) throw this.reportStorageError();
|
|
||||||
const buffer = await readFile(filePath);
|
|
||||||
if (createHash('sha256').update(buffer).digest('hex') !== item.sha256) throw this.reportStorageError();
|
|
||||||
return { filePath, originalName: item.originalName, mimeType: item.mimeType, sizeBytes: item.sizeBytes };
|
|
||||||
}
|
|
||||||
|
|
||||||
async addFollowUp(
|
async addFollowUp(
|
||||||
reportId: string,
|
reportId: string,
|
||||||
dto: CreateInspectionReportFollowUpDto,
|
dto: CreateInspectionReportFollowUpDto,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user