Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29208f4e1f | ||
|
|
4523aef932 | ||
|
|
011b3e7fcb | ||
|
|
9b7d67bea2 | ||
|
|
f0b92c00e1 | ||
|
|
cab9cd7c01 | ||
|
|
c9575cc520 | ||
|
|
69aeb52040 | ||
|
|
8d5ffc86f9 | ||
|
|
8266bd8669 | ||
|
|
510a5fbca3 | ||
|
|
215f443f71 | ||
|
|
ff297c8d93 | ||
|
|
c78c2b8029 | ||
|
|
60ba37c287 | ||
|
|
669c0d2656 | ||
|
|
5cf99442a8 | ||
|
|
079728aa6d | ||
|
|
47cd985931 | ||
|
|
da7c1ddb55 | ||
|
|
a2ec846721 |
@@ -1,5 +1,5 @@
|
|||||||
name: Android CI / RC
|
name: Android CI / RC
|
||||||
# F6.2 field closing barrier: lint + real tests + debug artifact + release compile.
|
# F6.13 client-demo barrier: lint + real tests + debug artifact + release compile.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -111,7 +111,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.2"
|
echo "phase=F6.13"
|
||||||
echo "version=$version"
|
echo "version=$version"
|
||||||
echo "versionCode=$code"
|
echo "versionCode=$code"
|
||||||
echo "commit=$GITHUB_SHA"
|
echo "commit=$GITHUB_SHA"
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
- 'feature/**'
|
||||||
|
- 'fix/**'
|
||||||
|
- 'chore/**'
|
||||||
|
- 'release/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -52,9 +58,10 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
contract:
|
contract:
|
||||||
name: Docker / scripts contract
|
name: Docker / migrations / production images
|
||||||
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
|
||||||
@@ -347,3 +354,22 @@ 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"
|
||||||
|
|||||||
+38
-7
@@ -1,20 +1,51 @@
|
|||||||
# DH Inspección Android · release final de campo 0.19.7
|
# DH Inspección Android · release de campo 0.19.12
|
||||||
|
|
||||||
## Candidata vigente
|
## Candidata vigente
|
||||||
|
|
||||||
|
- `versionName`: **0.19.12**; `versionCode`: **40**.
|
||||||
|
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-13 / WEB 0.23.0-10.
|
||||||
|
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
|
||||||
|
- 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**.
|
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
|
||||||
- `versionName`: **0.19.7**.
|
- `versionName`: **0.19.10**.
|
||||||
- `versionCode`: **35**.
|
- `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-5 / WEB 0.23.0-3**.
|
- 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
|
||||||
|
|
||||||
|
- **Trabajo offline seguro:** Room conserva operaciones pendientes y WorkManager las sincroniza cuando vuelve la red, respetando el orden en que fueron creadas.
|
||||||
|
- La cola offline queda separada por usuario autenticado para impedir que una sesión sincronice trabajo de otra persona en el mismo dispositivo.
|
||||||
|
- Inspecciones, Actas, Inventario, Hallazgos, fotos, representante, manifestación y cierre conservan una identidad de cliente estable para reintentos idempotentes.
|
||||||
|
- Las listas de trabajo, Inventario, Actas, cierres, Hallazgos y estado de firma usan caché local por usuario para poder consultar contexto ya descargado sin conectividad.
|
||||||
|
- Antes de incorporar una foto de Hallazgo se muestra **Revisar fotografía** con **Usar esta foto**, **Volver a tomar** y **Eliminar foto**.
|
||||||
|
- Corrige el cierre que enviaba `uploadMode=ONLINE`; el contrato vigente usa `IMMEDIATE` o `DEFERRED`.
|
||||||
|
- La APK consulta el estado real de la firma reutilizable del Inspector y no afirma que existe cuando todavía no fue configurada.
|
||||||
|
- El Dashboard de Actas prioriza el **PDF real del Acta** y muestra directamente las fotografías de Hallazgos y del Inventario relacionado.
|
||||||
|
- En toda la interfaz activa, la antigua expresión “clasificación técnica” se presenta como **Tipo de instalación** o **Tipo de subinstalación**.
|
||||||
|
|
||||||
|
- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED.
|
||||||
|
- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante.
|
||||||
|
- 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.
|
||||||
|
- 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
|
## Procedimiento operativo validado
|
||||||
|
|
||||||
La APK 0.19.7 fija como recorrido principal de campo:
|
La APK 0.19.10 fija como recorrido principal de campo:
|
||||||
|
|
||||||
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
|
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
|
||||||
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define.
|
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.
|
||||||
@@ -23,8 +54,8 @@ La APK 0.19.7 fija como recorrido principal de campo:
|
|||||||
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
|
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
|
||||||
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
|
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
|
||||||
7. 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.
|
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 Acta y dejar pendiente de firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
|
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. La firma del Inspector y la manifestación del representante completan el Acta: **conformidad**, **disconformidad con motivo obligatorio** o **negativa a firmar con motivo obligatorio**. La firma se registra Acta por Acta aunque sea la misma persona.
|
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.
|
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.
|
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ 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 = 35
|
versionCode = 40
|
||||||
versionName = "0.19.7"
|
versionName = "0.19.12"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
@@ -73,6 +75,10 @@ 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,7 +6,9 @@ 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.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
|
||||||
@@ -18,10 +20,14 @@ 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
|
||||||
@@ -36,6 +42,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
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
|
||||||
@@ -45,6 +52,14 @@ 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
|
||||||
@@ -75,6 +90,8 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,24 +160,80 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
fun startVisit() {
|
fun startVisit() {
|
||||||
val id = visit?.id ?: return
|
val id = visit?.id ?: return
|
||||||
|
val previousVisit = visit
|
||||||
launchBusy(mutation = true) {
|
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 { loadActsInternal(visitId, selectDraft = selectedAct == null) }
|
launchBusy {
|
||||||
|
loadActsInternal(visitId, selectDraft = selectedAct == null)
|
||||||
|
refreshInspectorSignatureStatusInternal()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncPendingNow() {
|
||||||
|
launchBusy {
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
selectedAct?.id?.let { actId ->
|
||||||
|
runCatching { refreshSelectedActInternal(actId) }
|
||||||
|
}
|
||||||
|
if (result.pending == 0) {
|
||||||
|
notice = if (result.synced > 0) "Sincronización completa. ${result.synced} cambio${if (result.synced == 1) "" else "s"} enviado${if (result.synced == 1) "" else "s"}." else "No hay cambios pendientes de sincronización."
|
||||||
|
} else if (result.retry) {
|
||||||
|
notice = "Sin conexión. Los cambios siguen guardados en este dispositivo."
|
||||||
|
} else {
|
||||||
|
error = result.lastError ?: "Quedan cambios pendientes de sincronización."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshInspectorSignatureStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
refreshInspectorSignatureStatusInternal()
|
||||||
|
if (inspectorSignatureConfigured == true && pendingSyncCount > 0) {
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
val actId = selectedAct?.id
|
||||||
|
if (actId != null && !selectedActSealPending) {
|
||||||
|
runCatching { refreshSelectedActInternal(actId) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
result.pending == 0 -> notice = "Firma verificada y cambios pendientes sincronizados."
|
||||||
|
result.retry -> notice = "Firma verificada. Los cambios siguen guardados hasta recuperar conexión."
|
||||||
|
else -> error = result.lastError ?: "Quedan cambios pendientes de sincronización."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectAct(actId: String) {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,11 +249,41 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
val created = actsRepository.create(currentVisit.id, currentVisit.code)
|
val actId = DhRepository.newOperationId()
|
||||||
selectedAct = created
|
val occurredAt = Instant.now().toString()
|
||||||
actClosure = actsRepository.closure(created.id)
|
offlineQueue.enqueueActCreate(actId, currentVisit.id, currentVisit.code, occurredAt)
|
||||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
val localCode = "ACTA-PEND-${actId.take(6).uppercase()}"
|
||||||
notice = "${created.code} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
|
val local = MobileActDetail(
|
||||||
|
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
|
||||||
|
acts = acts + MobileActSummary(
|
||||||
|
id = actId, visitId = currentVisit.id, code = localCode, status = "DRAFT", occurredAt = occurredAt,
|
||||||
|
title = local.title, summary = local.summary,
|
||||||
|
)
|
||||||
|
actClosure = MobileActClosure(
|
||||||
|
act = MobileActClosureHeader(
|
||||||
|
id = actId, code = localCode, status = "DRAFT", visitId = currentVisit.id, currentVersion = 0,
|
||||||
|
),
|
||||||
|
visit = MobileVisitClosureHeader(
|
||||||
|
id = currentVisit.id, code = currentVisit.code, status = currentVisit.status, actualClosedAt = currentVisit.actualClosedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||||
|
refreshSelectedActInternal(actId)
|
||||||
|
notice = "${selectedAct?.code ?: localCode} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
|
||||||
|
} else {
|
||||||
|
notice = "Acta creada en el dispositivo. Podés continuar trabajando; recibirá su código oficial cuando vuelva internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,16 +312,55 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
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(mutation = true) {
|
||||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
offlineQueue.enqueueFieldAssetSelect(visitId, item.id)
|
||||||
notice = "Inventario agregado a la Inspección."
|
selectedFieldAsset = FieldAssetDetail(
|
||||||
reloadCurrentInventory(visitId)
|
context = null,
|
||||||
|
asset = item.copy(selectedInInspection = true),
|
||||||
|
selectedInInspection = true,
|
||||||
|
capture = CaptureStatus(
|
||||||
|
captureRequired = item.captureRequired,
|
||||||
|
hasGeometry = item.hasGeometry,
|
||||||
|
creationGpsCaptured = item.hasGeometry,
|
||||||
|
fieldPhotoCount = item.fieldPhotoCount,
|
||||||
|
readyForFinding = item.readyForFinding,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val sync = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (sync.pending == 0) {
|
||||||
|
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||||
|
runCatching { reloadCurrentInventory(visitId) }
|
||||||
|
notice = "Inventario agregado a la Inspección."
|
||||||
|
} else {
|
||||||
|
notice = "Inventario seleccionado en el dispositivo. Se sincronizará automáticamente al volver internet."
|
||||||
|
}
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||||
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
|
ensureAssetInActOffline(draft.id, item.id, item.code, item.name, item.type?.name)
|
||||||
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."
|
||||||
@@ -248,7 +390,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
|
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,
|
||||||
@@ -259,11 +404,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
deviceLatitude = FieldCoordinates.latitude(latitude),
|
deviceLatitude = FieldCoordinates.latitude(latitude),
|
||||||
deviceLongitude = FieldCoordinates.longitude(longitude),
|
deviceLongitude = FieldCoordinates.longitude(longitude),
|
||||||
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
|
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
|
||||||
deviceCapturedAt = Instant.now().toString(),
|
deviceCapturedAt = capturedAt,
|
||||||
)
|
)
|
||||||
selectedFieldAsset = repository.createFieldAsset(visitId, request)
|
offlineQueue.enqueueFieldAssetCreate(assetId, visitId, request)
|
||||||
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
val localAsset = FieldInventoryItem(
|
||||||
reloadCurrentInventory(visitId)
|
id = assetId,
|
||||||
|
code = "PEND-${assetId.take(6).uppercase()}",
|
||||||
|
name = request.name,
|
||||||
|
commonName = request.commonName,
|
||||||
|
informationStatus = "DRAFT",
|
||||||
|
dataOrigin = "FIELD_SURVEY",
|
||||||
|
type = AssetSummary(type.id, type.code, type.name, type.name),
|
||||||
|
selectedInInspection = true,
|
||||||
|
captureRequired = true,
|
||||||
|
hasGeometry = true,
|
||||||
|
fieldPhotoCount = 0,
|
||||||
|
readyForFinding = false,
|
||||||
|
)
|
||||||
|
selectedFieldAsset = FieldAssetDetail(
|
||||||
|
asset = localAsset,
|
||||||
|
selectedInInspection = true,
|
||||||
|
capture = CaptureStatus(
|
||||||
|
captureRequired = true,
|
||||||
|
hasGeometry = true,
|
||||||
|
creationGpsCaptured = true,
|
||||||
|
fieldPhotoCount = 0,
|
||||||
|
readyForFinding = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inventory = inventory + localAsset
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
selectedFieldAsset = repository.selectFieldAsset(visitId, assetId)
|
||||||
|
runCatching { reloadCurrentInventory(visitId) }
|
||||||
|
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
||||||
|
} else {
|
||||||
|
notice = "Inventario creado y guardado en el dispositivo. Sacá la foto obligatoria; se sincronizará automáticamente al volver internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +468,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
reloadCurrentInventory(visitId)
|
reloadCurrentInventory(visitId)
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||||
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
|
ensureAssetInActOffline(
|
||||||
loadActsInternal(visitId, selectDraft = false)
|
draft.id, result.canonical.id, result.canonical.code, result.canonical.name, result.canonical.typeName,
|
||||||
|
)
|
||||||
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
|
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,27 +485,41 @@ 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(mutation = true) {
|
||||||
val response = repository.uploadFieldPhoto(
|
offlineQueue.enqueueFieldAssetPhoto(
|
||||||
visitId = visitId,
|
|
||||||
assetId = asset.id,
|
assetId = asset.id,
|
||||||
file = file,
|
visitId = visitId,
|
||||||
|
source = file,
|
||||||
latitude = latitude,
|
latitude = latitude,
|
||||||
longitude = longitude,
|
longitude = longitude,
|
||||||
accuracyM = accuracyM,
|
accuracyM = accuracyM,
|
||||||
)
|
)
|
||||||
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
|
val localCapture = selectedFieldAsset?.capture?.copy(
|
||||||
notice = if (response.capture.readyForFinding) {
|
hasGeometry = true,
|
||||||
"Inventario listo: ubicación y foto registradas."
|
creationGpsCaptured = true,
|
||||||
|
fieldPhotoCount = (selectedFieldAsset?.capture?.fieldPhotoCount ?: 0) + 1,
|
||||||
|
readyForFinding = true,
|
||||||
|
) ?: CaptureStatus(
|
||||||
|
captureRequired = true, hasGeometry = true, creationGpsCaptured = true, fieldPhotoCount = 1, readyForFinding = true,
|
||||||
|
)
|
||||||
|
selectedFieldAsset = selectedFieldAsset?.copy(
|
||||||
|
asset = asset.copy(hasGeometry = true, fieldPhotoCount = localCapture.fieldPhotoCount, readyForFinding = true),
|
||||||
|
capture = localCapture,
|
||||||
|
)
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
selectedFieldAsset = repository.selectFieldAsset(visitId, asset.id)
|
||||||
|
runCatching { reloadCurrentInventory(visitId) }
|
||||||
|
notice = "Inventario listo: ubicación y foto registradas."
|
||||||
} else {
|
} else {
|
||||||
"Fotografía registrada."
|
notice = "Foto del Inventario guardada en el dispositivo. Ya podés continuar; se sincronizará al volver internet."
|
||||||
}
|
}
|
||||||
reloadCurrentInventory(visitId)
|
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (response.capture.readyForFinding && draft != null) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||||
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
|
val current = selectedFieldAsset?.asset ?: asset
|
||||||
loadActsInternal(visitId, selectDraft = false)
|
ensureAssetInActOffline(draft.id, current.id, current.code, current.name, current.type?.name)
|
||||||
loadFindingOptionsInternal(visitId, asset.id, draft.id)
|
loadFindingOptionsInternal(visitId, current.id, draft.id)
|
||||||
} else if (response.capture.readyForFinding) {
|
} 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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,8 +534,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
|
val asset = selectedFieldAsset?.asset ?: return@launchBusy
|
||||||
loadActsInternal(visitId, selectDraft = false)
|
ensureAssetInActOffline(draft.id, assetId, asset.code, asset.name, asset.type?.name)
|
||||||
loadFindingOptionsInternal(visitId, assetId, draft.id)
|
loadFindingOptionsInternal(visitId, assetId, draft.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,23 +567,60 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
selectedAct = actsRepository.ensureAsset(actId, assetId)
|
val clientFindingId = DhRepository.newOperationId()
|
||||||
val response = findingsRepository.create(
|
val request = CreateFieldFindingRequest(
|
||||||
visitId,
|
actId = actId,
|
||||||
assetId,
|
clientGeneratedId = clientFindingId,
|
||||||
CreateFieldFindingRequest(
|
catalogItemId = catalogItemId,
|
||||||
actId = actId,
|
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
|
||||||
catalogItemId = catalogItemId,
|
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
||||||
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
|
description = description.trim(),
|
||||||
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
severity = severity,
|
||||||
description = description.trim(),
|
|
||||||
severity = severity,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
lastCreatedFinding = response.finding
|
if (selectedAct?.assets?.none { it.id == assetId } != false) {
|
||||||
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
|
offlineQueue.enqueueActAsset(actId, assetId)
|
||||||
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
|
selectedFieldAsset?.asset?.let { asset ->
|
||||||
loadActsInternal(visitId, selectDraft = false)
|
selectedAct = selectedAct?.copy(assets = selectedAct!!.assets + AssetSummary(
|
||||||
|
id = asset.id,
|
||||||
|
code = asset.code,
|
||||||
|
name = asset.name,
|
||||||
|
typeName = asset.type?.name,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offlineQueue.enqueueFindingCreate(clientFindingId, visitId, assetId, request)
|
||||||
|
|
||||||
|
val catalog = fieldFindingOptions?.catalog?.items?.firstOrNull { it.id == catalogItemId }
|
||||||
|
val localNumber = (fieldFindingOptions?.findings?.maxOfOrNull { it.findingNumber } ?: 0) + 1
|
||||||
|
val localFinding = FieldFindingItem(
|
||||||
|
id = clientFindingId,
|
||||||
|
actId = actId,
|
||||||
|
assetId = assetId,
|
||||||
|
catalogItemId = catalogItemId,
|
||||||
|
findingNumber = localNumber,
|
||||||
|
code = "PEND-H${localNumber.toString().padStart(3, '0')}",
|
||||||
|
status = "OPEN",
|
||||||
|
title = catalog?.title ?: customTitle?.trim().orEmpty().ifBlank { "Hallazgo pendiente" },
|
||||||
|
description = description.trim(),
|
||||||
|
severity = severity ?: catalog?.suggestedSeverity,
|
||||||
|
suggestedSeverity = catalog?.suggestedSeverity,
|
||||||
|
)
|
||||||
|
lastCreatedFinding = localFinding
|
||||||
|
fieldFindingOptions = fieldFindingOptions?.copy(
|
||||||
|
findings = fieldFindingOptions!!.findings + localFinding,
|
||||||
|
canAddAnother = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
|
||||||
|
lastCreatedFinding = fieldFindingOptions?.findings?.firstOrNull { it.id == clientFindingId } ?: localFinding
|
||||||
|
loadActsInternal(visitId, selectDraft = false)
|
||||||
|
notice = "Hallazgo ${lastCreatedFinding?.code ?: localFinding.code} registrado. Podés agregar evidencia fotográfica."
|
||||||
|
} else {
|
||||||
|
notice = "Hallazgo guardado en el dispositivo. Podés seguir sacando fotos; todo se sincronizará automáticamente al volver internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,17 +634,23 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
description: String? = null,
|
description: String? = null,
|
||||||
) {
|
) {
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
findingsRepository.uploadObservationPhoto(
|
offlineQueue.enqueueFindingPhoto(
|
||||||
findingId = findingId,
|
findingId = findingId,
|
||||||
file = file,
|
source = file,
|
||||||
latitude = latitude,
|
latitude = latitude,
|
||||||
longitude = longitude,
|
longitude = longitude,
|
||||||
accuracyM = accuracyM,
|
accuracyM = accuracyM,
|
||||||
title = title,
|
title = title,
|
||||||
description = description,
|
description = description,
|
||||||
)
|
)
|
||||||
loadEvidenceInternal(findingId)
|
val result = offlineQueue.syncAll()
|
||||||
notice = "Evidencia fotográfica registrada con GPS."
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
loadEvidenceInternal(findingId)
|
||||||
|
notice = "Evidencia fotográfica registrada con GPS."
|
||||||
|
} else {
|
||||||
|
notice = "Foto guardada en el dispositivo. Se sincronizará automáticamente al volver la conexión."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,19 +674,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
actClosure = actsRepository.setResponsible(
|
val request = MobileResponsibleRequest(
|
||||||
actId,
|
attendanceStatus = "PRESENT",
|
||||||
MobileResponsibleRequest(
|
fullName = fullName.trim(),
|
||||||
attendanceStatus = "PRESENT",
|
documentType = documentType,
|
||||||
fullName = fullName.trim(),
|
documentNumber = documentNumber.trim(),
|
||||||
documentType = documentType,
|
position = position.trim(),
|
||||||
documentNumber = documentNumber.trim(),
|
email = normalizedEmail.lowercase(),
|
||||||
position = position.trim(),
|
phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
||||||
email = normalizedEmail.lowercase(),
|
|
||||||
phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
notice = "Representante de la empresa registrado para esta Acta."
|
offlineQueue.enqueueResponsible(actId, request)
|
||||||
|
actClosure = actClosure?.copy(responsible = MobileResponsible(
|
||||||
|
actId = actId,
|
||||||
|
attendanceStatus = request.attendanceStatus,
|
||||||
|
fullName = request.fullName,
|
||||||
|
documentType = request.documentType,
|
||||||
|
documentNumber = request.documentNumber,
|
||||||
|
position = request.position,
|
||||||
|
email = request.email,
|
||||||
|
phone = request.phone,
|
||||||
|
))
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
actClosure = actsRepository.closure(actId)
|
||||||
|
notice = "Representante de la empresa registrado para esta Acta."
|
||||||
|
} else {
|
||||||
|
notice = "Representante guardado en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,28 +712,53 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
actClosure = actsRepository.setResponsible(
|
val request = MobileResponsibleRequest(
|
||||||
actId,
|
attendanceStatus = "ABSENT",
|
||||||
MobileResponsibleRequest(
|
absenceReason = reason.trim(),
|
||||||
attendanceStatus = "ABSENT",
|
|
||||||
absenceReason = reason.trim(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
notice = "Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
|
offlineQueue.enqueueResponsible(actId, request)
|
||||||
|
actClosure = actClosure?.copy(responsible = MobileResponsible(
|
||||||
|
actId = actId,
|
||||||
|
attendanceStatus = "ABSENT",
|
||||||
|
absenceReason = reason.trim(),
|
||||||
|
))
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) actClosure = actsRepository.closure(actId)
|
||||||
|
notice = if (result.pending == 0) {
|
||||||
|
"Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
|
||||||
|
} else {
|
||||||
|
"Ausencia guardada en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepareSelectedAct(urgency: String) {
|
fun prepareSelectedAct(urgency: String, narrative: String) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||||
error = "Definí la urgencia antes de cerrar el Acta."
|
error = "Definí la urgencia antes de cerrar el Acta."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
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) {
|
launchBusy(mutation = true) {
|
||||||
actClosure = actsRepository.lock(actId, urgency)
|
offlineQueue.enqueueActSummary(actId, summary)
|
||||||
refreshSelectedActInternal(actId)
|
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"
|
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
||||||
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
|
if (result.pending == 0) {
|
||||||
|
refreshSelectedActInternal(actId)
|
||||||
|
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
|
||||||
|
} else {
|
||||||
|
notice = "Cierre de contenido guardado en el dispositivo como $urgencyLabel. Podés continuar con la firma; se sincronizará al recuperar internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,14 +789,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
) {
|
) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
actClosure = actsRepository.signCompany(
|
offlineQueue.enqueueCompanySignature(
|
||||||
actId, png, latitude, longitude, accuracyM, manifestation, statement,
|
actId = actId,
|
||||||
|
source = png,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
|
accuracyM = accuracyM,
|
||||||
|
manifestation = manifestation,
|
||||||
|
statement = statement,
|
||||||
)
|
)
|
||||||
notice = if (manifestation == "DISSENT") {
|
val result = offlineQueue.syncAll()
|
||||||
"Firma del representante registrada en disconformidad."
|
refreshOfflineStateInternal()
|
||||||
|
if (!selectedActCompanySignaturePending) {
|
||||||
|
actClosure = actsRepository.closure(actId)
|
||||||
|
notice = if (manifestation == "DISSENT") {
|
||||||
|
"Firma del representante registrada en disconformidad."
|
||||||
|
} else {
|
||||||
|
"Firma del representante de la empresa registrada."
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
"Firma del representante de la empresa registrada."
|
notice = "Firma guardada en el dispositivo. Quedó pendiente de sincronización y no se perderá si no hay internet."
|
||||||
}
|
}
|
||||||
|
result.lastError?.takeIf { result.pending > 0 && !result.retry }?.let { error = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,8 +825,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
offlineQueue.enqueueCompanyOutcome(actId, status, reason.trim())
|
||||||
notice = "Negativa a firmar asentada."
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (result.pending == 0) {
|
||||||
|
actClosure = actsRepository.closure(actId)
|
||||||
|
notice = "Negativa a firmar asentada."
|
||||||
|
} else {
|
||||||
|
notice = "Negativa a firmar guardada en el dispositivo. Se sincronizará automáticamente al recuperar internet."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,20 +841,50 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy(mutation = true) {
|
launchBusy(mutation = true) {
|
||||||
actClosure = actsRepository.seal(actId)
|
offlineQueue.enqueueSeal(actId)
|
||||||
refreshSelectedActInternal(actId)
|
if (inspectorSignatureConfigured == false) {
|
||||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
refreshOfflineStateInternal()
|
||||||
clearFindingState()
|
notice = "Cierre guardado en el dispositivo. Falta configurar la firma del inspector en Mi perfil del Dashboard; después se sincronizará automáticamente."
|
||||||
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
return@launchBusy
|
||||||
|
}
|
||||||
|
val result = offlineQueue.syncAll()
|
||||||
|
refreshOfflineStateInternal()
|
||||||
|
if (!selectedActSealPending) {
|
||||||
|
refreshSelectedActInternal(actId)
|
||||||
|
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||||
|
clearFindingState()
|
||||||
|
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
||||||
|
} else if (result.retry) {
|
||||||
|
val localClosedAt = Instant.now().toString()
|
||||||
|
selectedAct = selectedAct?.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt)
|
||||||
|
acts = acts.map { if (it.id == actId) it.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) else it }
|
||||||
|
clearFindingState()
|
||||||
|
notice = "Acta cerrada en el dispositivo. Podés continuar con otra Acta; se sincronizará automáticamente al recuperar internet."
|
||||||
|
} else {
|
||||||
|
error = result.lastError ?: "El cierre quedó pendiente de sincronización."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeInspection() {
|
fun closeInspection() {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
|
val previousVisit = visit
|
||||||
launchBusy(mutation = true) {
|
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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,6 +922,7 @@ 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) {
|
||||||
@@ -625,6 +953,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshInspectorSignatureStatusInternal() {
|
||||||
|
runCatching { actsRepository.selfSignatureStatus() }
|
||||||
|
.onSuccess { inspectorSignatureConfigured = it.configured }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshOfflineStateInternal() {
|
||||||
|
pendingSyncCount = offlineQueue.pendingCount()
|
||||||
|
val actId = selectedAct?.id
|
||||||
|
if (actId == null) {
|
||||||
|
selectedActCompanySignaturePending = false
|
||||||
|
selectedActSealPending = false
|
||||||
|
} else {
|
||||||
|
selectedActCompanySignaturePending = offlineQueue.hasPending("COMPANY_SIGNATURE", actId)
|
||||||
|
|| offlineQueue.hasPending("COMPANY_OUTCOME", actId)
|
||||||
|
selectedActSealPending = offlineQueue.hasPending("ACT_SEAL", actId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearActState() {
|
private fun clearActState() {
|
||||||
acts = emptyList()
|
acts = emptyList()
|
||||||
selectedAct = null
|
selectedAct = null
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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 okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -25,6 +27,7 @@ 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
|
||||||
@@ -254,6 +257,7 @@ 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,
|
||||||
@@ -366,6 +370,7 @@ 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?,
|
||||||
@@ -448,8 +453,14 @@ class SecureSessionStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DhRepository(context: Context) {
|
class DhRepository(context: Context) {
|
||||||
private val store = SecureSessionStore(context.applicationContext)
|
private val appContext = context.applicationContext
|
||||||
|
private val store = SecureSessionStore(appContext)
|
||||||
|
private val cache = OfflineJsonCache(appContext)
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val 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())
|
||||||
@@ -468,25 +479,47 @@ class DhRepository(context: Context) {
|
|||||||
store.clear()
|
store.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun visits(): VisitListResponse = authorized { session ->
|
suspend fun visits(): VisitListResponse {
|
||||||
api.visits("Bearer ${session.accessToken}", session.userId)
|
val session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||||
|
return cached("visits:${session.userId}", visitListAdapter) {
|
||||||
|
authorized { active -> api.visits("Bearer ${active.accessToken}", active.userId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun visit(id: String): VisitDetail = authorized { session ->
|
suspend fun visit(id: String): VisitDetail = cached("visit:$id", visitAdapter) {
|
||||||
api.visit("Bearer ${session.accessToken}", id)
|
authorized { session -> api.visit("Bearer ${session.accessToken}", id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
|
suspend fun startVisit(id: String): VisitDetail {
|
||||||
api.startVisit("Bearer ${session.accessToken}", id)
|
val value = authorized { session -> api.startVisit("Bearer ${session.accessToken}", id) }
|
||||||
|
cache.put(userCacheKey("visit:$id"), visitAdapter.toJson(value))
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
|
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null): FieldInventoryListResponse {
|
||||||
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
|
val normalizedSearch = search?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
val baseKey = "inventory:$visitId:${parentId ?: "root"}"
|
||||||
|
return try {
|
||||||
|
val value = authorized { session ->
|
||||||
|
api.fieldInventory("Bearer ${session.accessToken}", visitId, normalizedSearch, parentId)
|
||||||
|
}
|
||||||
|
if (normalizedSearch == null) cache.put(userCacheKey(baseKey), inventoryAdapter.toJson(value))
|
||||||
|
value
|
||||||
|
} catch (error: IOException) {
|
||||||
|
val cached = cache.get(userCacheKey(baseKey))?.let(inventoryAdapter::fromJson) ?: throw error
|
||||||
|
if (normalizedSearch == null) cached else cached.copy(
|
||||||
|
data = cached.data.filter { item ->
|
||||||
|
item.code.contains(normalizedSearch, ignoreCase = true)
|
||||||
|
|| item.name.contains(normalizedSearch, ignoreCase = true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
|
suspend fun fieldTypes(visitId: String, parentId: String?): FieldTypeResponse =
|
||||||
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
|
cached("field-types:$visitId:${parentId ?: "root"}", fieldTypeAdapter) {
|
||||||
}
|
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
||||||
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
||||||
@@ -518,6 +551,7 @@ 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())
|
||||||
@@ -532,12 +566,27 @@ class DhRepository(context: Context) {
|
|||||||
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
|
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
|
||||||
capturedAt = capturedAt.toRequestBody(text),
|
capturedAt = capturedAt.toRequestBody(text),
|
||||||
deviceLabel = "DH Android".toRequestBody(text),
|
deviceLabel = "DH Android".toRequestBody(text),
|
||||||
|
operationId = operationId?.toRequestBody(text),
|
||||||
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
|
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
|
||||||
exifLongitude = FieldCoordinates.longitude(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 {
|
||||||
|
|||||||
+51
-7
@@ -2,6 +2,8 @@ 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 okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -22,6 +24,7 @@ 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(
|
||||||
@@ -108,6 +111,7 @@ 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,
|
||||||
@@ -161,6 +165,7 @@ 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")
|
||||||
@@ -172,8 +177,12 @@ 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 store = SecureSessionStore(context.applicationContext)
|
private val appContext = context.applicationContext
|
||||||
|
private val store = SecureSessionStore(appContext)
|
||||||
|
private val cache = OfflineJsonCache(appContext)
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val 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())
|
||||||
@@ -181,10 +190,28 @@ 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 {
|
||||||
authorized { session ->
|
val exactKey = "finding-options:$visitId:$assetId:$actId"
|
||||||
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
|
val templateKey = "finding-options-template:$visitId:$assetId"
|
||||||
|
return try {
|
||||||
|
val value = authorized { session ->
|
||||||
|
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
|
||||||
|
}
|
||||||
|
val json = optionsAdapter.toJson(value)
|
||||||
|
cache.put(userCacheKey(exactKey), json)
|
||||||
|
cache.put(userCacheKey(templateKey), json)
|
||||||
|
value
|
||||||
|
} catch (error: IOException) {
|
||||||
|
cache.get(userCacheKey(exactKey))?.let(optionsAdapter::fromJson)
|
||||||
|
?: cache.get(userCacheKey(templateKey))?.let(optionsAdapter::fromJson)?.copy(
|
||||||
|
act = FieldFindingAct(id = actId, code = "Acta local pendiente", status = "DRAFT"),
|
||||||
|
findings = emptyList(),
|
||||||
|
assetIncludedInAct = true,
|
||||||
|
canAddAnother = true,
|
||||||
|
)
|
||||||
|
?: throw error
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
visitId: String,
|
visitId: String,
|
||||||
@@ -194,9 +221,10 @@ 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 = authorized { session ->
|
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse =
|
||||||
api.evidence("Bearer ${session.accessToken}", findingId)
|
cached("finding-evidence:$findingId", evidenceAdapter) {
|
||||||
}
|
authorized { session -> api.evidence("Bearer ${session.accessToken}", findingId) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun uploadObservationPhoto(
|
suspend fun uploadObservationPhoto(
|
||||||
findingId: String,
|
findingId: String,
|
||||||
@@ -207,6 +235,7 @@ 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(
|
||||||
@@ -227,9 +256,24 @@ class FieldFindingsRepository(context: Context) {
|
|||||||
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
|
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
|
||||||
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
|
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.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 {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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 okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
@@ -24,6 +26,7 @@ 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(
|
||||||
@@ -90,6 +93,7 @@ data class MobileActListResponse(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class CreateMobileActRequest(
|
data class CreateMobileActRequest(
|
||||||
|
val clientGeneratedId: String? = null,
|
||||||
val occurredAt: String,
|
val occurredAt: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
@@ -102,7 +106,8 @@ data class PrepareMobileActRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class UpdateMobileActRequest(
|
data class UpdateMobileActRequest(
|
||||||
val assetIds: List<String>,
|
val assetIds: List<String>? = null,
|
||||||
|
val summary: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileResponsibleRequest(
|
data class MobileResponsibleRequest(
|
||||||
@@ -194,6 +199,14 @@ data class MobileActClosure(
|
|||||||
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,
|
||||||
@@ -201,7 +214,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 = "ONLINE",
|
val uploadMode: String = "IMMEDIATE",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileCloseVisitRequest(
|
data class MobileCloseVisitRequest(
|
||||||
@@ -236,6 +249,11 @@ 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,
|
||||||
@@ -312,8 +330,14 @@ private interface MobileActsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MobileActsRepository(context: Context) {
|
class MobileActsRepository(context: Context) {
|
||||||
private val store = SecureSessionStore(context.applicationContext)
|
private val appContext = context.applicationContext
|
||||||
|
private val store = SecureSessionStore(appContext)
|
||||||
|
private val cache = OfflineJsonCache(appContext)
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val 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())
|
||||||
@@ -321,28 +345,40 @@ class MobileActsRepository(context: Context) {
|
|||||||
.build()
|
.build()
|
||||||
.create(MobileActsApi::class.java)
|
.create(MobileActsApi::class.java)
|
||||||
|
|
||||||
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
|
suspend fun list(visitId: String): MobileActListResponse =
|
||||||
api.listActs("Bearer ${session.accessToken}", visitId)
|
cached("acts:$visitId", actListAdapter) {
|
||||||
}
|
authorized { session -> api.listActs("Bearer ${session.accessToken}", visitId) }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun get(actId: String): MobileActDetail = authorized { session ->
|
suspend fun get(actId: String): MobileActDetail = cached("act:$actId", actAdapter) {
|
||||||
api.act("Bearer ${session.accessToken}", actId)
|
authorized { session -> api.act("Bearer ${session.accessToken}", actId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun create(
|
suspend fun create(
|
||||||
visitId: String,
|
visitId: String,
|
||||||
visitCode: String,
|
visitCode: String,
|
||||||
): MobileActDetail = authorized { session ->
|
clientGeneratedId: String? = null,
|
||||||
api.createAct(
|
occurredAt: String = Instant.now().toString(),
|
||||||
"Bearer ${session.accessToken}",
|
): MobileActDetail {
|
||||||
visitId,
|
val created = authorized { session ->
|
||||||
CreateMobileActRequest(
|
api.createAct(
|
||||||
occurredAt = Instant.now().toString(),
|
"Bearer ${session.accessToken}",
|
||||||
title = "Acta de inspección $visitCode",
|
visitId,
|
||||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
CreateMobileActRequest(
|
||||||
assetIds = emptyList(),
|
clientGeneratedId = clientGeneratedId,
|
||||||
),
|
occurredAt = occurredAt,
|
||||||
)
|
title = "Acta de inspección $visitCode",
|
||||||
|
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 {
|
||||||
@@ -354,16 +390,25 @@ class MobileActsRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
|
suspend fun closure(actId: String): MobileActClosure = cached("act-closure:$actId", closureAdapter) {
|
||||||
api.closure("Bearer ${session.accessToken}", actId)
|
authorized { session -> api.closure("Bearer ${session.accessToken}", actId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
|
suspend fun selfSignatureStatus(): MobileProfileSignatureStatus =
|
||||||
api.responsible("Bearer ${session.accessToken}", actId, request)
|
cached("self-signature-status", signatureStatusAdapter) {
|
||||||
|
authorized { session -> api.selfSignature("Bearer ${session.accessToken}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure {
|
||||||
|
val value = authorized { session -> api.responsible("Bearer ${session.accessToken}", actId, request) }
|
||||||
|
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun lock(actId: String, urgency: String): MobileActClosure = authorized { session ->
|
suspend fun lock(actId: String, urgency: String): MobileActClosure {
|
||||||
api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency))
|
val value = authorized { session -> api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) }
|
||||||
|
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signInspector(
|
suspend fun signInspector(
|
||||||
@@ -382,6 +427,7 @@ 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,
|
||||||
@@ -391,22 +437,38 @@ 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 = authorized { session ->
|
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure {
|
||||||
api.companyOutcome(
|
val value = authorized { session ->
|
||||||
"Bearer ${session.accessToken}",
|
api.companyOutcome(
|
||||||
actId,
|
"Bearer ${session.accessToken}",
|
||||||
MobileCompanyOutcomeRequest(status, reason.trim()),
|
actId,
|
||||||
)
|
MobileCompanyOutcomeRequest(status, reason.trim()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
|
suspend fun seal(
|
||||||
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
|
actId: String,
|
||||||
|
uploadMode: String = "IMMEDIATE",
|
||||||
|
clientClosedAt: String = Instant.now().toString(),
|
||||||
|
): MobileActClosure {
|
||||||
|
val value = authorized { session ->
|
||||||
|
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest(clientClosedAt, uploadMode))
|
||||||
|
}
|
||||||
|
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
suspend fun closeVisit(
|
||||||
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
|
visitId: String,
|
||||||
|
clientClosedAt: String = Instant.now().toString(),
|
||||||
|
): VisitDetail = authorized { session ->
|
||||||
|
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest(clientClosedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun signature(
|
private suspend fun signature(
|
||||||
@@ -418,6 +480,7 @@ 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(
|
||||||
@@ -426,7 +489,7 @@ 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 = Instant.now().toString().toRequestBody(text)
|
val signedAt = clientSignedAt.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?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
|
||||||
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
|
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
|
||||||
@@ -446,6 +509,20 @@ 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 {
|
||||||
|
|||||||
+632
@@ -0,0 +1,632 @@
|
|||||||
|
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() }
|
||||||
+101
-11
@@ -1,12 +1,14 @@
|
|||||||
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.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
|
||||||
@@ -40,6 +42,8 @@ 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.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.LocalFocusManager
|
||||||
@@ -48,6 +52,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.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
|
||||||
@@ -93,6 +98,9 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
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
|
||||||
@@ -100,14 +108,11 @@ 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) }
|
||||||
model.uploadFindingPhoto(
|
reviewPhotoFile = file
|
||||||
findingId = findingId,
|
reviewPhotoGeo = geo
|
||||||
file = file,
|
reviewPhotoFindingId = findingId
|
||||||
latitude = geo.latitude,
|
} else {
|
||||||
longitude = geo.longitude,
|
file?.delete()
|
||||||
accuracyM = geo.accuracyM,
|
|
||||||
title = "Evidencia fotográfica de campo",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
pendingPhotoFile = null
|
pendingPhotoFile = null
|
||||||
pendingPhotoGeo = null
|
pendingPhotoGeo = null
|
||||||
@@ -182,6 +187,79 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val reviewFile = reviewPhotoFile
|
||||||
|
val reviewGeo = reviewPhotoGeo
|
||||||
|
val reviewFindingId = reviewPhotoFindingId
|
||||||
|
if (reviewFile != null && reviewGeo != null && reviewFindingId != null) {
|
||||||
|
val bitmap = remember(reviewFile.absolutePath, reviewFile.lastModified()) {
|
||||||
|
runCatching { BitmapFactory.decodeFile(reviewFile.absolutePath)?.asImageBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
|
Dialog(onDismissRequest = {}) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Revisar fotografía", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Todavía no se subió. Verificá la imagen antes de incorporarla al Hallazgo.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (bitmap != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = bitmap,
|
||||||
|
contentDescription = "Vista previa de la fotografía del Hallazgo",
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 220.dp, max = 460.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("No se pudo generar la vista previa.", color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"GPS ${"%.6f".format(reviewGeo.latitude)}, ${"%.6f".format(reviewGeo.longitude)}${reviewGeo.accuracyM?.let { " · ±${"%.1f".format(it)} m" }.orEmpty()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
model.uploadFindingPhoto(
|
||||||
|
findingId = reviewFindingId,
|
||||||
|
file = reviewFile,
|
||||||
|
latitude = reviewGeo.latitude,
|
||||||
|
longitude = reviewGeo.longitude,
|
||||||
|
accuracyM = reviewGeo.accuracyM,
|
||||||
|
title = "Evidencia fotográfica de campo",
|
||||||
|
)
|
||||||
|
reviewPhotoFile = null
|
||||||
|
reviewPhotoGeo = null
|
||||||
|
reviewPhotoFindingId = null
|
||||||
|
},
|
||||||
|
enabled = !model.busy && bitmap != null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Usar esta foto") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
reviewFile.delete()
|
||||||
|
reviewPhotoFile = null
|
||||||
|
reviewPhotoGeo = null
|
||||||
|
reviewPhotoFindingId = null
|
||||||
|
beginPhoto(reviewFindingId)
|
||||||
|
},
|
||||||
|
enabled = !model.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Volver a tomar") }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
reviewFile.delete()
|
||||||
|
reviewPhotoFile = null
|
||||||
|
reviewPhotoGeo = null
|
||||||
|
reviewPhotoFindingId = null
|
||||||
|
},
|
||||||
|
enabled = !model.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Eliminar foto") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -238,13 +316,18 @@ 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("Tomar foto con GPS")
|
Text(if (photoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
|
||||||
}
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +448,8 @@ fun FieldFindingScreen(model: MainViewModel) {
|
|||||||
label = { Text("Qué observaste *") },
|
label = { Text("Qué observaste *") },
|
||||||
placeholder = { Text("Describí brevemente el problema") },
|
placeholder = { Text("Describí brevemente el problema") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
minLines = 3,
|
||||||
|
maxLines = 6,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
|
||||||
)
|
)
|
||||||
@@ -405,13 +489,19 @@ 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("Tomar foto con GPS")
|
Text(if (createdPhotoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ fun MobileActsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||||
|
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(representativeSeed?.attendanceStatus ?: "PRESENT")
|
||||||
}
|
}
|
||||||
@@ -92,26 +95,17 @@ fun MobileActsScreen(
|
|||||||
|
|
||||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||||
|
|
||||||
fun signWithGeo(file: File, company: Boolean) {
|
fun signCompanyWithGeo(file: File) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
|
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
|
||||||
if (company) {
|
model.signSelectedActAsCompany(
|
||||||
model.signSelectedActAsCompany(
|
png = file,
|
||||||
png = file,
|
latitude = geo?.latitude,
|
||||||
latitude = geo?.latitude,
|
longitude = geo?.longitude,
|
||||||
longitude = geo?.longitude,
|
accuracyM = geo?.accuracyM,
|
||||||
accuracyM = geo?.accuracyM,
|
manifestation = manifestation,
|
||||||
manifestation = manifestation,
|
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
model.signSelectedActAsInspector(
|
|
||||||
png = file,
|
|
||||||
latitude = geo?.latitude,
|
|
||||||
longitude = geo?.longitude,
|
|
||||||
accuracyM = geo?.accuracyM,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,28 +182,6 @@ 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(
|
|
||||||
when (selected.urgency) {
|
|
||||||
"URGENT" -> "Urgencia: Urgente"
|
|
||||||
"NON_URGENT" -> "Urgencia: No urgente"
|
|
||||||
else -> "Urgencia: se define al cerrar el Acta"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
|
||||||
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(
|
||||||
@@ -263,7 +235,14 @@ 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 Acta, el contenido y los Hallazgos quedan inmutables. Definí ahora una única urgencia para el Acta completa; no para cada Hallazgo.")
|
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.")
|
||||||
|
OutlinedTextField(
|
||||||
|
actNarrative,
|
||||||
|
{ actNarrative = it },
|
||||||
|
label = { Text("Descripción de lo actuado *") },
|
||||||
|
minLines = 4,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { closingUrgency = "NON_URGENT" },
|
onClick = { closingUrgency = "NON_URGENT" },
|
||||||
@@ -275,53 +254,42 @@ fun MobileActsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
|
||||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
) { Text("Cerrar contenido y pasar a firma") }
|
||||||
}
|
}
|
||||||
|
|
||||||
"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"
|
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
|
||||||
|
|
||||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.")
|
||||||
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
Text("Acompañante / representante de la empresa", 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") "Firma en disconformidad" else "Firma en conformidad"
|
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
|
||||||
"REFUSED" -> "Negativa a firmar"
|
"REFUSED" -> "Se negó 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") {
|
||||||
Text(
|
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
|
||||||
"El representante fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.")
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
|
||||||
)
|
OutlinedButton(
|
||||||
|
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
|
||||||
|
enabled = !model.busy && recordedAbsence.trim().length >= 10,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) { Text("Confirmar ausencia documentada") }
|
||||||
} 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 disconformidad" else "En disconformidad") })
|
||||||
@@ -336,11 +304,10 @@ fun MobileActsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
SignaturePad(
|
SignaturePad(
|
||||||
label = "Firma del representante de la empresa",
|
label = "Firma del representante de la empresa (acompañante)",
|
||||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
onCaptured = { file -> signCompanyWithGeo(file) },
|
||||||
)
|
)
|
||||||
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 },
|
||||||
@@ -350,23 +317,20 @@ fun MobileActsScreen(
|
|||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
enabled = !model.busy && refusalReason.trim().length >= 10,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Registrar negativa a firmar") }
|
) { Text("Registrar negativa a firmar") }
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
if (inspectorSigned && companyResolved) {
|
if (companyResolved) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.closeSelectedAct() },
|
onClick = { model.closeSelectedAct() },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("SELLAR Acta definitivamente") }
|
) { Text("Aplicar mi firma y cerrar Acta") }
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall)
|
||||||
"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,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -86,21 +85,33 @@ fun MobileHomeScreen(model: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||||
Row(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
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(
|
||||||
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
|
Modifier.fillMaxWidth(),
|
||||||
Text("Actualizar")
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
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() }, enabled = !opening) { Text("Salir") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+162
-139
@@ -3,7 +3,6 @@ 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
|
||||||
@@ -77,40 +76,32 @@ fun ModernMobileActsScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||||
var attendance by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT") }
|
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 fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
|
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
|
||||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
|
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
|
||||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
|
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
|
||||||
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
|
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
|
||||||
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
|
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
|
||||||
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
|
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.orEmpty()) }
|
||||||
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
|
|
||||||
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
var 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 signWithGeo(file: File, company: Boolean) {
|
fun signCompanyWithGeo(file: File) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
|
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
|
||||||
if (company) {
|
model.signSelectedActAsCompany(
|
||||||
model.signSelectedActAsCompany(
|
png = file,
|
||||||
png = file,
|
latitude = geo?.latitude,
|
||||||
latitude = geo?.latitude,
|
longitude = geo?.longitude,
|
||||||
longitude = geo?.longitude,
|
accuracyM = geo?.accuracyM,
|
||||||
accuracyM = geo?.accuracyM,
|
manifestation = manifestation,
|
||||||
manifestation = manifestation,
|
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
)
|
||||||
)
|
|
||||||
} else {
|
|
||||||
model.signSelectedActAsInspector(
|
|
||||||
png = file,
|
|
||||||
latitude = geo?.latitude,
|
|
||||||
longitude = geo?.longitude,
|
|
||||||
accuracyM = geo?.accuracyM,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +139,29 @@ 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(
|
||||||
@@ -172,12 +186,8 @@ 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.surface,
|
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
tonalElevation = if (active) 1.dp else 0.dp,
|
tonalElevation = 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),
|
||||||
@@ -245,33 +255,6 @@ 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(
|
|
||||||
when (selected.urgency) {
|
|
||||||
"URGENT" -> "Urgencia · Urgente"
|
|
||||||
"NON_URGENT" -> "Urgencia · No urgente"
|
|
||||||
else -> "Urgencia · Se define al cerrar el Acta"
|
|
||||||
},
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
|
|
||||||
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()) {
|
||||||
@@ -296,37 +279,23 @@ fun ModernMobileActsScreen(
|
|||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Text(
|
||||||
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
|
"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 == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
|
style = MaterialTheme.typography.bodySmall,
|
||||||
}
|
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("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
|
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
|
||||||
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
|
||||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Guardar representante") }
|
|
||||||
} else {
|
|
||||||
OutlinedTextField(
|
|
||||||
absenceReason,
|
|
||||||
{ absenceReason = it },
|
|
||||||
label = { Text("Motivo de ausencia *") },
|
|
||||||
minLines = 2,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
|
|
||||||
enabled = !model.busy && absenceReason.trim().length >= 10,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Guardar ausencia") }
|
|
||||||
}
|
}
|
||||||
|
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") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,9 +307,17 @@ fun ModernMobileActsScreen(
|
|||||||
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. En este momento definí la urgencia del Acta completa. La urgencia no se asigna a cada Hallazgo.",
|
"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.",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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)
|
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -364,67 +341,103 @@ fun ModernMobileActsScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.prepareSelectedAct(closingUrgency) },
|
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
|
||||||
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank(),
|
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar Acta y dejar pendiente de firma") }
|
) { Text("Cerrar contenido y pasar a firma") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"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"
|
val companyResolvedOnServer = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "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.Lock, null, tint = MaterialTheme.colorScheme.secondary)
|
Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
|
Text(
|
||||||
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
|
"El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
HorizontalDivider()
|
)
|
||||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
when (model.inspectorSignatureConfigured) {
|
||||||
if (inspectorSigned) {
|
true -> Surface(
|
||||||
SuccessLine("Firma del inspector registrada")
|
modifier = Modifier.fillMaxWidth(),
|
||||||
} else {
|
shape = MaterialTheme.shapes.medium,
|
||||||
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
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) {
|
|
||||||
val detail = when (companyOutcome.status) {
|
|
||||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disconformidad" else "Firma en conformidad"
|
|
||||||
"REFUSED" -> "Negativa a firmar"
|
|
||||||
else -> humanBackendLabelEs(companyOutcome.status)
|
|
||||||
}
|
|
||||||
SuccessLine(detail)
|
|
||||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
|
||||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
|
||||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
|
||||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
|
|
||||||
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
|
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
Text(
|
Text(
|
||||||
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
|
"Firma del inspector configurada en Mi perfil. Se aplicará automáticamente al cierre definitivo de esta Acta.",
|
||||||
Modifier.weight(1f),
|
Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
false -> Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
|
||||||
|
Text(
|
||||||
|
"Falta configurar la firma del inspector en Mi perfil del Dashboard. La firma de la empresa puede guardarse igual; el cierre quedará pendiente hasta completar este requisito.",
|
||||||
|
Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { model.refreshInspectorSignatureStatus() }, enabled = !model.busy) {
|
||||||
|
Text("Volver a verificar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null -> Text(
|
||||||
|
"La firma del inspector se validará con el Dashboard al sincronizar.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
|
||||||
|
if (companyOutcome != null && companyResolvedOnServer) {
|
||||||
|
val detail = when (companyOutcome.status) {
|
||||||
|
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
|
||||||
|
"REFUSED" -> "Se negó a firmar"
|
||||||
|
"ABSENT" -> "Ausencia documentada"
|
||||||
|
else -> humanBackendLabelEs(companyOutcome.status)
|
||||||
|
}
|
||||||
|
SuccessLine(detail)
|
||||||
|
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
} else if (model.selectedActCompanySignaturePending) {
|
||||||
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
|
SuccessLine("Firma guardada en el dispositivo")
|
||||||
|
Text(
|
||||||
|
"Todavía no llegó al servidor. Podés continuar; se enviará automáticamente cuando haya conexión.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||||
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
|
||||||
|
Text(
|
||||||
|
"Esta Acta conserva un registro histórico de ausencia. El flujo actual requiere identificar al acompañante antes de pasar el contenido a firma.",
|
||||||
|
Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} 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 disconformidad") })
|
||||||
@@ -439,11 +452,11 @@ fun ModernMobileActsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
SignaturePad(
|
SignaturePad(
|
||||||
label = "Firma del representante de la empresa",
|
label = "Firma del representante de la empresa (acompañante)",
|
||||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
onCaptured = { file -> signCompanyWithGeo(file) },
|
||||||
)
|
)
|
||||||
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
|
Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", style = MaterialTheme.typography.bodySmall)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
refusalReason,
|
refusalReason,
|
||||||
{ refusalReason = it },
|
{ refusalReason = it },
|
||||||
@@ -453,24 +466,34 @@ fun ModernMobileActsScreen(
|
|||||||
)
|
)
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
enabled = !model.busy && refusalReason.trim().length >= 10,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Registrar negativa a firmar") }
|
) { Text("Registrar negativa a firmar") }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inspectorSigned && companyResolved) {
|
if (companyResolved) {
|
||||||
Button(
|
if (model.selectedActSealPending) {
|
||||||
onClick = { model.closeSelectedAct() },
|
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
|
||||||
enabled = !model.busy,
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
"Cierre guardado en este dispositivo · pendiente de sincronización",
|
||||||
) {
|
Modifier.padding(12.dp),
|
||||||
Icon(Icons.Filled.CheckCircle, null)
|
fontWeight = FontWeight.SemiBold,
|
||||||
Spacer(Modifier.width(6.dp))
|
)
|
||||||
Text("Firmar y cerrar 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(
|
||||||
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
|
"Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ internal fun visitStatusLabelEs(status: String?): String = when (status?.upperca
|
|||||||
|
|
||||||
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
|
||||||
"DRAFT" -> "En elaboración"
|
"DRAFT" -> "En elaboración"
|
||||||
"LOCKED" -> "Pendiente de firma"
|
"LOCKED" -> "Para firmar"
|
||||||
"SEALED" -> "Firmada y cerrada"
|
"SEALED" -> "Firmada y cerrada"
|
||||||
"CLOSED" -> "Cerrada"
|
"CLOSED" -> "Cerrada"
|
||||||
"RECTIFIED" -> "Rectificada"
|
"RECTIFIED" -> "Rectificada"
|
||||||
|
|||||||
+3
-3
@@ -20,15 +20,15 @@ class FinalFieldFlowContractTest {
|
|||||||
assertTrue(visit.contains("Instalación"))
|
assertTrue(visit.contains("Instalación"))
|
||||||
assertTrue(visit.contains("Subinstalación"))
|
assertTrue(visit.contains("Subinstalación"))
|
||||||
assertTrue(visit.contains("Guardar y tomar foto"))
|
assertTrue(visit.contains("Guardar y tomar foto"))
|
||||||
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
|
assertTrue(acts.contains("Cerrar contenido y pasar a firma"))
|
||||||
assertTrue(acts.contains("Nombres y apellidos *"))
|
assertTrue(acts.contains("Nombres y apellidos *"))
|
||||||
assertTrue(acts.contains("Cargo / función *"))
|
assertTrue(acts.contains("Cargo / función *"))
|
||||||
assertTrue(acts.contains("Email *"))
|
assertTrue(acts.contains("Email *"))
|
||||||
assertTrue(acts.contains("En disconformidad"))
|
assertTrue(acts.contains("En disconformidad"))
|
||||||
assertTrue(acts.contains("Motivo de disconformidad *"))
|
assertTrue(acts.contains("Motivo de disconformidad *"))
|
||||||
assertTrue(acts.contains("urgencia del Acta completa"))
|
assertTrue(acts.contains("urgencia pertenece al Acta completa"))
|
||||||
assertTrue(acts.contains("urgencia no se asigna a cada Hallazgo"))
|
assertTrue(acts.contains("urgencia no se asigna a cada Hallazgo"))
|
||||||
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
|
assertTrue(labels.contains("\"LOCKED\" -> \"Para firmar\""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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(35, BuildConfig.VERSION_CODE)
|
assertEquals(40, BuildConfig.VERSION_CODE)
|
||||||
assertEquals("0.19.7-debug", BuildConfig.VERSION_NAME)
|
assertEquals("0.19.12-debug", BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ 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,6 +4,7 @@ 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
|
||||||
@@ -13,6 +14,7 @@ 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.
@@ -0,0 +1,78 @@
|
|||||||
|
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.
|
After Width: | Height: | Size: 14 KiB |
Generated
+742
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-5",
|
"version": "0.29.0-13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-5",
|
"version": "0.29.0-13",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
@@ -21,9 +21,11 @@
|
|||||||
"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": {
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"@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"
|
||||||
@@ -216,6 +219,16 @@
|
|||||||
"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",
|
||||||
@@ -664,6 +677,506 @@
|
|||||||
"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",
|
||||||
@@ -1459,6 +1972,30 @@
|
|||||||
"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",
|
||||||
@@ -1484,6 +2021,15 @@
|
|||||||
"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",
|
||||||
@@ -1659,6 +2205,16 @@
|
|||||||
"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",
|
||||||
@@ -2185,6 +2741,15 @@
|
|||||||
"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",
|
||||||
@@ -2805,6 +3370,21 @@
|
|||||||
"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",
|
||||||
@@ -3156,7 +3736,6 @@
|
|||||||
"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": {
|
||||||
@@ -3189,6 +3768,12 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@@ -3228,6 +3813,32 @@
|
|||||||
"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",
|
||||||
@@ -3906,6 +4517,25 @@
|
|||||||
"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",
|
||||||
@@ -4388,6 +5018,12 @@
|
|||||||
"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",
|
||||||
@@ -4475,6 +5111,20 @@
|
|||||||
"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",
|
||||||
@@ -4594,6 +5244,14 @@
|
|||||||
"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",
|
||||||
@@ -4793,6 +5451,12 @@
|
|||||||
"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",
|
||||||
@@ -4997,6 +5661,55 @@
|
|||||||
"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",
|
||||||
@@ -5437,6 +6150,12 @@
|
|||||||
"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",
|
||||||
@@ -5887,6 +6606,26 @@
|
|||||||
"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",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.29.0-5",
|
"version": "0.29.0-13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,9 +28,11 @@
|
|||||||
"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": {
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"@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,14 +1,9 @@
|
|||||||
|
|
||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import { Controller, Get, Param, ParseUUIDPipe, Query, Res } 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 type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import { ActAdministrationService } from './act-administration.service';
|
||||||
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 {
|
||||||
@@ -21,11 +16,8 @@ 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); }
|
||||||
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
|
// Historic deadlines and responses remain readable here.
|
||||||
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); }
|
// New administrative entries are recorded on the related Informe.
|
||||||
@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')
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
|
|||||||
FieldBriefingController,
|
FieldBriefingController,
|
||||||
],
|
],
|
||||||
providers: [ActAdministrationService, FieldBriefingService],
|
providers: [ActAdministrationService, FieldBriefingService],
|
||||||
|
exports: [ActAdministrationService],
|
||||||
})
|
})
|
||||||
export class ActAdministrationModule {}
|
export class ActAdministrationModule {}
|
||||||
|
|||||||
@@ -114,17 +114,46 @@ export class ActAdministrationService {
|
|||||||
return act;
|
return act;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
|
async setDeadline(
|
||||||
|
actId: string,
|
||||||
|
dto: SetActResponseDeadlineDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
reportId: string | null = null,
|
||||||
|
) {
|
||||||
const act = await this.ensureClosedAct(actId);
|
const act = await this.ensureClosedAct(actId);
|
||||||
const rows = await this.dataSource.query(
|
return this.dataSource.transaction(async (manager) => {
|
||||||
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
const rows = await manager.query(
|
||||||
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
`INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by)
|
||||||
);
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } });
|
RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
||||||
return rows[0];
|
[randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId],
|
||||||
|
);
|
||||||
|
const projected = await manager.query(
|
||||||
|
`UPDATE inspection_findings
|
||||||
|
SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE act_id=$1 AND status<>'VOIDED'
|
||||||
|
RETURNING id`,
|
||||||
|
[actId, dto.responseDueOn, principal.userId],
|
||||||
|
) as Array<{ id: string }>;
|
||||||
|
await this.audit.record({
|
||||||
|
actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET',
|
||||||
|
entityType: 'inspection_act', entityId: actId, requestId: request.requestId,
|
||||||
|
afterData: { actCode: act.code, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason },
|
||||||
|
metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true },
|
||||||
|
}, manager);
|
||||||
|
return { ...rows[0], projectedFindingCount: projected.length };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
async addResponse(
|
||||||
|
actId: string,
|
||||||
|
dto: CreateActCompanyResponseDto,
|
||||||
|
file: UploadedActResponseFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
reportId: string | null = null,
|
||||||
|
) {
|
||||||
const act = await this.ensureClosedAct(actId);
|
const act = await this.ensureClosedAct(actId);
|
||||||
let storedName: string | null = null;
|
let storedName: string | null = null;
|
||||||
let sha256: string | null = null;
|
let sha256: string | null = null;
|
||||||
@@ -138,12 +167,12 @@ export class ActAdministrationService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rows = await this.dataSource.query(
|
const rows = await this.dataSource.query(
|
||||||
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
`INSERT INTO inspection_act_company_responses (id, act_id, report_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
RETURNING id, report_id AS "reportId", received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
||||||
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
[randomUUID(), actId, reportId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
||||||
);
|
);
|
||||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } });
|
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } });
|
||||||
return rows[0];
|
return rows[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
||||||
@@ -151,8 +180,13 @@ export class ActAdministrationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async responseContent(responseId: string) {
|
async responseContent(responseId: string, reportId?: string) {
|
||||||
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]);
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes"
|
||||||
|
FROM inspection_act_company_responses
|
||||||
|
WHERE id=$1 AND stored_name IS NOT NULL AND ($2::uuid IS NULL OR report_id=$2::uuid)`,
|
||||||
|
[responseId, reportId ?? null],
|
||||||
|
);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
||||||
const filePath = join(STORAGE, row.storedName);
|
const filePath = join(STORAGE, row.storedName);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -9,7 +10,12 @@ 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 { 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';
|
||||||
@@ -19,6 +25,10 @@ import type {
|
|||||||
} from '../../common/http/request-context';
|
} from '../../common/http/request-context';
|
||||||
import { AuditAction } from '../../database/entities';
|
import { AuditAction } from '../../database/entities';
|
||||||
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
|
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 { 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';
|
||||||
@@ -58,6 +68,45 @@ export class UsersController {
|
|||||||
return this.users.updateSelfProfile(dto, principal, request);
|
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')
|
@Get('self/smtp')
|
||||||
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
|
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
|
||||||
return this.smtp.publicUserSettings(principal.userId);
|
return this.smtp.publicUserSettings(principal.userId);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -31,6 +33,10 @@ 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 { 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;
|
||||||
@@ -38,6 +44,14 @@ 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;
|
||||||
@@ -192,6 +206,87 @@ export class UsersService {
|
|||||||
return this.getById(userId);
|
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(
|
async updateSelfProfile(
|
||||||
dto: UpdateSelfProfileDto,
|
dto: UpdateSelfProfileDto,
|
||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
|
|||||||
@@ -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 una familia técnica específica.',
|
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.',
|
||||||
canBeRoot: false,
|
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'],
|
||||||
|
|||||||
@@ -756,6 +756,7 @@ 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,
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 la clasificación de Inventario');
|
if (!inserted) throw new Error('No se pudo crear el tipo de Inventario');
|
||||||
await this.replaceParents(manager, inserted.id, parentIds);
|
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 la clasificación',
|
message:'Ya existe un campo técnico con ese código en el tipo',
|
||||||
});
|
});
|
||||||
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:'La clasificación de Inventario no existe',
|
code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo 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:'Una clasificación de Instalación no lleva compatibilidades padre',
|
message:'Un tipo 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:'Una clasificación no puede ser compatible consigo misma',
|
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo',
|
||||||
});
|
});
|
||||||
const [count]=(await manager.query(`
|
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:'La clasificación admite hasta 100 campos de información',
|
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'El tipo admite hasta 100 campos de información',
|
||||||
});
|
});
|
||||||
return [...unique.values()];
|
return [...unique.values()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
? `Clasificación técnica: ${family.code} · ${family.name}`
|
? `Tipo: ${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 clasificación técnica',
|
message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación',
|
||||||
});
|
});
|
||||||
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í la clasificación técnica de la ${kind.toLowerCase()}`,
|
message: `Elegí el tipo 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: 'La clasificación técnica no existe' });
|
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación no existe' });
|
||||||
if (family.level !== expectedLevel) throw new BadRequestException({
|
if (family.level !== expectedLevel) throw new BadRequestException({
|
||||||
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||||
message: 'La clasificación técnica no corresponde al nivel seleccionado',
|
message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado',
|
||||||
});
|
});
|
||||||
if (kind === 'SUBINSTALACION') {
|
if (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 una clasificación técnica válida',
|
message:'La Instalación padre debe tener un Tipo de instalación válido',
|
||||||
});
|
});
|
||||||
const [compatible]=(await manager.query(`
|
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 la clasificación de la Instalación seleccionada',
|
message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return family;
|
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 a la clasificación actual',
|
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen al tipo actual',
|
||||||
});
|
});
|
||||||
const value=this.normalize(definition,raw);
|
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 clasificación técnica y no admite campos técnicos por rubro',
|
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene Tipo de instalación/subinstalación y no admite campos técnicos por rubro',
|
||||||
});
|
});
|
||||||
const definitions=(await manager.query(`
|
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",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
|
||||||
|
name = 'F611ReportResponseWorkflow1790139000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION validate_report_act_relation()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.report_id IS NOT NULL AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_reports report
|
||||||
|
WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'El Informe no corresponde al Acta indicada';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`);
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`);
|
||||||
|
await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F612AdminReportPermission1790142600000 implements MigrationInterface {
|
||||||
|
name = 'F612AdminReportPermission1790142600000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO role_permissions(role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM roles role
|
||||||
|
JOIN permissions permission ON permission.code='inspection_reports.generate'
|
||||||
|
WHERE role.code='admin'
|
||||||
|
ON CONFLICT(role_id, permission_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM role_permissions role_permission
|
||||||
|
USING roles role, permissions permission
|
||||||
|
WHERE role_permission.role_id=role.id
|
||||||
|
AND role_permission.permission_id=permission.id
|
||||||
|
AND role.code='admin'
|
||||||
|
AND permission.code='inspection_reports.generate'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateInspectionActDto {
|
export class CreateInspectionActDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
clientGeneratedId?: string;
|
||||||
|
|
||||||
@IsISO8601({ strict: true })
|
@IsISO8601({ strict: true })
|
||||||
occurredAt!: string;
|
occurredAt!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ 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) {
|
||||||
|
|||||||
@@ -278,6 +278,48 @@ 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,
|
||||||
@@ -289,6 +331,22 @@ 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);
|
||||||
@@ -305,6 +363,7 @@ 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,
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ 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,
|
||||||
@@ -296,12 +295,6 @@ 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'],
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
type UploadedInspectionSignatureFile,
|
type UploadedInspectionSignatureFile,
|
||||||
} from './inspection-signature-file';
|
} from './inspection-signature-file';
|
||||||
|
|
||||||
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4';
|
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V5';
|
||||||
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,6 +103,14 @@ 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;
|
||||||
@@ -306,21 +314,24 @@ export class InspectionClosingService {
|
|||||||
const lockedAt = new Date();
|
const lockedAt = new Date();
|
||||||
|
|
||||||
const [updated] = (await manager.query(`
|
const [updated] = (await manager.query(`
|
||||||
UPDATE inspection_acts
|
WITH updated AS (
|
||||||
SET status='LOCKED',
|
UPDATE inspection_acts
|
||||||
urgency=$2,
|
SET status='LOCKED',
|
||||||
deadline_days=$3,
|
urgency=$2,
|
||||||
deadline_day_type=$4,
|
deadline_days=$3,
|
||||||
deadline_basis=$5,
|
deadline_day_type=$4,
|
||||||
deadline_base_at=$6,
|
deadline_basis=$5,
|
||||||
deadline_at=$7,
|
deadline_base_at=$6,
|
||||||
locked_at=$8,
|
deadline_at=$7,
|
||||||
locked_by=$9,
|
locked_at=$8,
|
||||||
current_version=current_version+1,
|
locked_by=$9,
|
||||||
updated_by=$9,
|
current_version=current_version+1,
|
||||||
updated_at=$8
|
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,
|
dto.urgency,
|
||||||
@@ -377,7 +388,7 @@ export class InspectionClosingService {
|
|||||||
afterData: {
|
afterData: {
|
||||||
status: InspectionActStatus.LOCKED,
|
status: InspectionActStatus.LOCKED,
|
||||||
lockedSha256: preparedSha256,
|
lockedSha256: preparedSha256,
|
||||||
urgency: act.urgency,
|
urgency: dto.urgency,
|
||||||
deadlineDays,
|
deadlineDays,
|
||||||
deadlineDayType,
|
deadlineDayType,
|
||||||
deadlineBasis,
|
deadlineBasis,
|
||||||
@@ -461,6 +472,13 @@ 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',
|
||||||
@@ -532,22 +550,15 @@ export class InspectionClosingService {
|
|||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
): Promise<InspectionClosureView> {
|
): Promise<InspectionClosureView> {
|
||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
const sealed = await this.dataSource.transaction(async (manager) => {
|
let generatedInspectorSignaturePath: string | null = null;
|
||||||
|
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);
|
||||||
const signatures = await this.loadSignatures(manager, actId);
|
let 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,
|
||||||
);
|
);
|
||||||
@@ -557,6 +568,17 @@ 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);
|
||||||
@@ -619,18 +641,21 @@ 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(`
|
||||||
UPDATE inspection_acts
|
WITH updated AS (
|
||||||
SET status='SEALED',
|
UPDATE inspection_acts
|
||||||
sealed_at=$2,
|
SET status='SEALED',
|
||||||
sealed_by=$3,
|
sealed_at=$2,
|
||||||
closed_at=$2,
|
sealed_by=$3,
|
||||||
closed_by=$3,
|
closed_at=$2,
|
||||||
closure_sha256=$4,
|
closed_by=$3,
|
||||||
current_version=current_version+1,
|
closure_sha256=$4,
|
||||||
updated_by=$3,
|
current_version=current_version+1,
|
||||||
updated_at=$2
|
updated_by=$3,
|
||||||
WHERE id=$1
|
updated_at=$2
|
||||||
RETURNING current_version AS "versionNumber"
|
WHERE id=$1
|
||||||
|
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 (
|
||||||
@@ -664,7 +689,11 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -867,6 +896,8 @@ 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,
|
||||||
@@ -881,6 +912,7 @@ 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,
|
||||||
@@ -893,43 +925,74 @@ 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(`
|
||||||
SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion",
|
WITH RECURSIVE selected AS (
|
||||||
type.code AS "typeCode",type.name AS "typeName"
|
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,
|
||||||
FROM inspection_act_assets link
|
family.code AS family_code,family.name AS family_name
|
||||||
JOIN assets asset ON asset.id=link.asset_id
|
FROM inspection_act_assets link
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
WHERE link.act_id=$1 AND link.included=true
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
ORDER BY asset.code,asset.id
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_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
|
SELECT finding.id,finding.finding_number AS "findingNumber",finding.code,finding.status,
|
||||||
finding.id,
|
finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",finding.title,finding.description,
|
||||||
finding.finding_number AS "findingNumber",
|
finding.legal_basis AS "legalBasis",finding.glossary,finding.catalog_revision AS "catalogRevision",
|
||||||
finding.code,
|
finding.suggested_severity AS "suggestedSeverity",finding.severity,
|
||||||
finding.status,
|
finding.is_recurrence AS "isRecurrence",finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||||
finding.asset_id AS "assetId",
|
finding.correction_due_on AS "correctionDueOn",finding.current_version AS "currentVersion",
|
||||||
finding.catalog_item_id AS "catalogItemId",
|
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.title,
|
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.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 {
|
return { schemaVersion: CLOSURE_SCHEMA_VERSION, lockedAt: lockedAt.toISOString(), act: act.act, context, responsible, inventories, findings };
|
||||||
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> {
|
||||||
@@ -1171,6 +1234,82 @@ 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,6 +16,10 @@ 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,6 +16,10 @@ 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 una clasificación técnica activa.',
|
configurationReason: 'El elemento todavía no tiene tipo de instalación/subinstalación activo.',
|
||||||
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 a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
configurationReason: `Hallazgos asociados al tipo ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
||||||
categories,
|
categories,
|
||||||
items,
|
items,
|
||||||
other,
|
other,
|
||||||
|
|||||||
@@ -153,6 +153,22 @@ 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);
|
||||||
|
|
||||||
@@ -210,12 +226,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
|
device_label, source, uploaded_by, client_operation_id
|
||||||
) 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
|
$18, $19, $20, $21
|
||||||
)
|
)
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
@@ -238,6 +254,7 @@ 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,6 +398,22 @@ 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
|
||||||
@@ -418,6 +434,7 @@ 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,
|
||||||
@@ -612,6 +629,13 @@ 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);
|
||||||
@@ -1166,10 +1190,13 @@ export class InspectionFindingsService {
|
|||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const [row] = await manager.query(`
|
const [row] = await manager.query(`
|
||||||
UPDATE inspection_findings
|
WITH updated AS (
|
||||||
SET current_version = current_version + 1
|
UPDATE inspection_findings
|
||||||
WHERE id = $1
|
SET current_version = current_version + 1
|
||||||
RETURNING current_version AS "versionNumber"
|
WHERE id = $1
|
||||||
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetInspectionReportResponseDeadlineDto {
|
||||||
|
@IsDateString()
|
||||||
|
responseDueOn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(1000)
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
@@ -1,214 +1,300 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import PDFDocument = require('pdfkit');
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
export interface ActPdfImage {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
id: string;
|
||||||
? value as Record<string, unknown>
|
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> {
|
||||||
|
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 {
|
||||||
function text(value: unknown, fallback = '-'): string {
|
return String(value ?? '').trim() || fallback;
|
||||||
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()) ? parsed.toLocaleDateString('es-AR') : '-';
|
return Number.isFinite(parsed.getTime())
|
||||||
|
? 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ó';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clean(value: string): string {
|
// %PDF-1.4 is the document-version contract for consolidated Actas.
|
||||||
return value
|
export async function buildInspectionActPdf(
|
||||||
.normalize('NFKD')
|
snapshot: Record<string, unknown>,
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
images: ActPdfImage[] = [],
|
||||||
.replace(/[–—]/g, '-')
|
fallbackContext: ActPdfContext = {},
|
||||||
.replace(/[“”]/g, '"')
|
): Promise<{ buffer: Buffer; sha256: string }> {
|
||||||
.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 ?? asRecord(act).visit);
|
const inspection = asRecord(act.inspection ?? 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 ?? locked.assets);
|
const inventories = asArray(locked.inventories);
|
||||||
const findings = asArray(locked.findings);
|
const findings = asArray(locked.findings);
|
||||||
const signatures = source.signatures;
|
const signatures = asArray(sealed.signatures);
|
||||||
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
const seal = asRecord(sealed.seal);
|
||||||
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
const hash = text(sealed.finalSha256 ?? sealed.lockedSha256);
|
||||||
|
const logo = resolve(process.cwd(), 'assets/logo-mendoza.png');
|
||||||
|
|
||||||
const deadlineText = act.deadlineAt
|
const doc = new PDFDocument({ size: 'A4', pdfVersion: '1.4', margins: { top: 146, bottom: 80, left: 54, right: 54 }, compress: true });
|
||||||
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
doc.registerFont('body', resolve(process.cwd(), 'assets/fonts/DejaVuSans.ttf'));
|
||||||
: act.deadlineBasis === 'GEDO_DATE'
|
doc.registerFont('body-bold', resolve(process.cwd(), 'assets/fonts/DejaVuSans-Bold.ttf'));
|
||||||
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
const chunks: Buffer[] = [];
|
||||||
: '-';
|
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 out: string[] = [
|
const header = () => {
|
||||||
'ACTA DE INSPECCION',
|
doc.font('body-bold').fillColor(blue).fontSize(11).text('MINISTERIO DE ENERGÍA Y AMBIENTE', 54, 42);
|
||||||
'',
|
doc.text('DIRECCIÓN DE HIDROCARBUROS', 54, 58);
|
||||||
`Acta: ${text(act.code)}`,
|
if (existsSync(logo)) doc.image(logo, 474, 32, { fit: [62, 82] });
|
||||||
`Inspeccion: ${text(inspection.code)}`,
|
doc.moveTo(54, 121).lineTo(540, 121).strokeColor('#BAC5DA').stroke();
|
||||||
`Fecha: ${date(act.occurredAt)}`,
|
doc.y = 146;
|
||||||
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
};
|
||||||
`Plazo: ${deadlineText}`,
|
doc.on('pageAdded', header);
|
||||||
`Representante de la empresa: ${text(responsible.fullName)}`,
|
header();
|
||||||
`DNI: ${text(responsible.documentNumber)}`,
|
|
||||||
`Cargo / funcion: ${text(responsible.position)}`,
|
|
||||||
`Email: ${text(responsible.email)}`,
|
|
||||||
'',
|
|
||||||
'RESUMEN',
|
|
||||||
...wrap(text(act.summary)),
|
|
||||||
'',
|
|
||||||
'OBSERVACIONES',
|
|
||||||
...wrap(text(act.observations)),
|
|
||||||
'',
|
|
||||||
'INVENTARIO INSPECCIONADO',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!inventories.length) out.push('-');
|
const need = (height: number) => { if (doc.y + height > doc.page.height - 85) doc.addPage(); };
|
||||||
for (const item of inventories) {
|
const section = (label: string) => {
|
||||||
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
need(46);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push('', 'HALLAZGOS');
|
section('5. Hallazgos');
|
||||||
if (!findings.length) out.push('Sin hallazgos registrados.');
|
if (!findings.length) body('No se registraron hallazgos en esta Acta.');
|
||||||
for (const item of findings) {
|
for (const finding of findings) {
|
||||||
const recurrence = item.isRecurrence
|
const inventory = inventories.find((item) => text(item.id) === text(finding.assetId)) ?? {};
|
||||||
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
const catalog = asRecord(finding.catalog);
|
||||||
: '';
|
const recurrence = asRecord(finding.recurrence);
|
||||||
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
const path = asArray(inventory.path);
|
||||||
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
const route = path.map((node) => `${text(node.typeName)}: ${text(node.name)}${text(node.code) ? ` [${text(node.code)}]` : ''}`).filter(Boolean).join(' > ');
|
||||||
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
const number = text(finding.findingNumber, '?');
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push('', 'FIRMAS Y CONSTANCIAS');
|
section('6. Firmas y manifestaciones');
|
||||||
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
for (const signature of signatures) {
|
||||||
for (const signature of inspectorSignatures) {
|
const name = text(signature.signerName);
|
||||||
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
const inspector = text(signature.signerType) === 'INSPECTOR';
|
||||||
}
|
const role = inspector ? 'Inspector/a' : 'Representante de la empresa';
|
||||||
if (!companySignature) {
|
subheading(`${role}: ${name}`);
|
||||||
out.push('Manifestacion de empresa: pendiente.');
|
const signedDocument = [text(signature.documentType), text(signature.documentNumber)].filter(Boolean).join(' ');
|
||||||
} else if (text(companySignature.status, '') === 'SIGNED') {
|
if (signedDocument) label('Documento', signedDocument);
|
||||||
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
label('Cargo o función', signature.position);
|
||||||
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disconformidad' : 'Empresa: firma en conformidad');
|
label('Resultado', signatureStatus(signature.status));
|
||||||
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
if (!inspector && text(signature.companyManifestation)) label('Manifestación', manifestation(signature.companyManifestation));
|
||||||
} else {
|
if (signature.companyManifestation === 'DISSENT') label('Fundamento de la disconformidad', signature.companyStatement);
|
||||||
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
if (text(signature.status) === 'REFUSED' || text(signature.status) === 'ABSENT') label('Motivo', signature.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(
|
section('7. Integridad y cierre');
|
||||||
'',
|
label('Contenido bloqueado', date(locked.lockedAt));
|
||||||
'INTEGRIDAD',
|
label('Sellado en servidor', date(seal.serverSealedAt));
|
||||||
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
label('Fecha informada por dispositivo', date(seal.deviceSealedAt));
|
||||||
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
label('Modo de cierre', seal.uploadMode);
|
||||||
);
|
body(`SHA-256 del cierre: ${hash}`);
|
||||||
return out;
|
body(`SHA-256 del contenido bloqueado: ${text(sealed.lockedSha256)}`);
|
||||||
}
|
need(28);
|
||||||
|
doc.fontSize(8).fillColor(muted).text(`Acta ${text(act.code)} · plantilla consolidada v3 · ${date(seal.serverSealedAt)}`, 54, doc.y);
|
||||||
|
|
||||||
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
doc.end();
|
||||||
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
const buffer = await done;
|
||||||
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,11 +4,15 @@ 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 } from './inspection-act-pdf-builder';
|
import { buildInspectionActPdf, type ActPdfContext, type ActPdfImage } 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')
|
||||||
@@ -16,6 +20,9 @@ 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> {
|
||||||
@@ -59,7 +66,7 @@ export class InspectionActPdfService {
|
|||||||
`, [actId]);
|
`, [actId]);
|
||||||
try {
|
try {
|
||||||
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||||
const built = buildInspectionActPdf(snapshot);
|
const built = await buildInspectionActPdf(snapshot, await this.fieldImages(actId, false), await this.actContext(actId));
|
||||||
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`;
|
||||||
@@ -82,6 +89,154 @@ 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",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
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;
|
||||||
@@ -13,6 +15,10 @@ 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 = (() => {
|
||||||
@@ -68,7 +74,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>${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 xml:space="preserve">${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 {
|
||||||
@@ -102,75 +108,93 @@ function deadline(act: Record<string, unknown>): string {
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentXml(input: ReportWordInput): string {
|
function imageDimensions(buffer: Buffer): { width: number; height: number } {
|
||||||
|
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 findingRows = snapshot.findings.map((item) => [
|
const photos = input.photos ?? [];
|
||||||
text(item.code),
|
const authors = snapshot.signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR').map((item) => text(item.signerName)).join(', ');
|
||||||
text(item.title),
|
const legalBasis = [...new Set(snapshot.findings.map((item) => text(item.legalBasis, '')).filter(Boolean))];
|
||||||
text(item.description),
|
const quantity = `${snapshot.findings.length} ${snapshot.findings.length === 1 ? 'hallazgo' : 'hallazgos'}`;
|
||||||
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
const entries: string[] = [
|
||||||
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
paragraph('MINISTERIO DE ENERGÍA Y AMBIENTE'),
|
||||||
]);
|
paragraph('DIRECCIÓN DE HIDROCARBUROS'),
|
||||||
const inventoryRows = snapshot.inventories.map((item) => [
|
drawing(2, logo, 'Identidad institucional Mendoza', 594360, 1005840, true),
|
||||||
text(item.code),
|
paragraph(`Mendoza, ${input.generatedAt.toLocaleDateString('es-AR')}`),
|
||||||
text(item.name),
|
paragraph(`INFORME TÉCNICO ${input.code}`, 'Title'),
|
||||||
text(item.typeName ?? item.typeCode),
|
paragraph('Sr. Director de Hidrocarburos'),
|
||||||
]);
|
labelValue('Referencia', text(input.scopeName ?? snapshot.inventories[0]?.name, 'Instalación inspeccionada')),
|
||||||
const signatureRows = snapshot.signatures.map((item) => [
|
labelValue('Inspector/a', authors || 'No consignado'),
|
||||||
text(item.signerType),
|
labelValue('Acta', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||||
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('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
|
labelValue('Área o Yacimiento', text(input.scopeName ?? input.areaName)),
|
||||||
labelValue('Urgencia', urgency(snapshot.act.urgency)),
|
labelValue('Empresa inspeccionada', text(input.companyName)),
|
||||||
labelValue('Plazo', deadline(snapshot.act)),
|
paragraph('OBJETIVOS', 'Heading1'),
|
||||||
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
labelValue('General', 'Documentar los resultados de la inspección consignados en el Acta fuente.'),
|
||||||
paragraph('Resumen ejecutivo', 'Heading1'),
|
labelValue('Particular', 'Analizar los hallazgos y el estado de las instalaciones inspeccionadas para definir las acciones y verificaciones que correspondan.'),
|
||||||
paragraph(executive),
|
paragraph('MARCO LEGAL', 'Heading1'),
|
||||||
paragraph('Descripción / análisis técnico', 'Heading1'),
|
...(legalBasis.length ? legalBasis.map((basis) => paragraph(basis)) : [paragraph('No se consignó normativa específica en los hallazgos del Acta fuente.')]),
|
||||||
paragraph(description),
|
paragraph('DESCRIPCIÓN Y ANÁLISIS TÉCNICO', 'Heading1'),
|
||||||
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
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('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
paragraph('FOTOS Y HALLAZGOS', 'Heading1'),
|
||||||
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
];
|
||||||
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
if (!snapshot.findings.length) entries.push(paragraph('El Acta fuente no registra hallazgos.'));
|
||||||
labelValue('Representante de la empresa', text(snapshot.responsible.fullName)),
|
const shownPhotos = new Set<number>();
|
||||||
labelValue('DNI', text(snapshot.responsible.documentNumber)),
|
for (const finding of snapshot.findings) {
|
||||||
labelValue('Cargo / función', text(snapshot.responsible.position)),
|
entries.push(paragraph(`${text(finding.code)} ${text(finding.title)}`, 'Heading2'));
|
||||||
labelValue('Email', text(snapshot.responsible.email)),
|
entries.push(labelValue('Instalación', text(snapshot.inventories.find((item) => text(item.id) === text(finding.assetId))?.name)));
|
||||||
paragraph('Inventario inspeccionado', 'Heading1'),
|
entries.push(paragraph(text(finding.description)));
|
||||||
inventoryRows.length
|
if (finding.legalBasis) entries.push(labelValue('Normativa', text(finding.legalBasis)));
|
||||||
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
if (finding.severity != null) entries.push(labelValue('Gravedad', `${text(finding.severity)}/10`));
|
||||||
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
for (let index = 0; index < photos.length; index++) {
|
||||||
paragraph('Hallazgos', 'Heading1'),
|
const photo = photos[index]!;
|
||||||
findingRows.length
|
if (photo.findingId !== text(finding.id) && photo.assetId !== text(finding.assetId)) continue;
|
||||||
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
shownPhotos.add(index);
|
||||||
: paragraph('El Acta no contiene Hallazgos.'),
|
entries.push(drawing(index + 3, photo.buffer, photo.title || text(finding.title)));
|
||||||
paragraph('Firmas y manifestaciones', 'Heading1'),
|
entries.push(paragraph(`Fotografía vinculada · SHA-256 ${photo.sha256}`));
|
||||||
signatureRows.length
|
}
|
||||||
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
}
|
||||||
: paragraph('No se registraron firmas en la instantánea sellada.'),
|
const otherPhotos = photos.map((photo, index) => ({ photo, index }))
|
||||||
paragraph('Integridad de la fuente', 'Heading1'),
|
.filter(({ photo, index }) => photo.assetId && !shownPhotos.has(index));
|
||||||
labelValue('Hash de la fuente del INF', input.frozenSha256),
|
if (otherPhotos.length) {
|
||||||
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
entries.push(paragraph('OTRAS INSTALACIONES INSPECCIONADAS', 'Heading1'));
|
||||||
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
|
for (const { photo, index } of otherPhotos) {
|
||||||
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.'),
|
entries.push(labelValue('Instalación', text(photo.title)));
|
||||||
].join('');
|
entries.push(drawing(index + 3, photo.buffer, photo.title || 'Fotografía de instalación'));
|
||||||
|
entries.push(paragraph(`Fotografía de inventario · SHA-256 ${photo.sha256}`));
|
||||||
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('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 {
|
||||||
@@ -232,10 +256,13 @@ 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"/><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"/><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'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '_rels/.rels',
|
name: '_rels/.rels',
|
||||||
@@ -243,13 +270,15 @@ 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"/></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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/logo-mendoza.png"/>${mediaRelationships}</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: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'),
|
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'),
|
||||||
},
|
},
|
||||||
{ name: 'word/document.xml', data: Buffer.from(documentXml(input), 'utf8') },
|
{ name: 'word/document.xml', data: Buffer.from(documentXml(input, logo), '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,11 +5,16 @@ 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;
|
||||||
@@ -30,7 +35,7 @@ interface WordRow {
|
|||||||
export class InspectionReportWordService {
|
export class InspectionReportWordService {
|
||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
constructor(private readonly dataSource: DataSource, private readonly actPdf: InspectionActPdfService, 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');
|
||||||
@@ -48,6 +53,7 @@ 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,
|
||||||
@@ -56,6 +62,10 @@ 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`;
|
||||||
@@ -114,6 +124,69 @@ 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'
|
||||||
@@ -150,21 +223,26 @@ 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 id,code,title,
|
SELECT report.id,report.act_id AS "actId",report.code,report.title,
|
||||||
executive_summary AS "executiveSummary",
|
company.name AS "companyName",area.name AS "areaName",scope.name AS "scopeName",
|
||||||
report_description AS "reportDescription",
|
report.executive_summary AS "executiveSummary",
|
||||||
generated_at AS "generatedAt",
|
report.report_description AS "reportDescription",
|
||||||
frozen_sha256 AS "frozenSha256",
|
report.generated_at AS "generatedAt",
|
||||||
frozen_snapshot AS "frozenSnapshot",
|
report.frozen_sha256 AS "frozenSha256",
|
||||||
word_status AS "wordStatus",
|
report.frozen_snapshot AS "frozenSnapshot",
|
||||||
word_original_name AS "wordOriginalName",
|
report.word_status AS "wordStatus",
|
||||||
word_stored_name AS "wordStoredName",
|
report.word_original_name AS "wordOriginalName",
|
||||||
word_mime_type AS "wordMimeType",
|
report.word_stored_name AS "wordStoredName",
|
||||||
|
report.word_mime_type AS "wordMimeType",
|
||||||
word_size_bytes::integer AS "wordSizeBytes",
|
word_size_bytes::integer AS "wordSizeBytes",
|
||||||
word_sha256 AS "wordSha256",
|
report.word_sha256 AS "wordSha256",
|
||||||
generated_by AS "generatedBy"
|
report.generated_by AS "generatedBy"
|
||||||
FROM inspection_reports
|
FROM inspection_reports report
|
||||||
WHERE id=$1
|
JOIN inspection_visits visit ON visit.id=report.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 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({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
|
||||||
|
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||||
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||||
|
|
||||||
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly administration: ActAdministrationService,
|
||||||
config: ConfigService,
|
config: ConfigService,
|
||||||
) {
|
) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
||||||
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setResponseDeadline(
|
||||||
|
reportId: string,
|
||||||
|
dto: SetInspectionReportResponseDeadlineDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const report = await this.requireOfficializedReport(reportId);
|
||||||
|
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
|
||||||
|
await this.administration.setDeadline(
|
||||||
|
report.actId,
|
||||||
|
{ responseDueOn: dto.responseDueOn, reason },
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
report.id,
|
||||||
|
);
|
||||||
|
return this.getWorkflowView(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCompanyResponse(
|
||||||
|
reportId: string,
|
||||||
|
dto: CreateActCompanyResponseDto,
|
||||||
|
file: UploadedActResponseFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const report = await this.requireOfficializedReport(reportId);
|
||||||
|
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
|
||||||
|
return this.getWorkflowView(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async companyResponseContent(reportId: string, responseId: string) {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
return this.administration.responseContent(responseId, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -258,6 +298,23 @@ 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,
|
||||||
@@ -283,6 +340,15 @@ export class InspectionReportWorkflowService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const report = await this.lockReport(manager, reportId);
|
const report = await this.lockReport(manager, reportId);
|
||||||
|
if (
|
||||||
|
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
|
||||||
|
&& report.status !== InspectionReportStatus.OFFICIALIZED
|
||||||
|
) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
|
||||||
|
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
const occurredAt = new Date(dto.occurredAt);
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
INSERT INTO inspection_report_follow_ups (
|
INSERT INTO inspection_report_follow_ups (
|
||||||
@@ -390,6 +456,18 @@ export class InspectionReportWorkflowService {
|
|||||||
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
|
||||||
|
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
||||||
const [row] = await manager.query(`
|
const [row] = await manager.query(`
|
||||||
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
|
||||||
|
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
|
||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
@@ -21,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
|
|||||||
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
||||||
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
|
||||||
import {
|
import {
|
||||||
InspectionReportWorkflowService,
|
InspectionReportWorkflowService,
|
||||||
MAX_INSPECTION_REPORT_FILE_BYTES,
|
MAX_INSPECTION_REPORT_FILE_BYTES,
|
||||||
@@ -28,6 +32,7 @@ import {
|
|||||||
} from './inspection-report-workflow.service';
|
} from './inspection-report-workflow.service';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
|
|
||||||
@Controller('inspection-reports')
|
@Controller('inspection-reports')
|
||||||
export class InspectionReportsController {
|
export class InspectionReportsController {
|
||||||
@@ -68,6 +73,34 @@ export class InspectionReportsController {
|
|||||||
return response.sendFile(content.filePath);
|
return response.sendFile(content.filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/consolidated-word')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async consolidatedWordContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.word.consolidatedContent(id);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return response.sendFile(content.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/consolidated-word/revisions/:version')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async consolidatedWordRevision(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('version', ParseIntPipe) version: number,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.word.consolidatedRevisionContent(id, version);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
return response.sendFile(content.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/gedo-pdf')
|
@Get(':id/gedo-pdf')
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
async gedoPdfContent(
|
async gedoPdfContent(
|
||||||
@@ -106,12 +139,72 @@ export class InspectionReportsController {
|
|||||||
return this.workflow.officialize(id, dto, file, principal, request);
|
return this.workflow.officialize(id, dto, file, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/response-deadline')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
setResponseDeadline(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: SetInspectionReportResponseDeadlineDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.setResponseDeadline(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/company-responses')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
addCompanyResponse(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CreateActCompanyResponseDto,
|
||||||
|
@UploadedFile() file: UploadedActResponseFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/company-responses/:responseId/content')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async companyResponseContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const item = await this.workflow.companyResponseContent(id, responseId);
|
||||||
|
const safeName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', 'application/pdf');
|
||||||
|
response.setHeader('Content-Length', String(item.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/follow-ups')
|
@Get(':id/follow-ups')
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
return this.workflow.listFollowUps(id);
|
return this.workflow.listFollowUps(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/follow-ups/:followUpId/content')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async followUpContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Param('followUpId', new ParseUUIDPipe({ version: '4' })) followUpId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const file = await this.workflow.followUpContent(id, followUpId);
|
||||||
|
const safeName = file.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||||
|
response.setHeader('Content-Type', file.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(file.sizeBytes));
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(file.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/follow-ups')
|
@Post(':id/follow-ups')
|
||||||
@RequirePermissions('inspection_reports.generate')
|
@RequirePermissions('inspection_reports.generate')
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
@@ -148,3 +241,60 @@ export class InspectionActReportController {
|
|||||||
return this.reports.generate(actId, principal, request);
|
return this.reports.generate(actId, principal, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('inspection-acts/:actId/pdf')
|
||||||
|
export class InspectionActPdfController {
|
||||||
|
constructor(private readonly pdf: InspectionActPdfService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('inspection_acts.read')
|
||||||
|
async content(
|
||||||
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
await this.pdf.ensure(actId);
|
||||||
|
const content = await this.pdf.content(actId);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(content.buffer.length));
|
||||||
|
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return response.send(content.buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('inspection-acts/:actId/consolidated-pdf')
|
||||||
|
export class InspectionActConsolidatedPdfController {
|
||||||
|
constructor(private readonly pdf: InspectionActPdfService) {}
|
||||||
|
|
||||||
|
@Get('revisions/:version')
|
||||||
|
@RequirePermissions('inspection_acts.read')
|
||||||
|
async revision(
|
||||||
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
|
@Param('version', ParseIntPipe) version: number,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.pdf.consolidatedRevisionContent(actId, version);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(content.buffer.length));
|
||||||
|
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
return response.send(content.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('inspection_acts.read')
|
||||||
|
async content(
|
||||||
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.pdf.consolidatedContent(actId);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Length', String(content.buffer.length));
|
||||||
|
response.setHeader('Content-Disposition', `inline; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
response.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return response.send(content.buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { ActAdministrationModule } from '../act-administration/act-administration.module';
|
||||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
||||||
@@ -7,15 +8,17 @@ import { InspectionDeadlineAdminService } from './inspection-deadline-admin.serv
|
|||||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||||
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
import { InspectionActConsolidatedPdfController, InspectionActPdfController, InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule, ActAdministrationModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
InspectionReportsController,
|
InspectionReportsController,
|
||||||
InspectionActReportController,
|
InspectionActReportController,
|
||||||
|
InspectionActPdfController,
|
||||||
|
InspectionActConsolidatedPdfController,
|
||||||
InspectionDeadlineAdminController,
|
InspectionDeadlineAdminController,
|
||||||
DocumentDeliveryController,
|
DocumentDeliveryController,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
|
|||||||
|
|
||||||
export interface InspectionReportView extends InspectionReportListItem {
|
export interface InspectionReportView extends InspectionReportListItem {
|
||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
|
responseDueOn: string | null;
|
||||||
|
deadlineReason: string | null;
|
||||||
|
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
|
||||||
|
companyResponses: Array<{
|
||||||
|
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
|
||||||
|
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
|
||||||
|
}>;
|
||||||
|
findings: Array<{
|
||||||
|
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingInspectionReportItem {
|
export interface PendingInspectionReportItem {
|
||||||
@@ -491,7 +501,38 @@ export class InspectionReportsService {
|
|||||||
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
||||||
[id],
|
[id],
|
||||||
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
||||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
const deadlines = await manager.query(`
|
||||||
|
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
|
||||||
|
FROM inspection_act_deadline_events
|
||||||
|
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
|
||||||
|
ORDER BY created_at DESC,id DESC
|
||||||
|
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
|
||||||
|
const currentDeadline = deadlines[0] ?? null;
|
||||||
|
const companyResponses = await manager.query(`
|
||||||
|
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
|
||||||
|
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
|
||||||
|
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
|
||||||
|
FROM inspection_act_company_responses
|
||||||
|
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
|
||||||
|
ORDER BY received_on DESC,created_at DESC,id DESC
|
||||||
|
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
|
||||||
|
const findings = await manager.query(`
|
||||||
|
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
|
||||||
|
$2::date AS "responseDueOn"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN assets asset ON asset.id=finding.asset_id
|
||||||
|
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
|
||||||
|
ORDER BY finding.finding_number,finding.id
|
||||||
|
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
frozenSnapshot: snapshot.frozenSnapshot,
|
||||||
|
responseDueOn: currentDeadline?.responseDueOn ?? null,
|
||||||
|
deadlineReason: currentDeadline?.reason ?? null,
|
||||||
|
deadlines,
|
||||||
|
companyResponses,
|
||||||
|
findings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { connect as connectNet, Socket } from 'node:net';
|
import { connect as connectNet, Socket } from 'node:net';
|
||||||
import { connect as connectTls, TLSSocket } from 'node:tls';
|
import { connect as connectTls, TLSSocket } from 'node:tls';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { SmtpSecurityMode } from '../database/entities';
|
import { SmtpSecurityMode } from '../database/entities';
|
||||||
@@ -250,9 +250,9 @@ export class SmtpDeliveryService {
|
|||||||
|
|
||||||
private encryptionKey():Buffer{
|
private encryptionKey():Buffer{
|
||||||
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
|
||||||
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
|
if(!raw)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED', message:'La clave maestra para proteger credenciales SMTP no está configurada en el servidor' });
|
||||||
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
|
||||||
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
|
if(key.length!==32)throw new ServiceUnavailableException({ code:'SMTP_SETTINGS_MASTER_KEY_INVALID', message:'La clave maestra SMTP del servidor tiene un formato inválido' });
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export class CreateFieldFindingDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
actId!: string;
|
actId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
clientGeneratedId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(optionalText)
|
@Transform(optionalText)
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateFieldInventoryDto {
|
export class CreateFieldInventoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
clientGeneratedId?: string;
|
||||||
|
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
typeId!: string;
|
typeId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import {
|
|||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
Max,
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UploadFieldInventoryPhotoDto {
|
export class UploadFieldInventoryPhotoDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operationId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -101,13 +101,13 @@ export class F3FieldInventoryStructureService {
|
|||||||
if (familyRequired && !dto.familyId) {
|
if (familyRequired && !dto.familyId) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
|
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
|
||||||
message: 'Elegí la familia técnica o la opción Otro / no catalogado',
|
message: 'Elegí el Tipo de instalación/subinstalación o la opción Otro / no catalogado',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!familyRequired && dto.familyId) {
|
if (!familyRequired && dto.familyId) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
|
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
|
||||||
message: 'Este nivel estructural no utiliza familia técnica',
|
message: 'Este nivel estructural no utiliza Tipo de instalación/subinstalación',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const family = dto.familyId
|
const family = dto.familyId
|
||||||
@@ -116,7 +116,7 @@ export class F3FieldInventoryStructureService {
|
|||||||
if (dto.familyId && !family) {
|
if (dto.familyId && !family) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'FIELD_INVENTORY_FAMILY_INVALID',
|
code: 'FIELD_INVENTORY_FAMILY_INVALID',
|
||||||
message: 'La familia técnica no es válida para el padre seleccionado',
|
message: 'El Tipo de instalación/subinstalación no es válido para el padre seleccionado',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ export class F3FieldInventoryStructureService {
|
|||||||
if (!parent.inventoryFamilyId) {
|
if (!parent.inventoryFamilyId) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
|
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||||
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones',
|
message: 'La Instalación debe tener un Tipo de instalación antes de agregar Subinstalaciones',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.dataSource.query(`
|
return this.dataSource.query(`
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export class FieldFindingsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const payload: CreateInspectionFindingDto = {
|
const payload: CreateInspectionFindingDto = {
|
||||||
|
clientGeneratedId: dto.clientGeneratedId,
|
||||||
assetId,
|
assetId,
|
||||||
catalogItemId: dto.catalogItemId ?? null,
|
catalogItemId: dto.catalogItemId ?? null,
|
||||||
customTitle: dto.customTitle ?? null,
|
customTitle: dto.customTitle ?? null,
|
||||||
@@ -278,7 +279,7 @@ export class FieldFindingsService {
|
|||||||
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
|
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'FIELD_FINDING_FAMILY_REQUIRED',
|
code: 'FIELD_FINDING_FAMILY_REQUIRED',
|
||||||
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
|
message: 'La Instalación/Subinstalación debe tener su tipo definido antes de registrar Hallazgos',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!row.insideScope) {
|
if (!row.insideScope) {
|
||||||
|
|||||||
@@ -273,6 +273,23 @@ export class FieldInventoryService {
|
|||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
) {
|
) {
|
||||||
const context = await this.requireVisitContext(visitId, principal, true);
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
|
if (dto.clientGeneratedId) {
|
||||||
|
const [existing] = await this.dataSource.query(`
|
||||||
|
SELECT discovery.asset_id AS "assetId", discovery.visit_id AS "visitId"
|
||||||
|
FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.asset_id=$1::uuid
|
||||||
|
LIMIT 1
|
||||||
|
`, [dto.clientGeneratedId]) as Array<{ assetId: string; visitId: string }>;
|
||||||
|
if (existing) {
|
||||||
|
if (existing.visitId !== visitId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_CLIENT_ID_REUSED',
|
||||||
|
message: 'El identificador offline del Inventario ya fue utilizado en otra inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.detail(visitId, existing.assetId, principal);
|
||||||
|
}
|
||||||
|
}
|
||||||
const parentId = dto.parentId ?? context.scopeAssetId;
|
const parentId = dto.parentId ?? context.scopeAssetId;
|
||||||
await this.requireParentInContext(parentId, context);
|
await this.requireParentInContext(parentId, context);
|
||||||
|
|
||||||
@@ -286,6 +303,7 @@ export class FieldInventoryService {
|
|||||||
|
|
||||||
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
|
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
|
||||||
const discoveryDto: CreateFieldDiscoveryDto = {
|
const discoveryDto: CreateFieldDiscoveryDto = {
|
||||||
|
clientGeneratedId: dto.clientGeneratedId,
|
||||||
visitId,
|
visitId,
|
||||||
code,
|
code,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
@@ -351,6 +369,28 @@ export class FieldInventoryService {
|
|||||||
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
|
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
|
||||||
const context = await this.requireVisitContext(visitId, principal, true);
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
await this.requireAssetInContext(assetId, context);
|
await this.requireAssetInContext(assetId, context);
|
||||||
|
if (dto.operationId) {
|
||||||
|
const [existing] = await this.dataSource.query(`
|
||||||
|
SELECT media_id AS "mediaId", asset_id AS "assetId"
|
||||||
|
FROM asset_field_capture_events
|
||||||
|
WHERE client_operation_id=$1::uuid
|
||||||
|
LIMIT 1
|
||||||
|
`, [dto.operationId]) as Array<{ mediaId: string | null; assetId: string }>;
|
||||||
|
if (existing) {
|
||||||
|
if (existing.assetId !== assetId || !existing.mediaId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_PHOTO_OPERATION_REUSED',
|
||||||
|
message: 'La operación offline de fotografía ya fue utilizada en otro Inventario',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const media = (await this.media.list(assetId)).data.find((item) => item.id === existing.mediaId);
|
||||||
|
if (!media) throw new ConflictException({
|
||||||
|
code: 'FIELD_PHOTO_OPERATION_MEDIA_MISSING',
|
||||||
|
message: 'La fotografía sincronizada no está disponible',
|
||||||
|
});
|
||||||
|
return { media, capture: await this.captureStatus(visitId, assetId) };
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!await this.isSelected(visitId, assetId)) {
|
if (!await this.isSelected(visitId, assetId)) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'FIELD_INVENTORY_NOT_SELECTED',
|
code: 'FIELD_INVENTORY_NOT_SELECTED',
|
||||||
@@ -383,8 +423,8 @@ export class FieldInventoryService {
|
|||||||
device_latitude, device_longitude, device_accuracy_m,
|
device_latitude, device_longitude, device_accuracy_m,
|
||||||
device_captured_at, device_label,
|
device_captured_at, device_label,
|
||||||
exif_latitude, exif_longitude, exif_captured_at,
|
exif_latitude, exif_longitude, exif_captured_at,
|
||||||
created_by
|
created_by, client_operation_id
|
||||||
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||||
`, [
|
`, [
|
||||||
visitId,
|
visitId,
|
||||||
assetId,
|
assetId,
|
||||||
@@ -398,6 +438,7 @@ export class FieldInventoryService {
|
|||||||
dto.exifLongitude ?? null,
|
dto.exifLongitude ?? null,
|
||||||
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
|
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
|
||||||
principal.userId,
|
principal.userId,
|
||||||
|
dto.operationId ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
|
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const API_VERSION = '0.29.0-5';
|
export const API_VERSION = '0.29.0-13';
|
||||||
export const API_PHASE = 'F6.2';
|
export const API_PHASE = 'F6.12';
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
|
||||||
|
|
||||||
|
test('Acta PDF renders Hallazgos and finding evidence, never standalone Inventory or field photos', () => {
|
||||||
|
const source = api('inspection-reports/inspection-act-pdf-builder.ts');
|
||||||
|
assert.match(source, /section\('5\. Hallazgos'\)/);
|
||||||
|
assert.match(source, /item\.findingId === text\(finding\.id\)/);
|
||||||
|
assert.doesNotMatch(source, /Instalaciones inspeccionadas/);
|
||||||
|
assert.doesNotMatch(source, /Otras instalaciones inspeccionadas/);
|
||||||
|
assert.doesNotMatch(source, /item\.assetId === text\(finding\.assetId\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dashboard Acta shows only Hallazgos and evidence directly attached to them', () => {
|
||||||
|
const media = web('features/inspections/InspectionActMediaPanel.tsx');
|
||||||
|
const editor = web('pages/InspectionActEditorPage.tsx');
|
||||||
|
assert.match(media, /listInspectionFindingEvidence/);
|
||||||
|
assert.doesNotMatch(media, /listInspectionActFieldMedia/);
|
||||||
|
assert.doesNotMatch(media, /getAssetMediaBlob/);
|
||||||
|
assert.doesNotMatch(media, /Fotos de otras instalaciones/);
|
||||||
|
assert.doesNotMatch(editor, /Instalaciones inspeccionadas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Company signing view exposes Hallazgos as Acta content without a standalone Inventory list', () => {
|
||||||
|
const service = api('inspection-closing/company-signature-invite.service.ts');
|
||||||
|
const viewBody = service.slice(service.indexOf(' async view(token: string)'), service.indexOf(' async sign('));
|
||||||
|
const page = web('pages/CompanySignaturePage.tsx');
|
||||||
|
assert.match(viewBody, /findings/);
|
||||||
|
assert.doesNotMatch(viewBody, /inventories/);
|
||||||
|
assert.match(page, />Hallazgos</);
|
||||||
|
assert.doesNotMatch(page, /Inventario inspeccionado/);
|
||||||
|
assert.doesNotMatch(page, /view\.inventories/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta sealed snapshot excludes Inventory that has no Hallazgo and PDF skips standalone asset media', () => {
|
||||||
|
const closing = api('inspection-closing/inspection-closing.service.ts');
|
||||||
|
const pdfService = api('inspection-reports/inspection-act-pdf.service.ts');
|
||||||
|
const snapshotBody = closing.slice(closing.indexOf(' private async buildLockedSnapshot('), closing.indexOf(' private async deadlinePolicy('));
|
||||||
|
assert.match(snapshotBody, /EXISTS \(\s*SELECT 1 FROM inspection_findings finding/);
|
||||||
|
assert.match(snapshotBody, /finding\.asset_id=asset\.id/);
|
||||||
|
assert.match(snapshotBody, /finding\.status<>'VOIDED'/);
|
||||||
|
assert.match(pdfService, /fieldImages\(actId, false\)/);
|
||||||
|
assert.match(pdfService, /includeAssetPhotos \? await this\.dataSource\.query/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta PDF explains each Hallazgo with frozen territorial, technical and recurrence context', () => {
|
||||||
|
const pdf = api('inspection-reports/inspection-act-pdf-builder.ts');
|
||||||
|
const closing = api('inspection-closing/inspection-closing.service.ts');
|
||||||
|
assert.match(pdf, /Contexto territorial y operativo/);
|
||||||
|
assert.match(pdf, /Inspector\/a responsable/);
|
||||||
|
assert.match(pdf, /Tipo de instalación/);
|
||||||
|
assert.match(pdf, /Qué se constató/);
|
||||||
|
assert.match(pdf, /Referencia de catálogo/);
|
||||||
|
assert.match(pdf, /Normativa \/ Base legal/);
|
||||||
|
assert.match(pdf, /Reincidencia/);
|
||||||
|
assert.match(pdf, /Alcance de la inspección/);
|
||||||
|
assert.match(pdf, /scopeTypeCode/);
|
||||||
|
assert.match(pdf, /Antecedente relacionado/);
|
||||||
|
assert.match(closing, /DH-ACT-LIFECYCLE-V5/);
|
||||||
|
assert.match(closing, /AS department/);
|
||||||
|
assert.match(closing, /AS "leadInspector"/);
|
||||||
|
assert.match(closing, /AS "installationTypeName"/);
|
||||||
|
assert.match(closing, /AS recurrence/);
|
||||||
|
const service = api('inspection-reports/inspection-act-pdf.service.ts');
|
||||||
|
assert.match(service, /yacimiento_from_findings/);
|
||||||
|
});
|
||||||
@@ -28,7 +28,8 @@ test('F3.2/F4 exige seleccionar el Acta explícitamente al crear Hallazgos desde
|
|||||||
test('F4 conserva el cierre documental inmutable de cada Acta como LOCKED y luego SEALED', () => {
|
test('F4 conserva el cierre documental inmutable de cada Acta como LOCKED y luego SEALED', () => {
|
||||||
const closing = read('src/inspection-closing/inspection-closing.service.ts');
|
const closing = read('src/inspection-closing/inspection-closing.service.ts');
|
||||||
assert.match(closing, /status='LOCKED'/);
|
assert.match(closing, /status='LOCKED'/);
|
||||||
assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
|
assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/);
|
||||||
|
assert.match(closing, /createInspectorSignatureFromProfile/);
|
||||||
assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
||||||
assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/);
|
assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/);
|
||||||
assert.match(closing, /status='SEALED'/);
|
assert.match(closing, /status='SEALED'/);
|
||||||
|
|||||||
@@ -36,15 +36,14 @@ test('F4 document center and inspection detail consume the F4 act client', () =>
|
|||||||
test('F4 closure UI treats sealing as the definitive act state', () => {
|
test('F4 closure UI treats sealing as the definitive act state', () => {
|
||||||
assert.match(closurePanel, /getInspectionClosureF4/);
|
assert.match(closurePanel, /getInspectionClosureF4/);
|
||||||
assert.match(closurePanel, /act\.status === 'SEALED'/);
|
assert.match(closurePanel, /act\.status === 'SEALED'/);
|
||||||
assert.match(closurePanel, /ACTA SELLADA/);
|
assert.match(closurePanel, /Participantes y firmas/);
|
||||||
assert.match(closurePanel, /manifestación de la empresa puede completarse/);
|
assert.doesNotMatch(closurePanel, /Pendiente de evento válido/);
|
||||||
assert.match(closurePanel, /Pendiente de evento válido/);
|
|
||||||
assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/);
|
assert.doesNotMatch(closurePanel, /reabrir ni cerrar el acta/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => {
|
test('F4 presentation keeps new lifecycle labels while retaining explicit legacy readability', () => {
|
||||||
assert.match(presentation, /LOCKED: 'Bloqueada/);
|
assert.match(presentation, /LOCKED: 'Para firmar/);
|
||||||
assert.match(presentation, /SEALED: 'Sellada'/);
|
assert.match(presentation, /SEALED: 'Firmada y cerrada'/);
|
||||||
assert.match(presentation, /READY: 'Lista para cerrar · legado'/);
|
assert.match(presentation, /READY: 'Lista para cerrar · legado'/);
|
||||||
assert.match(presentation, /CLOSED: 'Cerrada · legado'/);
|
assert.match(presentation, /CLOSED: 'Firmada y cerrada'/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ test('F4 Android uses only lock and seal endpoints for the active Act lifecycle'
|
|||||||
assert.doesNotMatch(mobileActs, /suspend fun closeAct\(/);
|
assert.doesNotMatch(mobileActs, /suspend fun closeAct\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F4 Android ViewModel calls lock and seal directly', () => {
|
test('F6.8 Android ViewModel persists lock and seal through the offline queue', () => {
|
||||||
assert.match(viewModel, /actsRepository\.lock\(actId, urgency\)/);
|
assert.match(viewModel, /offlineQueue\.enqueueActLock\(actId, urgency\)/);
|
||||||
assert.match(viewModel, /actsRepository\.seal\(actId\)/);
|
assert.match(viewModel, /offlineQueue\.enqueueSeal\(actId\)/);
|
||||||
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
|
assert.doesNotMatch(viewModel, /actsRepository\.prepare\(/);
|
||||||
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
|
assert.doesNotMatch(viewModel, /actsRepository\.reopen\(/);
|
||||||
assert.doesNotMatch(viewModel, /actsRepository\.closeAct\(/);
|
assert.doesNotMatch(viewModel, /actsRepository\.closeAct\(/);
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||||
|
|
||||||
test('health metadata reports the current F6.1 release', () => {
|
test('health metadata reports the current F6.12 release', () => {
|
||||||
assert.equal(API_PHASE, 'F6.2');
|
assert.equal(API_PHASE, 'F6.12');
|
||||||
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string };
|
||||||
assert.equal(API_VERSION, pkg.version);
|
assert.equal(API_VERSION, pkg.version);
|
||||||
assert.equal(API_VERSION, '0.29.0-5');
|
assert.equal(API_VERSION, '0.29.0-13');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,9 +80,10 @@ test('F4 field finding flow only targets a draft act of the same in-progress ins
|
|||||||
assert.match(draftGuard, /WHERE visit_id = \$1::uuid[\s\S]*AND status = 'DRAFT'/);
|
assert.match(draftGuard, /WHERE visit_id = \$1::uuid[\s\S]*AND status = 'DRAFT'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F4 sealing requires inspector signature and exactly one terminal company outcome', () => {
|
test('F6.7 sealing copies the stored inspector signature and requires exactly one terminal company outcome', () => {
|
||||||
const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent('));
|
const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent('));
|
||||||
assert.match(closeBody, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/);
|
assert.match(closeBody, /createInspectorSignatureFromProfile/);
|
||||||
|
assert.match(closing, /INSPECTION_INSPECTOR_PROFILE_SIGNATURE_REQUIRED/);
|
||||||
assert.match(closeBody, /companyOutcomes\.length !== 1/);
|
assert.match(closeBody, /companyOutcomes\.length !== 1/);
|
||||||
assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
assert.match(closeBody, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/);
|
||||||
assert.match(closeBody, /ausencia documentada/);
|
assert.match(closeBody, /ausencia documentada/);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
|
|||||||
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
|
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
|
||||||
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
|
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
|
||||||
assert.match(panel, /Todos para vincular/);
|
assert.match(panel, /Todos para vincular/);
|
||||||
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
|
assert.match(panel, /Este tipo todavía no tiene Hallazgos asociados/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
|
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
|
||||||
@@ -116,7 +116,7 @@ test('F7 canonical detail uses one short everyday profile and keeps advanced adm
|
|||||||
assert.match(detail, />Fotos</);
|
assert.match(detail, />Fotos</);
|
||||||
assert.match(detail, />Cambios</);
|
assert.match(detail, />Cambios</);
|
||||||
assert.match(detail, /Empresa operadora/);
|
assert.match(detail, /Empresa operadora/);
|
||||||
assert.match(detail, /Tipo técnico/);
|
assert.match(detail, /Tipo de instalación \/ subinstalación/);
|
||||||
assert.match(detail, /AssetTechnicalDataPanel/);
|
assert.match(detail, /AssetTechnicalDataPanel/);
|
||||||
assert.match(detail, /AssetDossierPanel/);
|
assert.match(detail, /AssetDossierPanel/);
|
||||||
assert.match(detail, /Administración avanzada/);
|
assert.match(detail, /Administración avanzada/);
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
|||||||
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||||
|
|
||||||
assert.match(gradle, /versionCode = 35/);
|
assert.match(gradle, /versionCode = 40/);
|
||||||
assert.match(gradle, /versionName = "0\.19\.7"/);
|
assert.match(gradle, /versionName = "0\.19\.12"/);
|
||||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack
|
|||||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||||
|
|
||||||
assert.equal(visibleVersion, pkg.version);
|
assert.equal(visibleVersion, pkg.version);
|
||||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.2 · Firma por Acta y correo de usuario'/);
|
assert.match(version, /APP_PHASE\s*=\s*'F6\.13/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||||
@@ -49,7 +49,7 @@ test('F6.1 presentation keeps Android start, Otro and chronological merge flows
|
|||||||
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
|
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
|
||||||
assert.match(overview, /"Iniciar inspección"/);
|
assert.match(overview, /"Iniciar inspección"/);
|
||||||
assert.match(overview, /Otro \/ no catalogado/);
|
assert.match(overview, /Otro \/ no catalogado/);
|
||||||
assert.match(viewModel, /repository\.startVisit\(id\)/);
|
assert.match(viewModel, /enqueueVisitStart\(id\)|repository\.startVisit\(id\)/);
|
||||||
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
|
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
|
||||||
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
|
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.11 keeps GEDO officialization manual and does not activate response deadlines automatically', () => {
|
||||||
|
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(workflow, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
|
||||||
|
assert.doesNotMatch(workflow.slice(workflow.indexOf(' async officialize('), workflow.indexOf(' async setResponseDeadline(')), /setDeadline\(/);
|
||||||
|
assert.match(page, /GEDO no se consulta automáticamente/);
|
||||||
|
assert.match(page, /no crea respuestas ni vencimientos automáticamente/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 stores new deadlines and company responses with both Report and Act relation', () => {
|
||||||
|
const migration = api('database/migrations/1790139000000-f6-11-report-response-workflow.ts');
|
||||||
|
const administration = api('act-administration/act-administration.service.ts');
|
||||||
|
assert.match(migration, /inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid/);
|
||||||
|
assert.match(migration, /inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid/);
|
||||||
|
assert.match(migration, /report\.id=NEW\.report_id AND report\.act_id=NEW\.act_id/);
|
||||||
|
assert.doesNotMatch(migration, /UPDATE inspection_act_deadline_events|UPDATE inspection_act_company_responses/);
|
||||||
|
assert.match(administration, /INSERT INTO inspection_act_deadline_events \(id, act_id, report_id/);
|
||||||
|
assert.match(administration, /INSERT INTO inspection_act_company_responses \(id, act_id, report_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 projects one Act response deadline to every non-voided finding', () => {
|
||||||
|
const administration = api('act-administration/act-administration.service.ts');
|
||||||
|
const reports = api('inspection-reports/inspection-reports.service.ts');
|
||||||
|
assert.match(administration, /UPDATE inspection_findings[\s\S]*SET correction_due_on=\$2[\s\S]*WHERE act_id=\$1 AND status<>'VOIDED'/);
|
||||||
|
assert.match(administration, /sharedDeadline: true/);
|
||||||
|
assert.match(reports, /\$2::date AS "responseDueOn"/);
|
||||||
|
assert.match(reports, /currentDeadline\?\.responseDueOn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 enables formal responses only after the official GEDO PDF exists', () => {
|
||||||
|
const workflow = api('inspection-reports/inspection-report-workflow.service.ts');
|
||||||
|
const controller = api('inspection-reports/inspection-reports.controller.ts');
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(workflow, /requireOfficializedReport\(reportId\)/);
|
||||||
|
assert.match(workflow, /Primero debe cargarse manualmente el PDF oficial de GEDO/);
|
||||||
|
assert.match(controller, /@Post\(':id\/company-responses'\)/);
|
||||||
|
assert.match(controller, /@Patch\(':id\/response-deadline'\)/);
|
||||||
|
assert.match(page, /Las respuestas se habilitan después de cargar el PDF oficial de GEDO/);
|
||||||
|
assert.match(page, /Vencimiento común del Acta/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.11 separates formal company responses from internal report follow-up notes', () => {
|
||||||
|
const page = web('pages/ReportDetailPage.tsx');
|
||||||
|
assert.match(page, /RESPUESTAS DE EMPRESA/);
|
||||||
|
assert.match(page, /No usar este bloque para respuestas formales de empresa/);
|
||||||
|
assert.match(page, /<option value="INTERNAL_NOTE">Nota interna<\/option>/);
|
||||||
|
assert.doesNotMatch(page.slice(page.indexOf('Agregar otro antecedente')), /option value="COMPANY_NOTE"/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.12 uses standard GeoJSON names in WEB and converts only at the API boundary', () => {
|
||||||
|
const client = web('src/lib/api.ts');
|
||||||
|
const map = web('src/features/map/DhMap.tsx');
|
||||||
|
const editor = web('src/features/map/AssetGeometryEditor.tsx');
|
||||||
|
assert.match(client, /type: 'Point'/);
|
||||||
|
assert.match(client, /type: 'LineString'/);
|
||||||
|
assert.match(client, /type: 'Polygon'/);
|
||||||
|
assert.match(client, /geometryPayload/);
|
||||||
|
assert.match(map, /feature\.geometry\.type === 'Point'/);
|
||||||
|
assert.match(editor, /setType\(value\.geometryType\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.12 reports missing SMTP encryption configuration explicitly', () => {
|
||||||
|
const smtp = api('src/inspection-reports/smtp-delivery.service.ts');
|
||||||
|
assert.match(smtp, /SMTP_SETTINGS_MASTER_KEY_NOT_CONFIGURED/);
|
||||||
|
assert.match(smtp, /ServiceUnavailableException/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.12 grants report management to the system administrator role', () => {
|
||||||
|
const migration = api('src/database/migrations/1790142600000-f6-12-admin-report-permission.ts');
|
||||||
|
assert.match(migration, /role\.code='admin'/);
|
||||||
|
assert.match(migration, /inspection_reports\.generate/);
|
||||||
|
assert.match(migration, /ON CONFLICT\(role_id, permission_id\) DO NOTHING/);
|
||||||
|
});
|
||||||
@@ -40,6 +40,7 @@ test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () =>
|
|||||||
assert.match(projection, /Firmas y manifestaciones/);
|
assert.match(projection, /Firmas y manifestaciones/);
|
||||||
assert.match(projection, /GEDO e integridad/);
|
assert.match(projection, /GEDO e integridad/);
|
||||||
assert.match(projection, /Anexo final/);
|
assert.match(projection, /Anexo final/);
|
||||||
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
|
assert.match(actPage, /getInspectionActConsolidatedPdfBlob/);
|
||||||
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
|
assert.doesNotMatch(actPage, /DocumentPdfProjection/);
|
||||||
|
assert.doesNotMatch(reportPage, /DocumentPdfProjection/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', ()
|
|||||||
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
|
assert.doesNotMatch(root, /Text\("Inventario de campo"/);
|
||||||
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
|
assert.match(acts, /Text\("Agregar Hallazgo"\)/);
|
||||||
assert.match(acts, /model\.createAct\(\)/);
|
assert.match(acts, /model\.createAct\(\)/);
|
||||||
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency\)/);
|
assert.match(acts, /model\.prepareSelectedAct\(closingUrgency, actNarrative\)/);
|
||||||
assert.match(vm, /fun createAct\(\)/);
|
assert.match(vm, /fun createAct\(\)/);
|
||||||
assert.match(vm, /fun prepareSelectedAct\(urgency: String\)/);
|
assert.match(vm, /fun prepareSelectedAct\(urgency: String, narrative: String\)/);
|
||||||
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
|
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
function source(path: string): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F6.6 Hallazgo version capture uses a SELECT-shaped CTE so TypeORM never turns the version into NaN', () => {
|
||||||
|
const service = source('src/inspection-findings/inspection-findings.service.ts');
|
||||||
|
|
||||||
|
assert.match(service, /WITH updated AS \(/);
|
||||||
|
assert.match(service, /UPDATE inspection_findings/);
|
||||||
|
assert.match(service, /RETURNING current_version/);
|
||||||
|
assert.match(service, /SELECT current_version AS "versionNumber" FROM updated/);
|
||||||
|
assert.match(service, /const versionNumber = Number\(row\.versionNumber\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.6 mobile home keeps Salir readable on narrow screens', () => {
|
||||||
|
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
|
||||||
|
|
||||||
|
assert.match(home, /Text\("Actualizar", maxLines = 1\)/);
|
||||||
|
assert.match(home, /Text\("Salir", maxLines = 1\)/);
|
||||||
|
assert.match(home, /modifier = Modifier\.weight\(1f\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.6 Hallazgos can receive as many field photos as needed', () => {
|
||||||
|
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
|
||||||
|
const model = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
|
||||||
|
|
||||||
|
assert.match(screen, /Agregar foto con GPS/);
|
||||||
|
assert.match(screen, /Agregar otra foto con GPS/);
|
||||||
|
assert.match(screen, /Podés adjuntar todas las fotos que necesites/);
|
||||||
|
assert.match(model, /loadEvidenceInternal\(findingId\)/);
|
||||||
|
assert.doesNotMatch(screen, /máximo de 1 foto|solo una foto/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
function source(path: string): string {
|
||||||
|
return readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const closing = source('src/inspection-closing/inspection-closing.service.ts');
|
||||||
|
const users = source('src/administration/users/users.controller.ts');
|
||||||
|
const userService = source('src/administration/users/users.service.ts');
|
||||||
|
const migration = source('src/database/migrations/1790124600000-f6-7-user-reusable-signature.ts');
|
||||||
|
const android = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt');
|
||||||
|
const labels = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt');
|
||||||
|
const profile = source('../web-v2/src/pages/MyProfilePage.tsx');
|
||||||
|
|
||||||
|
test('F6.7 Acta lock and seal use SELECT-shaped version CTEs', () => {
|
||||||
|
assert.doesNotMatch(closing, /RETURNING current_version AS "versionNumber"/);
|
||||||
|
assert.equal((closing.match(/WITH updated AS \(/g) ?? []).length >= 2, true);
|
||||||
|
assert.equal((closing.match(/SELECT current_version AS "versionNumber" FROM updated/g) ?? []).length >= 2, true);
|
||||||
|
});
|
||||||
|
test('F6.7 inspector signature is stored once in profile and copied into each sealed Acta', () => {
|
||||||
|
assert.match(migration, /CREATE TABLE user_signature_profiles/);
|
||||||
|
assert.match(migration, /image_data bytea NOT NULL/);
|
||||||
|
assert.match(migration, /image_sha256 char\(64\) NOT NULL/);
|
||||||
|
assert.match(users, /@Put\('self\/signature'\)/);
|
||||||
|
assert.match(users, /@Get\('self\/signature\/content'\)/);
|
||||||
|
assert.match(userService, /inspectInspectionSignatureFile/);
|
||||||
|
assert.match(closing, /requireReusableInspectorSignature/);
|
||||||
|
assert.match(closing, /createInspectorSignatureFromProfile/);
|
||||||
|
assert.match(closing, /signatureMode: 'PROFILE_REUSABLE'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.7 Android goes Draft to Para firmar to company manifestation without inspector drawing', () => {
|
||||||
|
assert.match(labels, /"LOCKED" -> "Para firmar"/);
|
||||||
|
assert.match(android, /Cerrar contenido y pasar a firma/);
|
||||||
|
assert.match(android, /Firma del representante de la empresa \(acompañante\)/);
|
||||||
|
assert.match(android, /cerrar Acta/);
|
||||||
|
assert.doesNotMatch(android, /Firmá como inspector\/a/);
|
||||||
|
assert.doesNotMatch(android, /inspectorSigned/);
|
||||||
|
assert.doesNotMatch(android, /Text\(selected\.code/);
|
||||||
|
assert.match(android, /attendanceStatus == "ABSENT"/);
|
||||||
|
});
|
||||||
|
test('F6.7 Dashboard exposes the reusable inspector signature clearly', () => {
|
||||||
|
assert.match(profile, /FIRMA DEL INSPECTOR/);
|
||||||
|
assert.match(profile, /Firma guardada para Actas/);
|
||||||
|
assert.match(profile, /saveSelfSignature/);
|
||||||
|
assert.match(profile, /Cada Acta conserva una copia propia de la firma/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||||
|
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
|
||||||
|
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
|
||||||
|
|
||||||
|
test('F6.8 persists field mutations locally and retries only when network is available', () => {
|
||||||
|
const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
|
||||||
|
assert.match(queue, /@Database\(entities = \[PendingMobileOperation::class, OfflineCacheEntry::class\]/);
|
||||||
|
assert.match(queue, /NetworkType\.CONNECTED/);
|
||||||
|
assert.match(queue, /OneTimeWorkRequestBuilder<OfflineSyncWorker>/);
|
||||||
|
for (const operation of [
|
||||||
|
'VISIT_START', 'VISIT_CLOSE', 'FIELD_ASSET_CREATE', 'FIELD_ASSET_PHOTO',
|
||||||
|
'ACT_CREATE', 'ACT_ASSET_ENSURE', 'FINDING_CREATE', 'FINDING_PHOTO',
|
||||||
|
'ACT_RESPONSIBLE', 'ACT_LOCK', 'COMPANY_OUTCOME', 'COMPANY_SIGNATURE', 'ACT_SEAL',
|
||||||
|
]) assert.match(queue, new RegExp(`"${operation}"`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.8 isolates offline work and cached inspection data by authenticated user', () => {
|
||||||
|
const queue = android('app/src/main/java/com/korexlabs/dhinspeccion/data/offline/OfflineQueue.kt');
|
||||||
|
const mobile = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
|
||||||
|
const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
|
||||||
|
const findings = android('app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt');
|
||||||
|
assert.match(queue, /val ownerUserId: String/);
|
||||||
|
assert.match(queue, /ownerUserId=:ownerUserId/);
|
||||||
|
assert.match(queue, /SecureSessionStore/);
|
||||||
|
assert.match(mobile, /userCacheKey/);
|
||||||
|
assert.match(acts, /userCacheKey/);
|
||||||
|
assert.match(findings, /userCacheKey/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.8 gives offline-created objects idempotent client identities', () => {
|
||||||
|
const actDto = api('src/inspection-acts/dto/create-inspection-act.dto.ts');
|
||||||
|
const findingDto = api('src/inspection-findings/dto/create-inspection-finding.dto.ts');
|
||||||
|
const inventoryDto = api('src/inspection-visits/dto/create-field-inventory.dto.ts');
|
||||||
|
const migration = api('src/database/migrations/1790128200000-f6-8-offline-evidence-idempotency.ts');
|
||||||
|
assert.match(actDto, /clientGeneratedId/);
|
||||||
|
assert.match(findingDto, /clientGeneratedId/);
|
||||||
|
assert.match(inventoryDto, /clientGeneratedId/);
|
||||||
|
assert.match(migration, /inspection_finding_evidence/);
|
||||||
|
assert.match(migration, /asset_field_capture_events/);
|
||||||
|
assert.equal((migration.match(/client_operation_id/g) ?? []).length >= 6, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.8 reviews photos before queueing and never sends the retired ONLINE upload mode', () => {
|
||||||
|
const photo = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
|
||||||
|
const acts = android('app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
|
||||||
|
assert.match(photo, /Revisar fotografía/);
|
||||||
|
assert.match(photo, /Usar esta foto/);
|
||||||
|
assert.match(photo, /Volver a tomar/);
|
||||||
|
assert.match(photo, /Eliminar foto/);
|
||||||
|
assert.doesNotMatch(acts, /uploadMode: String = "ONLINE"/);
|
||||||
|
assert.match(acts, /uploadMode: String = "IMMEDIATE"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.8 makes the real Acta PDF and photographic record first-class in Dashboard', () => {
|
||||||
|
const controller = api('src/inspection-reports/inspection-reports.controller.ts');
|
||||||
|
const page = web('src/pages/InspectionActEditorPage.tsx');
|
||||||
|
const media = web('src/features/inspections/InspectionActMediaPanel.tsx');
|
||||||
|
assert.match(controller, /inspection-acts\/:actId\/pdf/);
|
||||||
|
assert.match(api('src/inspection-acts/inspection-acts.controller.ts'), /:id\/field-media/);
|
||||||
|
assert.match(api('src/inspection-acts/inspection-acts.service.ts'), /capture\.visit_id=act\.visit_id/);
|
||||||
|
assert.match(page, /Abrir PDF del Acta/);
|
||||||
|
assert.match(page, /Descargar PDF/);
|
||||||
|
assert.match(media, /Hallazgos del Acta/);
|
||||||
|
assert.match(media, /act-finding-photos/);
|
||||||
|
assert.match(media, /listInspectionFindingEvidence/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.8 presents technical families to users as installation and subinstallation types', () => {
|
||||||
|
const config = web('src/pages/AssetTypesPage.tsx');
|
||||||
|
const detail = web('src/pages/SimpleInventoryDetailPage.tsx');
|
||||||
|
const field = api('src/inspection-visits/f3-field-inventory-structure.service.ts');
|
||||||
|
assert.match(config, /Tipos de Instalación/);
|
||||||
|
assert.match(config, /Tipos de Subinstalación/);
|
||||||
|
assert.match(detail, /Tipo de instalación \/ subinstalación/);
|
||||||
|
assert.match(field, /Tipo de instalación\/subinstalación/);
|
||||||
|
assert.doesNotMatch(config, /clasificación técnica/i);
|
||||||
|
assert.doesNotMatch(detail, /clasificación técnica/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildInspectionActPdf } from '../../src/inspection-reports/inspection-act-pdf-builder';
|
||||||
|
import { buildInspectionReportWord } from '../../src/inspection-reports/inspection-report-word-builder';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { renderableInspectionImage } from '../../src/inspection-reports/inspection-document-images';
|
||||||
|
|
||||||
|
const digest = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
||||||
|
const photo = readFileSync(resolve(process.cwd(), 'assets/logo-mendoza.png'));
|
||||||
|
const locked = {
|
||||||
|
act: { code: 'ACT-TEST-01', occurredAt: '2026-09-14T18:30:00Z',
|
||||||
|
summary: 'Se inspeccionó un tanque.', inspection: { code: 'INSP-TEST-01' } },
|
||||||
|
responsible: { fullName: 'Responsable de prueba', documentNumber: '12345678' },
|
||||||
|
inventories: [{ id: 'asset-1', code: 'CAM-TEST-01', name: 'Tanque de prueba' }],
|
||||||
|
findings: [{ id: 'finding-1', assetId: 'asset-1', code: 'ACT-TEST-H01',
|
||||||
|
title: 'Pérdida', description: 'Se constató una pérdida visible.', severity: 3 }],
|
||||||
|
};
|
||||||
|
const sealed = { lockedSnapshot: locked, finalSha256: 'a'.repeat(64),
|
||||||
|
lockedSha256: 'b'.repeat(64), signatures: [], seal: { serverSealedAt: '2026-09-15T18:30:00Z' } };
|
||||||
|
const evidence = { id: 'photo-1', findingId: 'finding-1', sha256: digest(photo), buffer: photo };
|
||||||
|
|
||||||
|
test('F6.9 consolidated Acta is a hashed PDF 1.4 with actual evidence embedded', async () => {
|
||||||
|
const without = await buildInspectionActPdf(sealed);
|
||||||
|
const withEvidence = await buildInspectionActPdf(sealed, [evidence]);
|
||||||
|
assert.ok(withEvidence.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||||
|
assert.equal(withEvidence.sha256, digest(withEvidence.buffer));
|
||||||
|
assert.ok(withEvidence.buffer.length > without.buffer.length + 3_000);
|
||||||
|
assert.notEqual(withEvidence.sha256, without.sha256);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 technical Informe embeds evidence and its institutional source hash', () => {
|
||||||
|
const input = { code: 'INF-TEST-01', title: 'Informe de prueba', generatedAt: new Date('2026-09-15'),
|
||||||
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed, source: { actCode: locked.act.code } } };
|
||||||
|
const plain = buildInspectionReportWord(input);
|
||||||
|
const withEvidence = buildInspectionReportWord({ ...input, photos: [evidence] });
|
||||||
|
assert.equal(withEvidence.sha256, digest(withEvidence.buffer));
|
||||||
|
assert.ok(withEvidence.buffer.subarray(0, 4).equals(Buffer.from('PK\x03\x04')));
|
||||||
|
assert.ok(withEvidence.buffer.includes(Buffer.from('word/media/photo-1.png')));
|
||||||
|
assert.ok(withEvidence.buffer.includes(photo));
|
||||||
|
assert.ok(withEvidence.buffer.includes(Buffer.from('ACT-TEST-H01')));
|
||||||
|
assert.ok(withEvidence.buffer.includes(Buffer.from('SHA-256 de la fuente del Informe')));
|
||||||
|
assert.ok(withEvidence.buffer.length > plain.buffer.length + photo.length);
|
||||||
|
assert.ok(!withEvidence.buffer.includes(Buffer.from('[Completar')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 keeps prior sealed PDF and prior company responses available for audit', () => {
|
||||||
|
const pdf = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-act-pdf.service.ts'), 'utf8');
|
||||||
|
const controller = readFileSync(resolve(process.cwd(), 'src/act-administration/act-administration.controller.ts'), 'utf8');
|
||||||
|
assert.match(pdf, /inspection_act_consolidated_pdf_revisions/);
|
||||||
|
const word = readFileSync(resolve(process.cwd(), 'src/inspection-reports/inspection-report-word.service.ts'), 'utf8');
|
||||||
|
assert.match(word, /inspection_report_consolidated_word_revisions/);
|
||||||
|
assert.match(pdf, /ON CONFLICT \(act_id,template_version\) DO NOTHING/);
|
||||||
|
assert.match(controller, /responseContent\(responseId\)/);
|
||||||
|
assert.doesNotMatch(controller, /@Post\('responses'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 renders WebP field photographs in both documents without changing the source hash', async () => {
|
||||||
|
const original = await sharp(photo).webp().toBuffer();
|
||||||
|
const originalSha = digest(original);
|
||||||
|
const display = await renderableInspectionImage(original);
|
||||||
|
assert.ok(display.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])));
|
||||||
|
assert.equal(digest(original), originalSha);
|
||||||
|
const pdf = await buildInspectionActPdf(sealed, [{ ...evidence, sha256: originalSha, buffer: display }]);
|
||||||
|
assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||||
|
const word = buildInspectionReportWord({ code: 'INF-WEBP', title: 'Informe', generatedAt: new Date(),
|
||||||
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [{ ...evidence, sha256: originalSha, buffer: display }] });
|
||||||
|
assert.ok(word.buffer.includes(display));
|
||||||
|
assert.ok(word.buffer.includes(Buffer.from(originalSha)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 built CommonJS runtime renders signed PDF and WebP evidence', async () => {
|
||||||
|
const builtPdf = resolve(process.cwd(), 'dist/inspection-reports/inspection-act-pdf-builder.js');
|
||||||
|
const builtImage = resolve(process.cwd(), 'dist/inspection-reports/inspection-document-images.js');
|
||||||
|
if (!existsSync(builtPdf) || !existsSync(builtImage)) return;
|
||||||
|
const requireCjs = createRequire(resolve(process.cwd(), 'package.json'));
|
||||||
|
const runtimePdf = requireCjs(builtPdf) as { buildInspectionActPdf: typeof buildInspectionActPdf };
|
||||||
|
const runtimeImage = requireCjs(builtImage) as { renderableInspectionImage: typeof renderableInspectionImage };
|
||||||
|
const webp = await sharp(photo).webp().toBuffer();
|
||||||
|
const rendered = await runtimeImage.renderableInspectionImage(webp);
|
||||||
|
const pdf = await runtimePdf.buildInspectionActPdf(sealed, [{ ...evidence, buffer: rendered, sha256: digest(webp) }]);
|
||||||
|
assert.ok(pdf.buffer.subarray(0, 8).equals(Buffer.from('%PDF-1.4')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F6.9 revision migration archives both first document versions before serving the corrected template', () => {
|
||||||
|
const migration = readFileSync(resolve(process.cwd(),
|
||||||
|
'src/database/migrations/1790135400000-f6-9-consolidated-document-revisions.ts'), 'utf8');
|
||||||
|
assert.match(migration, /SELECT act_id,1,stored_name,original_name,size_bytes,sha256,generated_at/);
|
||||||
|
assert.match(migration, /SELECT report_id,1,stored_name,original_name,size_bytes,sha256,generated_at/);
|
||||||
|
assert.match(migration, /PRIMARY KEY \(act_id,template_version\)/);
|
||||||
|
assert.match(migration, /PRIMARY KEY \(report_id,template_version\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Acta ignores standalone field photos while the technical Informe remains independent', async () => {
|
||||||
|
const other = await sharp(photo).resize(75).png().toBuffer();
|
||||||
|
const unlinked = { id: 'photo-other-asset', assetId: 'asset-2', title: 'Otra instalación', sha256: digest(other), buffer: other };
|
||||||
|
const withUnlinked = await buildInspectionActPdf(sealed, [evidence, unlinked]);
|
||||||
|
const linkedOnly = await buildInspectionActPdf(sealed, [evidence]);
|
||||||
|
assert.ok(Math.abs(withUnlinked.buffer.length - linkedOnly.buffer.length) < 500);
|
||||||
|
const word = buildInspectionReportWord({ code: 'INF-OTHER', title: 'Informe', generatedAt: new Date(),
|
||||||
|
frozenSha256: 'c'.repeat(64), frozenSnapshot: { sealedAct: sealed }, photos: [evidence, unlinked] });
|
||||||
|
assert.ok(word.buffer.includes(Buffer.from('OTRAS INSTALACIONES INSPECCIONADAS')));
|
||||||
|
assert.ok(word.buffer.includes(Buffer.from('word/media/photo-2.png')));
|
||||||
|
assert.ok(word.buffer.includes(other));
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Auditoría integral DH V2 · F6.8 · 15/09/2026
|
||||||
|
|
||||||
|
## Alcance
|
||||||
|
Revisión del árbol activo API V3, WEB V2 y Android de campo contra el modelo funcional vigente. La auditoría distingue superficie activa, compatibilidad histórica y código fuente retirado/no enrutable para no confundir legado con funcionalidad productiva.
|
||||||
|
|
||||||
|
## 1. Acceso, usuarios, roles y perfil
|
||||||
|
- El acceso autenticado, permisos y cambio de contraseña siguen aislados por permisos.
|
||||||
|
- La firma reutilizable del inspector se administra en Mi perfil y se consulta desde Android antes del cierre del Acta.
|
||||||
|
- Corrección F6.8: Android ya no presume que existe la firma. Si se configura después de haber guardado un cierre pendiente, “Volver a verificar” reintenta automáticamente la sincronización.
|
||||||
|
- Corrección F6.8: cola y caché offline están separadas por `userId`; un segundo usuario del mismo dispositivo no puede sincronizar ni reutilizar datos locales del anterior.
|
||||||
|
|
||||||
|
## 2. Planificación e Inspecciones
|
||||||
|
- Flujo activo: oficina planifica; la APK inicia y cierra la ejecución física.
|
||||||
|
- Contexto vigente: Departamento → Área → Yacimiento; Operadora temporal del Área; el Yacimiento fija el alcance físico.
|
||||||
|
- La planificación activa no usa fecha de fin prevista. Las referencias `plannedEndAt` encontradas pertenecen al subsistema Survey retirado y no están en rutas activas.
|
||||||
|
- Corrección F6.8: inicio y cierre de Inspección quedan persistidos localmente si se pierde conectividad y se sincronizan en orden al recuperarla. Un rechazo funcional del servidor revierte el estado optimista local.
|
||||||
|
|
||||||
|
## 3. Actas
|
||||||
|
- Una Inspección puede contener múltiples Actas, manteniendo como máximo una en borrador simultáneamente.
|
||||||
|
- La urgencia se define al bloquear/cerrar el contenido del Acta completa; no pertenece individualmente a cada Hallazgo.
|
||||||
|
- El Acta bloqueada es inmutable y pasa a firma. La firma del inspector proviene del perfil; la persona acompañante firma por cada Acta.
|
||||||
|
- Flujo activo de acompañante: conforme, disconformidad con motivo o negativa a firmar con motivo. La opción de crear nuevas ausencias fue retirada de la interfaz; `ABSENT` se conserva únicamente para leer registros históricos.
|
||||||
|
- Corrección crítica F6.8: Android enviaba `uploadMode=ONLINE`, valor inválido para la API. Se reemplazó por `IMMEDIATE` y la cola offline usa `DEFERRED`.
|
||||||
|
- Corrección F6.8: alta, bloqueo, responsable, manifestación, firma y sellado pueden quedar en cola local sin perderse.
|
||||||
|
|
||||||
|
## 4. Hallazgos y evidencia
|
||||||
|
- Los Hallazgos pertenecen a un Acta explícita y a un elemento de Inventario incluido en ella.
|
||||||
|
- El catálogo se resuelve por Tipo de instalación/subinstalación y mantiene OTROS como salida no restrictiva.
|
||||||
|
- Corrección F6.8: alta de Hallazgo y carga de evidencia usan UUID de cliente para que un reintento offline no duplique registros.
|
||||||
|
- Corrección F6.8: cada fotografía se revisa antes de incorporarse: usar, volver a tomar o eliminar; conserva GPS y fecha de captura.
|
||||||
|
- Se mantienen múltiples fotografías por Hallazgo.
|
||||||
|
|
||||||
|
## 5. Inventarios
|
||||||
|
- Jerarquía activa: Departamento → Área → Yacimiento → Instalación → Subinstalación.
|
||||||
|
- Corrección de nomenclatura: la antigua expresión visible “clasificación técnica/familia técnica” se presenta como “Tipo de instalación” o “Tipo de subinstalación”. Los nombres físicos internos de tablas/variables se conservan para evitar migraciones destructivas.
|
||||||
|
- Altas en campo conservan GPS y foto obligatoria antes de habilitar Hallazgos.
|
||||||
|
- Corrección F6.8: creación, selección, foto y asociación al Acta pueden persistirse offline con identidad estable e idempotencia de servidor.
|
||||||
|
- Historial, dossier y consulta temporal siguen separados del estado actual para no reescribir historia.
|
||||||
|
|
||||||
|
## 6. Acta documental, fotos y Dashboard
|
||||||
|
- Desvío corregido: la ficha de Acta mezclaba demasiada información administrativa y el PDF/fotos no eran contenido principal.
|
||||||
|
- F6.8 agrega “Documento del Acta” con apertura y descarga del PDF real sellado.
|
||||||
|
- Las fotos de Hallazgos se muestran como miniaturas dentro del Acta.
|
||||||
|
- Las fotos de Inventario se limitan a eventos de campo de la misma Inspección y a elementos incluidos en esa Acta; no se mezclan fotos históricas de otras visitas.
|
||||||
|
- La proyección documental anterior queda sólo como referencia colapsada, no como sustituto del PDF oficial.
|
||||||
|
|
||||||
|
## 7. Seguimiento, verificaciones y vencimientos
|
||||||
|
- El seguimiento administrativo permanece separado de la captura de campo.
|
||||||
|
- Respuesta de empresa, documentación, fechas informadas y verificación física conservan trazabilidad sin reabrir el Acta.
|
||||||
|
- La planificación de verificaciones continúa basada en Hallazgos abiertos y no reemplaza el plazo administrativo del Acta.
|
||||||
|
- No se detectaron rutas activas que vuelvan a asignar la urgencia al Hallazgo.
|
||||||
|
|
||||||
|
## 8. Informes, GEDO y entrega documental
|
||||||
|
- Flujo activo confirmado: Acta → INF → oficialización GEDO/IF.
|
||||||
|
- Un INF corresponde a una sola Acta; una Inspección con varias Actas puede producir varios INF.
|
||||||
|
- El circuito legacy de aprobación/firma final por Director fue retirado de la superficie activa; sus menciones permanecen sólo en migraciones reversibles/historia.
|
||||||
|
- La entrega documental y SMTP continúan bajo permisos administrativos y trazabilidad.
|
||||||
|
|
||||||
|
## 9. Importaciones y compatibilidad histórica
|
||||||
|
- El módulo API de importaciones se conserva, pero la antigua pantalla de importación fue retirada deliberadamente de las rutas WEB en F5; no se reactivó durante F6.8 para no mezclar el contrato anterior con el modelo autoritativo actual.
|
||||||
|
- Las páginas Survey y campos como `plannedEndAt` existen como fuente histórica sin ruta activa; los módulos Survey tampoco están registrados en `AppModule`.
|
||||||
|
- Se mantienen redirects `/activos` → `/inventarios` y valores históricos de estados de Acta únicamente para compatibilidad de enlaces/datos existentes.
|
||||||
|
|
||||||
|
## 10. Offline, consistencia y seguridad de sincronización
|
||||||
|
- Room persiste operaciones y contexto descargado; WorkManager reintenta sólo con red disponible.
|
||||||
|
- Las operaciones dependientes se procesan en orden estable y la cola se detiene ante un error funcional permanente para no ejecutar hijos sobre un padre rechazado.
|
||||||
|
- Las operaciones críticas usan identificadores de cliente y columnas de idempotencia para evitar duplicados tras cortes/reintentos.
|
||||||
|
- Los archivos pendientes se almacenan dentro del espacio privado de la aplicación hasta quedar sincronizados.
|
||||||
|
|
||||||
|
## Barreras de aceptación F6.8
|
||||||
|
- API: build + suite integral de contratos/unidad.
|
||||||
|
- WEB: typecheck/build de producción.
|
||||||
|
- Android: compilación Debug/Release, tests unitarios, APK Debug instalable y lint por variante dentro de los límites del VPS.
|
||||||
|
- Deploy productivo: backup previo, migraciones, recreación API/WEB, health/versiones y ausencia de migraciones pendientes.
|
||||||
|
|
||||||
|
## Compatibilidad intencional que NO debe eliminarse a ciegas
|
||||||
|
- Estados/eventos históricos de Acta (`READY`, `CLOSED`, `RECTIFIED`, `ABSENT`) para lectura de expedientes existentes.
|
||||||
|
- Rutas antiguas `/activos` como redirección de compatibilidad.
|
||||||
|
- Migraciones históricas y textos de rollback: no se reescriben.
|
||||||
|
- Fuentes Survey/legacy no enrutadas: no forman parte del producto activo, pero sirven a contratos históricos y rollback.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# F6.10 · Acta PDF completa y autoexplicativa
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Ordenar el Acta consolidada para que pueda comprenderse sin consultar el sistema y reforzar la explicación de cada Hallazgo.
|
||||||
|
|
||||||
|
## Contenido documental
|
||||||
|
|
||||||
|
El PDF se organiza en siete bloques: identificación, contexto territorial y operativo, intervinientes, datos del Acta, Hallazgos, firmas/manifestaciones e integridad/cierre.
|
||||||
|
|
||||||
|
Cada Hallazgo congela y presenta el elemento afectado, tipo de instalación, ruta Departamento → Área → Yacimiento → Instalación/Subinstalación, denominación, constatación, referencia de catálogo u OTROS, criterio técnico disponible, base legal, gravedad, reincidencia/antecedente y fotografías vinculadas directamente al Hallazgo.
|
||||||
|
|
||||||
|
No se incorporan al Acta altas de campo, instalaciones ni fotografías sin Hallazgo. Los plazos y la urgencia continúan perteneciendo al Acta, no a los Hallazgos.
|
||||||
|
|
||||||
|
## Inmutabilidad
|
||||||
|
|
||||||
|
El snapshot de cierre sube a `DH-ACT-LIFECYCLE-V5` para congelar contexto territorial, operadora, inspectores, ruta técnica y metadatos de catálogo/reincidencia. El PDF consolidado pasa a plantilla documental v3, conservando disponibles las revisiones v1/v2 ya emitidas.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
- API typecheck: OK
|
||||||
|
- API tests: 470/470
|
||||||
|
- API build: OK
|
||||||
|
- PDF de muestra: 3 páginas, lectura completa y fechas Mendoza en formato administrativo de 24 horas.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# F6.11 · GEDO y respuestas de Informes
|
||||||
|
|
||||||
|
## Regla funcional
|
||||||
|
|
||||||
|
GEDO no está integrado como respuesta automática. El Informe permanece en preparación hasta que un usuario carga manualmente el identificador IF, la fecha y el PDF oficial emitido por GEDO.
|
||||||
|
|
||||||
|
La oficialización documental no crea una respuesta de empresa ni activa un vencimiento por sí sola.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. El Acta sellada origina un INF editable.
|
||||||
|
2. El usuario carga manualmente IF + PDF oficial GEDO.
|
||||||
|
3. El Informe queda oficializado e inmutable en su contenido técnico.
|
||||||
|
4. Desde el Informe se define un único vencimiento de respuesta para su Acta.
|
||||||
|
5. Ese vencimiento se proyecta a todos los Hallazgos no anulados del Acta.
|
||||||
|
6. Las respuestas de empresa se registran después de la oficialización y quedan relacionadas con `report_id` + `act_id`.
|
||||||
|
7. Cada respuesta puede incluir fecha de recepción, detalle, compromiso, contacto y PDF.
|
||||||
|
|
||||||
|
Los vencimientos y respuestas históricas permanecen append-only y no se reescriben para completar relaciones nuevas.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# F6.12 · Geometrías y correo SMTP
|
||||||
|
|
||||||
|
- Corrige el contrato GeoJSON entre PostGIS, WEB y MapLibre: `Point`, `LineString` y `Polygon` se usan para render; `POINT`, `LINESTRING` y `POLYGON` quedan como tipos técnicos al persistir.
|
||||||
|
- El editor de geometrías vuelve a cargar correctamente geometrías existentes y conserva el tipo técnico separado del GeoJSON.
|
||||||
|
- La vista general del mapa vuelve a calcular bounds sobre GeoJSON real.
|
||||||
|
- El SMTP administrable requiere una clave maestra AES-256-GCM persistente; si falta o es inválida, el API devuelve un error de configuración explícito en vez de un 500 genérico.
|
||||||
|
- El rol Administrador recibe `inspection_reports.generate`, coherente con su definición de administración total.
|
||||||
+13
-17
@@ -16,8 +16,6 @@ STAMP="$(date +%Y%m%d_%H%M%S)"
|
|||||||
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
|
||||||
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
STAGE="/root/dhv2-gitea-stage-${STAMP}"
|
||||||
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
|
||||||
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
|
|
||||||
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
|
|
||||||
PHASE="bootstrap"
|
PHASE="bootstrap"
|
||||||
PREV_SHA=""
|
PREV_SHA=""
|
||||||
TARGET_SHA=""
|
TARGET_SHA=""
|
||||||
@@ -32,7 +30,6 @@ cleanup() {
|
|||||||
set +e
|
set +e
|
||||||
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
|
||||||
rm -rf "$STAGE"
|
rm -rf "$STAGE"
|
||||||
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
publish_status() {
|
publish_status() {
|
||||||
@@ -159,11 +156,13 @@ fi
|
|||||||
|
|
||||||
PREV_SHA="$(git rev-parse HEAD)"
|
PREV_SHA="$(git rev-parse HEAD)"
|
||||||
PHASE="fetch"
|
PHASE="fetch"
|
||||||
git fetch origin "$DEPLOY_REF"
|
git fetch origin "$DEPLOY_REF" main
|
||||||
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
|
||||||
|
MAIN_SHA="$(git rev-parse origin/main)"
|
||||||
|
|
||||||
echo "Actual: $PREV_SHA"
|
echo "Actual: $PREV_SHA"
|
||||||
echo "Objetivo: $TARGET_SHA"
|
echo "Objetivo: $TARGET_SHA"
|
||||||
|
echo "Main: $MAIN_SHA"
|
||||||
|
|
||||||
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
||||||
echo "Producción ya está en el commit autorizado."
|
echo "Producción ya está en el commit autorizado."
|
||||||
@@ -171,6 +170,13 @@ if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$TARGET_SHA" != "$MAIN_SHA" ]; then
|
||||||
|
echo "ERROR: deploy no coincide con main; se rechaza una promoción manual o incompleta."
|
||||||
|
echo "deploy: $TARGET_SHA"
|
||||||
|
echo "main: $MAIN_SHA"
|
||||||
|
false
|
||||||
|
fi
|
||||||
|
|
||||||
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
|
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
|
||||||
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
|
||||||
false
|
false
|
||||||
@@ -193,19 +199,9 @@ while IFS= read -r -d '' script; do
|
|||||||
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "========== TEST API CANDIDATA =========="
|
echo "========== PREFLIGHT DE DEPLOY =========="
|
||||||
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
|
echo "Gitea Actions ya validó tests, migraciones e imágenes de producción."
|
||||||
docker run --rm \
|
echo "El VPS valida únicamente composición, scripts, backup, migraciones reales, recreación y health."
|
||||||
-v "$STAGE/api-v3/test:/app/test:ro" \
|
|
||||||
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
|
||||||
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
|
|
||||||
-v "$STAGE/web-v2:/web-v2:ro" \
|
|
||||||
-v "$STAGE/android-app:/android-app:ro" \
|
|
||||||
"$API_TEST_IMAGE" npm test </dev/null
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "========== BUILD WEB CANDIDATA =========="
|
|
||||||
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
|
|
||||||
|
|
||||||
PHASE="backup"
|
PHASE="backup"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ COPY package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY tsconfig*.json vite.config.ts index.html ./
|
COPY tsconfig*.json vite.config.ts index.html ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY scripts ./scripts
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location = /assets/maplibre-gl-worker.mjs {
|
||||||
|
default_type application/javascript;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-3",
|
"version": "0.23.0-10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-3",
|
"version": "0.23.0-10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"maplibre-gl": "6.4.1",
|
"maplibre-gl": "6.4.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-web",
|
"name": "dhv2-web",
|
||||||
"version": "0.23.0-3",
|
"version": "0.23.0-10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build && node scripts/copy-maplibre-worker.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { chmodSync, copyFileSync, mkdirSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const source = resolve('node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs');
|
||||||
|
const target = resolve('dist/assets/maplibre-gl-worker.mjs');
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
copyFileSync(source, target);
|
||||||
|
chmodSync(target, 0o644);
|
||||||
|
const bytes = statSync(target).size;
|
||||||
|
if (bytes < 1000) throw new Error(`MapLibre worker inválido: ${bytes} bytes`);
|
||||||
|
console.log(`MapLibre worker: ${target} (${bytes} bytes)`);
|
||||||
@@ -18,7 +18,7 @@ const actSections: ProjectionSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Alcance y constatación',
|
title: 'Alcance y constatación',
|
||||||
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
|
description: 'Objeto de la actuación, descripción de lo actuado y observaciones generales de la inspección.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Hallazgos',
|
title: 'Hallazgos',
|
||||||
@@ -34,7 +34,7 @@ const actSections: ProjectionSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Anexos',
|
title: 'Anexos',
|
||||||
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
|
description: 'Sólo evidencia vinculada a Hallazgos del Acta. Las fotos de inventario y altas de campo sin Hallazgos quedan fuera del documento.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type IconName =
|
export type IconName =
|
||||||
| 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert'
|
| 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert'
|
||||||
| 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu'
|
| 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu'
|
||||||
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock';
|
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail' | 'lock' | 'camera';
|
||||||
|
|
||||||
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
||||||
const paths: Record<IconName, React.ReactNode> = {
|
const paths: Record<IconName, React.ReactNode> = {
|
||||||
@@ -26,6 +26,7 @@ export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
|
|||||||
upload: <><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/></>,
|
upload: <><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/></>,
|
||||||
mail: <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></>,
|
mail: <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></>,
|
||||||
lock: <><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></>,
|
lock: <><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></>,
|
||||||
|
camera: <><path d="M4 7h3l2-3h6l2 3h3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="13" r="4"/></>,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.23.0-3';
|
export const APP_VERSION = '0.23.0-10';
|
||||||
export const APP_PHASE = 'F6.2 · Firma por Acta y correo de usuario';
|
export const APP_PHASE = 'F6.13 · Mapa operativo';
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
|
|||||||
.catch((requestError) => {
|
.catch((requestError) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const message = errorMessage(requestError);
|
const message = errorMessage(requestError);
|
||||||
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
|
if (message.includes('no tiene tipo')) setUnsupported(true);
|
||||||
else setError(message);
|
else setError(message);
|
||||||
})
|
})
|
||||||
.finally(() => active && setLoading(false));
|
.finally(() => active && setLoading(false));
|
||||||
@@ -52,10 +52,10 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
|
|||||||
.some((value) => value.toLocaleLowerCase().includes(needle)));
|
.some((value) => value.toLocaleLowerCase().includes(needle)));
|
||||||
}, [items, search]);
|
}, [items, search]);
|
||||||
|
|
||||||
if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>;
|
if (loading) return <div className="panel"><LoadingBlock label="Cargando tipo y Hallazgos aplicables…" /></div>;
|
||||||
if (error) return <Alert>{error}</Alert>;
|
if (error) return <Alert>{error}</Alert>;
|
||||||
if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
|
if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
|
||||||
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div>
|
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin tipo asignado</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones con su tipo definido.</p></div></div>
|
||||||
</section>;
|
</section>;
|
||||||
|
|
||||||
return <div className="asset-tab-stack">
|
return <div className="asset-tab-stack">
|
||||||
@@ -65,14 +65,14 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
|
|||||||
<div>
|
<div>
|
||||||
<span className="eyebrow">HALLAZGOS APLICABLES</span>
|
<span className="eyebrow">HALLAZGOS APLICABLES</span>
|
||||||
<h2>{technical.family.name}</h2>
|
<h2>{technical.family.name}</h2>
|
||||||
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
|
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a este tipo. Éste es el mismo catálogo que consume la APK para este objeto.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="count-pill">{items.length} asociados</span>
|
<span className="count-pill">{items.length} asociados</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canManage && <div className="temporal-notice">
|
{canManage && <div className="temporal-notice">
|
||||||
<Icon name="layers" />
|
<Icon name="layers" />
|
||||||
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
|
<p><strong>¿Falta un Hallazgo?</strong> Administrá el tipo en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo.</p>
|
||||||
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
|
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: stri
|
|||||||
</label>}
|
</label>}
|
||||||
|
|
||||||
{items.length === 0
|
{items.length === 0
|
||||||
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
|
? <div className="inline-empty">Este tipo todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
|
||||||
: visible.length === 0
|
: visible.length === 0
|
||||||
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
|
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
|
||||||
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
|
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user