Compare commits

...
Author SHA1 Message Date
admin 30a06f17f3 F2.2: identificar health de API 2026-09-06 16:55:38 -03:00
admin f8da455241 F2.2: versionar API 0.22.0-1 2026-09-06 16:55:32 -03:00
admin 0103422473 android: corregir token de cancelación GPS 2026-09-06 16:49:16 -03:00
admin cd42e55b7b ci: validar backend y web en rama F2.2 2026-09-06 16:42:32 -03:00
admin 205b97f16b ci: compilar APK Android F2.2 2026-09-06 16:41:48 -03:00
admin 118e41c938 android: implementar flujo inspector y alta de Inventario 2026-09-06 16:41:07 -03:00
admin 00ac09f358 android: agregar actividad principal Compose 2026-09-06 16:39:22 -03:00
admin 199129847e android: agregar estado y operaciones de campo 2026-09-06 16:38:52 -03:00
admin 216a813f50 android: implementar contrato API y sesión cifrada 2026-09-06 16:38:21 -03:00
admin ad79fb589d android: agregar tema base 2026-09-06 16:37:08 -03:00
admin 0b2c7f0216 android: agregar recursos de texto 2026-09-06 16:37:01 -03:00
admin 2a7bf54ce1 android: configurar almacenamiento de fotos 2026-09-06 16:36:54 -03:00
admin 692e6119cc android: declarar permisos y FileProvider 2026-09-06 16:36:48 -03:00
admin b3d6ae1326 android: agregar reglas base 2026-09-06 16:36:40 -03:00
admin 0d181b37a0 android: configurar aplicación DH Inspección 2026-09-06 16:36:32 -03:00
admin db3d87727e android: fijar propiedades de build 2026-09-06 16:36:18 -03:00
admin 5b708e433f android: configurar AGP y Kotlin 2026-09-06 16:36:13 -03:00
admin 3d101e239c android: iniciar proyecto nativo F2.2 2026-09-06 16:36:04 -03:00
admin 3a0c37b2a7 F2.2: integrar autenticación móvil segura
F2.2: contrato seguro de autenticación Android
2026-09-06 16:33:48 -03:00
admin 4c01cf6ffe fix: alinear health con API 0.21.0-2 2026-09-05 22:27:29 -03:00
admin 66333fb5dd ops: blindar reset preservando configuración estructural 2026-09-05 22:17:45 -03:00
admin 06f809b4c8 ops: versionar API 0.21.0-2 para reset limpio 2026-09-05 22:15:10 -03:00
admin 343c889250 ops: preparar reset limpio de datos operativos 2026-09-05 22:12:18 -03:00
admin 4fb1c502c8 F2.2: registrar autenticación móvil 2026-09-05 20:20:56 -03:00
admin d895812235 F2.2: exponer endpoints de autenticación Android 2026-09-05 20:20:47 -03:00
admin f680764373 F2.2: implementar autenticación Bearer Android 2026-09-05 20:20:40 -03:00
admin acc17bf291 F2.2: agregar DTO de refresh móvil 2026-09-05 20:20:16 -03:00
admin 1b57dcc850 maintenance: retirar diagnóstico Android temporal 2026-09-05 20:12:53 -03:00
admin 77d750992d maintenance: localizar fuente Android en VPS
Diagnóstico temporal de solo lectura para localizar fuentes/artefactos Android en el VPS y publicar el resultado en deploy-status.
2026-09-05 20:11:19 -03:00
admin b30cc1a294 maintenance: corregir diagnóstico Android de solo lectura 2026-09-05 20:07:58 -03:00
admin a7a6680cf8 maintenance: descubrir fuente Android en VPS sin modificar datos 2026-09-05 20:07:40 -03:00
admin 44570ad457 maintenance: retirar fresh-start temporal
Restaura migration:run y deploy-github estándar y elimina la herramienta fresh-start luego de la limpieza exitosa.
2026-09-05 20:04:46 -03:00
admin 8caed06362 maintenance: retirar herramienta destructiva temporal 2026-09-05 20:01:48 -03:00
admin e3db3d6cd7 maintenance: restaurar deploy estándar tras fresh-start 2026-09-05 20:01:37 -03:00
admin fb7b29cd94 maintenance: retirar hook de fresh-start 2026-09-05 20:01:08 -03:00
admin 0a11a452b5 maintenance: ejecutar fresh-start definitivo
Fresh-start único: backup integral de DB/source/media, limpieza de datos operativos y usuarios no-admin, reset de configuración editable, vaciado de archivos y verificación final.
2026-09-05 19:57:49 -03:00
admin 1d661e2e48 maintenance: respaldar y vaciar volumen de archivos en fresh-start 2026-09-05 19:54:48 -03:00
admin 2ee956665f maintenance: armar ejecución definitiva del fresh-start 2026-09-05 19:54:11 -03:00
admin 0d5db42c77 maintenance: cerrar whitelist del fresh-start
Ajusta el preview para limpiar también Departamentos importados, conservar sólo estructura sembrada y reiniciar configuración institucional editable. Reactiva FK antes de eliminar usuarios.
2026-09-05 19:50:46 -03:00
admin 21127b8cff maintenance: limpiar departamentos importados y resetear configuración editable 2026-09-05 19:47:42 -03:00
admin 50a9dc5183 maintenance: preservar configuración estructural y reforzar FK 2026-09-05 19:46:35 -03:00
admin a69df1d2d6 maintenance: preview seguro de limpieza integral
Agrega un fresh-start protegido y ejecuta únicamente el modo preview durante el paso de migraciones para inventariar la base real antes de borrar datos.
2026-09-05 19:41:46 -03:00
admin 95f34e2f08 maintenance: enlazar preview al paso de migraciones 2026-09-05 19:39:01 -03:00
admin 2f0d0d5ffc maintenance: exponer comando fresh-start 2026-09-05 19:38:08 -03:00
admin d2c79aeddc maintenance: agregar fresh-start protegido en modo preview 2026-09-05 19:37:54 -03:00
admin aef5a3d73d F2.1 · Inventario nacido en campo
Búsqueda y alta contextual de Inventario desde la APK, jerarquía dinámica, código de campo, GPS del dispositivo, fotos/EXIF y garantía de captura previa a emitir Hallazgos.
2026-09-05 18:08:49 -03:00
admin 1a51bdf820 F2.1: validar contexto de tipos de campo 2026-09-05 18:05:35 -03:00
admin 2142331fc0 F2.1: probar contrato de Inventario nacido en campo 2026-09-05 18:05:08 -03:00
admin f88dc8c2e0 F2.1: exigir GPS y foto antes de un Hallazgo 2026-09-05 18:04:20 -03:00
admin 6acbb26a82 F2.1: identificar fase de Inventario de campo 2026-09-05 18:02:50 -03:00
admin 56e5c9b59e F2.1: versionar API 0.21.0-1 2026-09-05 18:02:43 -03:00
admin 685b561821 F2.1: corregir alta contextual y captura de campo 2026-09-05 18:02:16 -03:00
admin ec600a1aff F2.1: marcar altas y selecciones de campo 2026-09-05 18:00:50 -03:00
admin 823e905aa3 F2.1: exportar servicios de Inventario para campo 2026-09-05 18:00:37 -03:00
admin 0b8e673072 F2.1: registrar Inventario de campo 2026-09-05 18:00:19 -03:00
admin 2c45eb7485 F2.1: exponer Inventario de campo a la APK 2026-09-05 18:00:01 -03:00
admin 3f6c537f05 F2.1: servicio móvil de Inventario nacido en campo 2026-09-05 17:47:03 -03:00
admin aed83e9ad7 F2.1: metadatos GPS y EXIF para foto de campo 2026-09-05 17:45:25 -03:00
admin e35ee78b08 F2.1: contrato de alta de Inventario en campo 2026-09-05 17:45:13 -03:00
admin 250540e076 F2.1: consulta móvil de Inventario por contexto 2026-09-05 17:44:58 -03:00
admin c527bc57dc F2.1: persistencia append-only para Inventario nacido en campo 2026-09-05 17:44:47 -03:00
admin 9da2f85c3c Merge pull request #9 from enlineawork/feature/f1-4-dashboard-briefing
F1.4 · Briefing contextual antes de salir
2026-09-05 17:28:39 -03:00
admin 0c309036cb F1.4: identificar briefing contextual en inspección 2026-09-05 17:25:32 -03:00
admin 6bda0f68fc F1.4: versionar dashboard 0.20.0-4 2026-09-05 17:25:26 -03:00
admin 6a86f67eba F1.4: mostrar briefing antes del cierre de planificación 2026-09-05 17:24:56 -03:00
admin 37795e6586 F1.4: integrar pendientes en la planificación de inspección 2026-09-05 17:24:42 -03:00
admin 3cd9cdd104 F1.4: compartir contrato del briefing en la ficha de inspección 2026-09-05 17:24:24 -03:00
admin 205ff1bab8 F1.3 · Vista web de preparación de campo
Completa F1.3 con la vista web imprimible de preparación de campo, selector buscable y lectura Acta → Hallazgos → Inventario.
2026-09-05 17:19:42 -03:00
admin d954ec135f F1.3 · Planificación inteligente de pendientes
Agrega briefing de campo por Inspección agrupado por Acta anterior y Hallazgos abiertos, usando plazo administrativo del Acta, respuesta de empresa, fecha comprometida y pendientes de verificación.
2026-09-05 17:10:41 -03:00
admin 4c5742c548 F1.2 · Seguimiento administrativo por Acta
Plazo único por Acta, respuestas de empresa con PDF, fecha comprometida, estados administrativos, cola y calendario por Acta.
2026-09-05 16:47:46 -03:00
admin cd24736df8 F1.1 · Inspección multi-Acta y firma diferida
Implementa el nuevo núcleo operativo: múltiples Actas por Inspección, un Informe por Acta, cierre de campo independiente y firma de empresa diferida con conformidad/disidencia.
2026-09-05 14:46:26 -03:00
admin 8e2d0dfe19 infra: endurecer preflight de deploy
Aísla el preflight candidato de producción y evita reinicios innecesarios antes de tocar la aplicación.
2026-09-05 10:25:44 -03:00
86 changed files with 5158 additions and 116 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Android APK
on:
push:
branches:
- feature/f2-2-android-v2
paths:
- 'android-app/**'
- '.github/workflows/android.yml'
pull_request:
paths:
- 'android-app/**'
- 'api-v3/src/auth/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
build-debug-apk:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Android SDK
uses: android-actions/setup-android@v3
- name: Android API 36
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.13'
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F2.2-0.10.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14
+52
View File
@@ -0,0 +1,52 @@
name: F2.2 Integration CI
on:
push:
branches:
- feature/f2-2-android-v2
paths:
- 'api-v3/**'
- 'web-v2/**'
- 'scripts/**'
- 'docker-compose.yml'
- '.github/workflows/f2-2-ci.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · typecheck, tests, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
web:
name: WEB · typecheck, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run build
+77
View File
@@ -0,0 +1,77 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.korexlabs.dhinspeccion"
compileSdk = 36
defaultConfig {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 14
versionName = "0.10.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// La firma de release NO se redefine aquí. Se conservará la clave histórica.
}
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions.jvmTarget = "17"
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.08.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.exifinterface:exifinterface:1.4.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
+1
View File
@@ -0,0 +1 @@
# DH Inspección V2. Las reglas se ampliarán cuando se habilite minificación de release.
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,19 @@
package com.korexlabs.dhinspeccion
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.viewmodel.compose.viewModel
import com.korexlabs.dhinspeccion.ui.DhApp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val model: MainViewModel = viewModel()
DhApp(model)
}
}
}
@@ -0,0 +1,209 @@
package com.korexlabs.dhinspeccion
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.FieldAssetDetail
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DhRepository(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
var busy by mutableStateOf(false)
private set
var error: String? by mutableStateOf(null)
private set
var notice: String? by mutableStateOf(null)
private set
var visits: List<VisitSummary> by mutableStateOf(emptyList())
private set
var visit: VisitDetail? by mutableStateOf(null)
private set
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set
init {
if (session != null) loadVisits()
}
fun clearMessages() {
error = null
notice = null
}
fun login(identifier: String, password: String) {
if (identifier.isBlank() || password.isBlank()) {
error = "Ingresá usuario y contraseña."
return
}
launchBusy {
session = repository.login(identifier, password)
notice = "Sesión iniciada."
loadVisitsInternal()
}
}
fun logout() {
viewModelScope.launch {
runCatching { repository.logout() }
session = null
visits = emptyList()
visit = null
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
}
}
fun loadVisits() = launchBusy { loadVisitsInternal() }
private suspend fun loadVisitsInternal() {
visits = repository.visits().data
}
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
}
fun closeVisitView() {
visit = null
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
loadVisits()
}
fun startVisit() {
val id = visit?.id ?: return
launchBusy {
visit = repository.startVisit(id)
notice = "Inspección iniciada."
loadVisitsInternal()
}
}
fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return
launchBusy {
inventory = repository.fieldInventory(id, search, parentId).data
}
}
fun loadFieldTypes(parentId: String? = null) {
val id = visit?.id ?: return
launchBusy {
fieldTypes = repository.fieldTypes(id, parentId).data
}
}
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección."
inventory = repository.fieldInventory(visitId, null, null).data
}
}
fun createFieldAsset(
type: FieldType,
parentId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para dar de alta Inventario."
return
}
launchBusy {
val request = CreateFieldInventoryRequest(
typeId = type.id,
parentId = parentId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
deviceAccuracyM = accuracyM,
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria."
inventory = repository.fieldInventory(visitId, null, null).data
}
}
fun uploadFieldPhoto(
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy {
val response = repository.uploadFieldPhoto(
visitId = visitId,
assetId = asset.id,
file = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados."
} else {
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
}
}
fun clearSelectedFieldAsset() {
selectedFieldAsset = null
}
private fun launchBusy(block: suspend () -> Unit) {
viewModelScope.launch {
busy = true
error = null
try {
block()
} catch (throwable: Throwable) {
error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null
} finally {
busy = false
}
}
}
}
@@ -0,0 +1,531 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.security.KeyStore
import java.time.Instant
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
// ---------- Auth ----------
data class LoginRequest(
val identifier: String,
val password: String,
val deviceLabel: String = "DH Android",
)
data class RefreshRequest(val refreshToken: String)
data class MobileUser(
val id: String,
val username: String,
val firstName: String? = null,
val lastName: String? = null,
val email: String? = null,
val mustChangePassword: Boolean = false,
val roles: List<String> = emptyList(),
val permissions: List<String> = emptyList(),
)
data class MobileSessionResponse(
val user: MobileUser,
val accessToken: String,
val refreshToken: String,
val accessExpiresInSeconds: Long,
)
data class StoredSession(
val userId: String,
val username: String,
val displayName: String,
val accessToken: String,
val refreshToken: String,
)
// ---------- Inspections ----------
data class AssetSummary(
val id: String,
val code: String,
val name: String,
val typeName: String? = null,
)
data class PersonSummary(
val id: String,
val username: String? = null,
val firstName: String? = null,
val lastName: String? = null,
)
data class VisitSummary(
val id: String,
val code: String,
val title: String? = null,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
val plannedStartAt: String? = null,
val actualStartedAt: String? = null,
val actualClosedAt: String? = null,
val instructions: String? = null,
val checklistGeneration: Int = 0,
val assetCount: Int = 0,
val memberCount: Int = 0,
)
data class VisitMeta(
val page: Int,
val pageSize: Int,
val total: Int,
val totalPages: Int,
)
data class VisitListResponse(val data: List<VisitSummary>, val meta: VisitMeta)
data class PlannedAsset(
val id: String,
val code: String,
val name: String,
val typeName: String? = null,
val included: Boolean = true,
val planningSource: String? = null,
val exclusionReason: String? = null,
)
data class ChecklistItem(
val id: String,
val findingId: String? = null,
val findingCode: String? = null,
val findingTitle: String? = null,
val findingStatus: String? = null,
val severity: Int? = null,
val itemKind: String? = null,
val referenceOn: String? = null,
val asset: AssetSummary? = null,
val assetIncluded: Boolean = true,
)
data class ChecklistSummary(
val generation: Int = 0,
val stale: Boolean = false,
val antecedents: Int = 0,
val companyOverdue: Int = 0,
val verificationOverdue: Int = 0,
val upcomingControls: Int = 0,
val actionableAssets: Int = 0,
val excludedAssets: Int = 0,
val items: List<ChecklistItem> = emptyList(),
)
data class VisitDetail(
val id: String,
val code: String,
val title: String? = null,
val objective: String? = null,
val status: String,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
val plannedStartAt: String? = null,
val actualStartedAt: String? = null,
val actualClosedAt: String? = null,
val instructions: String? = null,
val assets: List<AssetSummary> = emptyList(),
val planningAssets: List<PlannedAsset> = emptyList(),
val team: List<PersonSummary> = emptyList(),
val checklist: ChecklistSummary = ChecklistSummary(),
)
// ---------- Field inventory ----------
data class FieldContext(
val visitId: String? = null,
val visitCode: String? = null,
val areaId: String? = null,
val areaCode: String? = null,
val areaName: String? = null,
val companyId: String? = null,
val companyCode: String? = null,
val companyName: String? = null,
)
data class FieldInventoryItem(
val id: String,
val code: String,
val name: String,
val commonName: String? = null,
val informationStatus: String? = null,
val dataOrigin: String? = null,
val type: AssetSummary? = null,
val parent: AssetSummary? = null,
val selectedInInspection: Boolean = false,
val captureRequired: Boolean = false,
val hasGeometry: Boolean = false,
val fieldPhotoCount: Int = 0,
val readyForFinding: Boolean = true,
)
data class FieldInventoryListResponse(
val context: FieldContext,
val data: List<FieldInventoryItem>,
)
data class FieldAttributeDefinition(
val id: String,
val code: String,
val name: String,
val dataType: String,
val isRequired: Boolean = false,
val unit: String? = null,
val options: Any? = null,
val sortOrder: Int = 0,
)
data class FieldType(
val id: String,
val code: String,
val name: String,
val description: String? = null,
val attributes: List<FieldAttributeDefinition> = emptyList(),
)
data class FieldTypeResponse(
val context: FieldContext,
val parent: AssetSummary,
val data: List<FieldType>,
)
data class CaptureStatus(
val captureRequired: Boolean = false,
val hasGeometry: Boolean = false,
val creationGpsCaptured: Boolean = false,
val fieldPhotoCount: Int = 0,
val readyForFinding: Boolean = true,
)
data class FieldAssetDetail(
val context: FieldContext? = null,
val asset: FieldInventoryItem,
val selectedInInspection: Boolean = true,
val capture: CaptureStatus = CaptureStatus(),
)
data class CreateFieldInventoryRequest(
val typeId: String,
val parentId: String? = null,
val code: String? = null,
val name: String,
val commonName: String? = null,
val description: String? = null,
val discoveryNotes: String? = null,
val attributes: Map<String, Any?>,
val deviceLatitude: Double,
val deviceLongitude: Double,
val deviceAccuracyM: Double? = null,
val deviceCapturedAt: String,
val deviceLabel: String = "DH Android",
)
data class FieldPhotoResponse(
val capture: CaptureStatus,
)
interface DhApi {
@POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/mobile/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
@GET("inspection-visits")
suspend fun visits(
@Header("Authorization") authorization: String,
@Query("inspectorId") inspectorId: String,
@Query("pageSize") pageSize: Int = 100,
): VisitListResponse
@GET("inspection-visits/{id}")
suspend fun visit(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): VisitDetail
@POST("inspection-visits/{id}/start")
suspend fun startVisit(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): VisitDetail
@GET("inspection-visits/{visitId}/field-inventory")
suspend fun fieldInventory(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("search") search: String? = null,
@Query("parentId") parentId: String? = null,
@Query("limit") limit: Int = 80,
): FieldInventoryListResponse
@GET("inspection-visits/{visitId}/field-inventory/types")
suspend fun fieldTypes(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("parentId") parentId: String? = null,
): FieldTypeResponse
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
suspend fun selectFieldAsset(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
): FieldAssetDetail
@POST("inspection-visits/{visitId}/field-inventory")
suspend fun createFieldAsset(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: CreateFieldInventoryRequest,
): FieldAssetDetail
@Multipart
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/photos")
suspend fun uploadFieldPhoto(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Part file: MultipartBody.Part,
@Part("deviceLatitude") latitude: okhttp3.RequestBody,
@Part("deviceLongitude") longitude: okhttp3.RequestBody,
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
): FieldPhotoResponse
}
class SecureSessionStore(context: Context) {
private val prefs = context.getSharedPreferences("dh_v2_mobile_session", Context.MODE_PRIVATE)
private val alias = "dh_v2_mobile_session_key"
fun load(): StoredSession? {
val encoded = prefs.getString("payload", null) ?: return null
return runCatching {
val parts = encoded.split('.', limit = 2)
require(parts.size == 2)
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val encrypted = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv))
val json = JSONObject(String(cipher.doFinal(encrypted), Charsets.UTF_8))
StoredSession(
userId = json.getString("userId"),
username = json.getString("username"),
displayName = json.optString("displayName", json.getString("username")),
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
)
}.getOrElse {
clear()
null
}
}
fun save(response: MobileSessionResponse): StoredSession {
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
.joinToString(" ").trim().ifBlank { response.user.username }
val stored = StoredSession(
userId = response.user.id,
username = response.user.username,
displayName = displayName,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
)
val json = JSONObject()
.put("userId", stored.userId)
.put("username", stored.username)
.put("displayName", stored.displayName)
.put("accessToken", stored.accessToken)
.put("refreshToken", stored.refreshToken)
.toString()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
val payload = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." +
Base64.encodeToString(encrypted, Base64.NO_WRAP)
prefs.edit().putString("payload", payload).apply()
return stored
}
fun clear() {
prefs.edit().clear().apply()
}
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(
KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build(),
)
return generator.generateKey()
}
}
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(DhApi::class.java)
fun currentSession(): StoredSession? = store.load()
suspend fun login(identifier: String, password: String): StoredSession =
store.save(api.login(LoginRequest(identifier.trim(), password)))
suspend fun logout() {
val session = store.load()
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
store.clear()
}
suspend fun visits(): VisitListResponse = authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId)
}
suspend fun visit(id: String): VisitDetail = authorized { session ->
api.visit("Bearer ${session.accessToken}", id)
}
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id)
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
}
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
}
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session ->
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
}
suspend fun uploadFieldPhoto(
visitId: String,
assetId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadFieldPhoto(
authorization = "Bearer ${session.accessToken}",
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object {
fun humanError(error: Throwable): String {
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
if (!message.isNullOrBlank()) return message
return "Error HTTP ${error.code()}"
}
return error.message ?: "Ocurrió un error inesperado"
}
fun newOperationId(): String = UUID.randomUUID().toString()
}
}
@@ -0,0 +1,579 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class GeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun DhApp(model: MainViewModel) {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
when {
model.session == null -> LoginScreen(model)
model.visit == null -> VisitsScreen(model)
else -> VisitRouter(model)
}
}
}
}
@Composable
private fun MessageStrip(model: MainViewModel) {
val error = model.error
val notice = model.notice
if (error != null || notice != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
colors = CardDefaults.cardColors(
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(
text = error ?: notice.orEmpty(),
modifier = Modifier.padding(12.dp),
)
}
}
}
@Composable
private fun LoginScreen(model: MainViewModel) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
MessageStrip(model)
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Contraseña") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Button(
onClick = { model.login(identifier, password) },
modifier = Modifier.fillMaxWidth(),
enabled = !model.busy,
) {
if (model.busy) CircularProgressIndicator(modifier = Modifier.width(20.dp).height(20.dp))
else Text("Ingresar")
}
Text(
"El acceso móvil está reservado a usuarios con rol Inspector.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun VisitsScreen(model: MainViewModel) {
val session = model.session ?: return
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy) { Text("Actualizar") }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
}
}
Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) }
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
VisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun VisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let { Text("Prevista: ${shortDate(it)}", style = MaterialTheme.typography.bodySmall) }
Text("Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun VisitRouter(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
if (inventoryMode) {
FieldInventoryScreen(model) { inventoryMode = false }
} else {
VisitScreen(model) { inventoryMode = true }
}
}
@Composable
private fun VisitScreen(model: MainViewModel, onInventory: () -> Unit) {
val visit = model.visit ?: return
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
Text(visit.status, fontWeight = FontWeight.Bold)
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.instructions?.takeIf { it.isNotBlank() }?.let {
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Text("Instrucciones", fontWeight = FontWeight.Bold); Text(it) } }
}
MessageStrip(model)
if (visit.status == "PLANNED") {
Button(onClick = { model.startVisit() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
Text("Iniciar inspección")
}
}
if (visit.status == "PLANNED" || visit.status == "IN_PROGRESS") {
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Text("Inventario de campo") }
}
ChecklistCard(visit)
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (visit.planningAssets.isEmpty()) Text("Sin elementos planificados.")
visit.planningAssets.filter { it.included }.forEach { asset ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun ChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Checklist de antecedentes", fontWeight = FontWeight.Bold)
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
if (checklist.stale) Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
checklist.items.take(10).forEach { item ->
HorizontalDivider()
Text(item.findingTitle ?: item.findingCode ?: "Hallazgo", fontWeight = FontWeight.SemiBold)
Text("${item.asset?.name.orEmpty()} · Gravedad ${item.severity ?: "s/d"}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var search by rememberSaveable { mutableStateOf("") }
var showCreate by rememberSaveable { mutableStateOf(false) }
var parentId by rememberSaveable { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable { mutableStateOf("Área de la inspección") }
var name by rememberSaveable { mutableStateOf("") }
var commonName by rememberSaveable { mutableStateOf("") }
var selectedTypeId by rememberSaveable { mutableStateOf<String?>(null) }
val attributeValues = remember { mutableStateMapOf<String, String>() }
LaunchedEffect(visit.id) {
model.searchInventory("")
model.loadFieldTypes(null)
}
LaunchedEffect(model.fieldTypes) {
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = model.fieldTypes.firstOrNull()?.id
attributeValues.clear()
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val createWithLocation: () -> Unit = {
val type = selectedType
if (type != null) {
scope.launch {
runCatching { currentGeo(context) }
.onSuccess { geo ->
val values = buildAttributes(type, attributeValues)
model.createFieldAsset(type, parentId, name, commonName, values, geo.latitude, geo.longitude, geo.accuracyM)
}
}
}
}
val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (allowed) createWithLocation()
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<GeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentGeo(context) }.onSuccess { geo ->
val (file, uri) = newPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
}
}
val photoPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val camera = result[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || hasLocation(context)
if (camera && location) beginPhoto()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onBack) { Text("Volver") }
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) }
val selectedCapture = model.selectedFieldAsset
if (selectedCapture != null) {
CaptureCard(
detailName = selectedCapture.asset.name,
captureRequired = selectedCapture.capture.captureRequired,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = {
val permissions = arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
if (hasPermission(context, Manifest.permission.CAMERA) && hasLocation(context)) beginPhoto()
else photoPermissionLauncher.launch(permissions)
},
onClose = { model.clearSelectedFieldAsset() },
)
}
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre, código o atributo") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
if (visit.status == "IN_PROGRESS") {
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Alta en campo", fontWeight = FontWeight.Bold)
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Crear nuevo") }
}
}
if (showCreate && visit.status == "IN_PROGRESS") {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp).verticalScroll(rememberScrollState()).weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = "Área de la inspección"
model.loadFieldTypes(null)
}) { Text("Volver al Área") }
}
if (model.fieldTypes.isEmpty()) Text("No hay tipos habilitados debajo de este padre.")
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = { selectedTypeId = type.id; attributeValues.clear() },
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
OutlinedTextField(name, { name = it }, label = { Text("Nombre o código identificable *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(commonName, { commonName = it }, label = { Text("Nombre común") }, modifier = Modifier.fillMaxWidth())
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributeValues[it.code].orEmpty().isNotBlank() } ?: false
Button(
onClick = {
if (hasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
},
enabled = selectedType != null && name.isNotBlank() && requiredReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
}
Text("Resultados", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(model.inventory, key = { it.id }) { item ->
InventoryCard(
item = item,
canModify = visit.status == "IN_PROGRESS",
onSelect = { model.selectExisting(item) },
onUseParent = {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
showCreate = true
selectedTypeId = null
model.loadFieldTypes(item.id)
},
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
}
@Composable
private fun CaptureCard(
detailName: String,
captureRequired: Boolean,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(detailName, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (captureRequired && !ready) {
Text("El Hallazgo permanece bloqueado hasta completar GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else if (ready) {
Text("Captura completa · habilitado para Hallazgos", color = MaterialTheme.colorScheme.primary)
}
}
}
}
@Composable
private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect: () -> Unit, onUseParent: () -> Unit) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text("${item.code} · ${item.type?.name.orEmpty()}", style = MaterialTheme.typography.bodySmall)
item.commonName?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Listo" else "Captura incompleta: GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
if (canModify) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onSelect) { Text(if (item.selectedInInspection) "Abrir" else "Seleccionar") }
OutlinedButton(onClick = onUseParent) { Text("Crear hijo") }
}
}
}
}
}
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceAttribute(definition, raw)
}.toMap()
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun hasLocation(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!hasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
private fun newPhoto(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeExif(file: File, geo: GeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun shortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">DH Inspección</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DHInspeccion" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:colorAccent">#1B5E20</item>
<item name="android:navigationBarColor">#FFFFFF</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="inspection_photos" path="Pictures/" />
</paths>
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "DHInspeccion"
include(":app")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.19.6-4", "version": "0.20.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.19.6-4", "version": "0.20.0-2",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.19.6-4", "version": "0.22.0-1",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -0,0 +1,47 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
@Controller('act-administration')
export class ActAdministrationQueueController {
constructor(private readonly administration: ActAdministrationService) {}
@Get('queue') @RequirePermissions('inspection_acts.read') queue(@Query() query: ListActAdministrationQueryDto) { return this.administration.queue(query); }
@Get('calendar') @RequirePermissions('inspection_acts.read') calendar(@Query('from') from?: string, @Query('to') to?: string) { return this.administration.calendar(from, to); }
}
@Controller('inspection-acts/:actId/administration')
export class ActAdministrationController {
constructor(private readonly administration: ActAdministrationService) {}
@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')
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
}
@Controller('act-company-responses')
export class ActCompanyResponseContentController {
constructor(private readonly administration: ActAdministrationService) {}
@Get(':responseId/content') @RequirePermissions('inspection_acts.read')
async content(@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string, @Query('download') download: string | undefined, @Res() response: Response): Promise<void> {
const item = await this.administration.responseContent(responseId);
const disposition = download === '1' ? 'attachment' : 'inline';
const fallbackName = 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', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import {
ActAdministrationController,
ActAdministrationQueueController,
ActCompanyResponseContentController,
} from './act-administration.controller';
import { ActAdministrationService } from './act-administration.service';
import { FieldBriefingController } from './field-briefing.controller';
import { FieldBriefingService } from './field-briefing.service';
@Module({
imports: [AuditModule],
controllers: [
ActAdministrationQueueController,
ActAdministrationController,
ActCompanyResponseContentController,
FieldBriefingController,
],
providers: [ActAdministrationService, FieldBriefingService],
})
export class ActAdministrationModule {}
@@ -0,0 +1,180 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomUUID } from 'crypto';
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
import { join, resolve } from 'path';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
export const MAX_ACT_RESPONSE_BYTES = 15 * 1024 * 1024;
export interface UploadedActResponseFile { originalname: string; mimetype: string; size: number; buffer: Buffer }
const STORAGE = resolve(process.env.ACT_COMPANY_RESPONSE_STORAGE_DIR ?? '/app/storage/act-company-responses');
@Injectable()
export class ActAdministrationService {
constructor(private readonly dataSource: DataSource, private readonly audit: AuditService) {}
private baseCte() {
return `
WITH act_rows AS (
SELECT ia.id AS "actId", ia.code AS "actCode", ia.status AS "actStatus", ia.occurred_at AS "occurredAt",
ia.closed_at AS "closedAt", v.id AS "visitId", v.code AS "visitCode",
v.operational_area_id AS "areaId", v.operator_company_id AS "companyId",
area.name AS "areaName", COALESCE(op.legal_name, company.name) AS "companyName",
dl.id AS "deadlineEventId", dl.response_due_on AS "responseDueOn", dl.reason AS "deadlineReason",
dl.created_at AS "deadlineSetAt", rsp.id AS "latestResponseId", rsp.received_on AS "responseReceivedOn",
rsp.committed_correction_on AS "committedCorrectionOn",
COALESCE(fc.finding_count, 0)::int AS "findingCount",
COALESCE(fc.open_count, 0)::int AS "openFindingCount",
COALESCE(fc.scheduled_control_count, 0)::int AS "scheduledControlCount"
FROM inspection_acts ia
JOIN inspection_visits v ON v.id = ia.visit_id
LEFT JOIN assets area ON area.id = v.operational_area_id
LEFT JOIN assets company ON company.id = v.operator_company_id
LEFT JOIN organization_profiles op ON op.asset_id = v.operator_company_id
LEFT JOIN LATERAL (
SELECT d.* FROM inspection_act_deadline_events d WHERE d.act_id = ia.id ORDER BY d.created_at DESC, d.id DESC LIMIT 1
) dl ON true
LEFT JOIN LATERAL (
SELECT r.* FROM inspection_act_company_responses r WHERE r.act_id = ia.id ORDER BY r.received_on DESC, r.created_at DESC, r.id DESC LIMIT 1
) rsp ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) FILTER (WHERE f.status <> 'VOIDED') AS finding_count,
COUNT(*) FILTER (WHERE f.status = 'OPEN') AS open_count,
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
FROM inspection_findings f WHERE f.act_id = ia.id
) fc ON true
WHERE ia.status IN ('CLOSED', 'RECTIFIED')
), classified AS (
SELECT *, CASE
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
WHEN "latestResponseId" IS NOT NULL AND "committedCorrectionOn" IS NOT NULL AND "committedCorrectionOn" < CURRENT_DATE AND "openFindingCount" > 0 THEN 'COMMITMENT_OVERDUE'
WHEN "latestResponseId" IS NOT NULL AND "openFindingCount" > 0 AND "scheduledControlCount" = 0 THEN 'VERIFICATION_PENDING'
WHEN "latestResponseId" IS NOT NULL THEN 'RESPONSE_RECEIVED'
WHEN "responseDueOn" IS NULL THEN 'NEW'
WHEN "responseDueOn" < CURRENT_DATE THEN 'OVERDUE'
WHEN "responseDueOn" <= CURRENT_DATE + 3 THEN 'DUE_SOON'
ELSE 'WAITING_RESPONSE'
END AS "adminState"
FROM act_rows
)`;
}
async queue(query: ListActAdministrationQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const filters: string[] = [];
const args: unknown[] = [];
const add = (sql: string, value: unknown) => { args.push(value); filters.push(sql.replace('?', `$${args.length}`)); };
if (query.areaId) add('"areaId" = ?', query.areaId);
if (query.companyId) add('"companyId" = ?', query.companyId);
if (query.state && query.state !== 'ALL') add('"adminState" = ?', query.state);
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const rows = await this.dataSource.query(
`${this.baseCte()} SELECT *, COUNT(*) OVER()::int AS "totalRows" FROM classified ${where} ORDER BY "responseDueOn" ASC NULLS FIRST, "occurredAt" DESC LIMIT $${args.length + 1} OFFSET $${args.length + 2}`,
[...args, pageSize, (page - 1) * pageSize],
) as Array<Record<string, unknown>>;
const total = Number(rows[0]?.totalRows ?? 0);
const counterFilters: string[] = [];
const counterArgs: unknown[] = [];
if (query.areaId) { counterArgs.push(query.areaId); counterFilters.push(`"areaId" = $${counterArgs.length}`); }
if (query.companyId) { counterArgs.push(query.companyId); counterFilters.push(`"companyId" = $${counterArgs.length}`); }
const countersRaw = await this.dataSource.query(
`${this.baseCte()} SELECT "adminState" AS state, COUNT(*)::int AS count FROM classified ${counterFilters.length ? `WHERE ${counterFilters.join(' AND ')}` : ''} GROUP BY "adminState"`,
counterArgs,
) as Array<{ state: string; count: number }>;
const counters = Object.fromEntries(countersRaw.map((r) => [r.state, Number(r.count)]));
return {
data: rows.map(({ totalRows: _ignored, ...row }) => row),
counters,
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
};
}
async detail(actId: string) {
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE "actId" = $1`, [actId]);
const act = rows[0];
if (!act) throw new NotFoundException({ code: 'ACT_ADMIN_NOT_FOUND', message: 'El Acta no está disponible para seguimiento administrativo.' });
const deadlines = await this.dataSource.query(`SELECT id, response_due_on AS "responseDueOn", reason, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_deadline_events WHERE act_id = $1 ORDER BY created_at DESC`, [actId]);
const responses = await this.dataSource.query(`SELECT id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", sha256, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_company_responses WHERE act_id = $1 ORDER BY received_on DESC, created_at DESC`, [actId]);
const findings = await this.dataSource.query(`SELECT id, code, title, status, next_control_on AS "nextControlOn" FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' ORDER BY finding_number`, [actId]);
return { act, deadlines, responses, findings };
}
private async ensureClosedAct(actId: string) {
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
const act = rows[0];
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' });
return act;
}
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
const act = await this.ensureClosedAct(actId);
const rows = await this.dataSource.query(
`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"`,
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
);
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 } });
return rows[0];
}
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
const act = await this.ensureClosedAct(actId);
let storedName: string | null = null;
let sha256: string | null = null;
if (file) {
if (file.size <= 0 || file.size > MAX_ACT_RESPONSE_BYTES) throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_SIZE', message: 'El PDF supera el límite permitido.' });
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_TYPE', message: 'La respuesta adjunta debe ser un PDF válido.' });
await mkdir(STORAGE, { recursive: true });
sha256 = createHash('sha256').update(file.buffer).digest('hex');
storedName = `${randomUUID()}.pdf`;
await writeFile(join(STORAGE, storedName), file.buffer, { flag: 'wx' });
}
try {
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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
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"`,
[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],
);
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 } });
return rows[0];
} catch (error) {
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
throw error;
}
}
async responseContent(responseId: 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 row = rows[0];
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
const filePath = join(STORAGE, row.storedName);
const info = await stat(filePath).catch(() => null);
if (!info?.isFile()) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'El archivo de respuesta no está disponible.' });
return { originalName: row.originalName as string, sizeBytes: Number(row.sizeBytes), filePath };
}
async calendar(from?: string, to?: string) {
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
if ((from && !dateRe.test(from)) || (to && !dateRe.test(to))) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'Las fechas deben usar YYYY-MM-DD.' });
const start = from ?? new Date().toISOString().slice(0, 10);
const endDate = new Date(`${to ?? start}T00:00:00Z`);
if (!to) endDate.setUTCDate(endDate.getUTCDate() + 60);
const end = endDate.toISOString().slice(0, 10);
if (start > end) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'El rango de fechas es inválido.' });
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE ("responseDueOn" BETWEEN $1 AND $2) OR ("committedCorrectionOn" BETWEEN $1 AND $2) ORDER BY COALESCE("responseDueOn", "committedCorrectionOn")`, [start, end]);
const events: Array<Record<string, unknown>> = [];
for (const row of rows) {
if (row.responseDueOn && row.responseDueOn >= start && row.responseDueOn <= end) events.push({ type: 'RESPONSE_DUE', date: row.responseDueOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
if (row.committedCorrectionOn && row.committedCorrectionOn >= start && row.committedCorrectionOn <= end) events.push({ type: 'COMMITMENT_DUE', date: row.committedCorrectionOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
}
return { from: start, to: end, data: events.sort((a, b) => String(a.date).localeCompare(String(b.date))) };
}
}
@@ -0,0 +1,31 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateActCompanyResponseDto {
@IsDateString()
receivedOn!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(3)
@MaxLength(8000)
details?: string;
@IsOptional()
@IsDateString()
committedCorrectionOn?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(200)
contactName?: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsEmail()
@MaxLength(320)
contactEmail?: string;
}
@@ -0,0 +1,17 @@
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
export const ACT_ADMIN_STATES = [
'ALL', 'NEW', 'WAITING_RESPONSE', 'DUE_SOON', 'OVERDUE',
'RESPONSE_RECEIVED', 'VERIFICATION_PENDING', 'COMMITMENT_OVERDUE', 'REGULARIZED',
] as const;
export type ActAdministrationState = typeof ACT_ADMIN_STATES[number];
export class ListActAdministrationQueryDto {
@IsOptional() @IsIn(ACT_ADMIN_STATES) state?: ActAdministrationState;
@IsOptional() @IsUUID('4') areaId?: string;
@IsOptional() @IsUUID('4') companyId?: string;
@IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) page?: number;
@IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) @Max(100) pageSize?: number;
}
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsString, MaxLength, MinLength } from 'class-validator';
export class SetActResponseDeadlineDto {
@IsDateString()
responseDueOn!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(3)
@MaxLength(1000)
reason!: string;
}
@@ -0,0 +1,14 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { FieldBriefingService } from './field-briefing.service';
@Controller('inspection-visits/:visitId/field-briefing')
export class FieldBriefingController {
constructor(private readonly briefing: FieldBriefingService) {}
@Get()
@RequirePermissions('inspections.read')
get(@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string) {
return this.briefing.forVisit(visitId);
}
}
@@ -0,0 +1,242 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
export type FieldBriefingActState =
| 'ACT_RESPONSE_OVERDUE'
| 'ACT_RESPONSE_DUE_SOON'
| 'WAITING_RESPONSE'
| 'COMPANY_COMMITMENT_OVERDUE'
| 'VERIFICATION_PENDING'
| 'RESPONSE_RECEIVED';
export interface FieldBriefingFinding {
id: string;
code: string;
title: string;
status: string;
severity: number | null;
nextControlOn: string | null;
asset: {
id: string;
code: string;
name: string;
typeName: string;
};
}
export interface FieldBriefingAct {
actId: string;
actCode: string;
occurredAt: Date;
adminState: FieldBriefingActState;
responseDueOn: string | null;
responseReceivedOn: string | null;
committedCorrectionOn: string | null;
latestResponseId: string | null;
findings: FieldBriefingFinding[];
}
@Injectable()
export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {}
async forVisit(visitId: string) {
const [visit] = (await this.dataSource.query(`
SELECT
visit.id,
visit.code,
visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.operational_area_id AS "areaId",
visit.operator_company_id AS "companyId",
area.code AS "areaCode",
area.name AS "areaName",
company.code AS "companyCode",
COALESCE(profile.legal_name, company.name) AS "companyName"
FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
WHERE visit.id = $1
`, [visitId])) as Array<Record<string, unknown>>;
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Inspección no encontrada',
});
}
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
SELECT
act.id AS act_id,
act.code AS act_code,
act.occurred_at,
deadline.response_due_on,
response.id AS latest_response_id,
response.received_on AS response_received_on,
response.committed_correction_on,
finding.id AS finding_id,
finding.code AS finding_code,
finding.title AS finding_title,
finding.status AS finding_status,
finding.severity,
finding.next_control_on,
asset.id AS asset_id,
asset.code AS asset_code,
asset.name AS asset_name,
asset_type.name AS asset_type_name,
CASE
WHEN response.id IS NULL
AND deadline.response_due_on IS NOT NULL
AND deadline.response_due_on < $4::date
THEN 'ACT_RESPONSE_OVERDUE'
WHEN response.id IS NULL
AND deadline.response_due_on IS NOT NULL
AND deadline.response_due_on BETWEEN $4::date AND ($4::date + 3)
THEN 'ACT_RESPONSE_DUE_SOON'
WHEN response.id IS NOT NULL
AND response.committed_correction_on IS NOT NULL
AND response.committed_correction_on < $4::date
THEN 'COMPANY_COMMITMENT_OVERDUE'
WHEN response.id IS NOT NULL
AND finding.next_control_on IS NULL
THEN 'VERIFICATION_PENDING'
WHEN response.id IS NOT NULL
THEN 'RESPONSE_RECEIVED'
ELSE 'WAITING_RESPONSE'
END AS admin_state
FROM inspection_acts act
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN inspection_findings finding ON finding.act_id = act.id
AND finding.status = 'OPEN'
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN LATERAL (
SELECT event.response_due_on
FROM inspection_act_deadline_events event
WHERE event.act_id = act.id
ORDER BY event.created_at DESC, event.id DESC
LIMIT 1
) deadline ON true
LEFT JOIN LATERAL (
SELECT company_response.id, company_response.received_on, company_response.committed_correction_on
FROM inspection_act_company_responses company_response
WHERE company_response.act_id = act.id
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
LIMIT 1
) response ON true
WHERE act.status IN ('CLOSED', 'RECTIFIED')
AND source_visit.id <> $1
AND source_visit.operational_area_id = $2::uuid
AND source_visit.operator_company_id = $3::uuid
)
SELECT *
FROM prior_acts
WHERE admin_state IN (
'ACT_RESPONSE_OVERDUE',
'ACT_RESPONSE_DUE_SOON',
'COMPANY_COMMITMENT_OVERDUE',
'VERIFICATION_PENDING',
'RESPONSE_RECEIVED'
)
OR next_control_on IS NOT NULL
ORDER BY
CASE admin_state
WHEN 'ACT_RESPONSE_OVERDUE' THEN 1
WHEN 'COMPANY_COMMITMENT_OVERDUE' THEN 2
WHEN 'VERIFICATION_PENDING' THEN 3
WHEN 'ACT_RESPONSE_DUE_SOON' THEN 4
ELSE 5
END,
response_due_on NULLS LAST,
occurred_at,
act_code,
finding_code
`, [visitId, visit.areaId, visit.companyId, plannedOn])) as Array<{
act_id: string;
act_code: string;
occurred_at: Date;
response_due_on: string | null;
latest_response_id: string | null;
response_received_on: string | null;
committed_correction_on: string | null;
finding_id: string;
finding_code: string;
finding_title: string;
finding_status: string;
severity: number | null;
next_control_on: string | null;
asset_id: string;
asset_code: string;
asset_name: string;
asset_type_name: string;
admin_state: FieldBriefingActState;
}>;
const byAct = new Map<string, FieldBriefingAct>();
for (const row of rows) {
let act = byAct.get(row.act_id);
if (!act) {
act = {
actId: row.act_id,
actCode: row.act_code,
occurredAt: row.occurred_at,
adminState: row.admin_state,
responseDueOn: row.response_due_on,
responseReceivedOn: row.response_received_on,
committedCorrectionOn: row.committed_correction_on,
latestResponseId: row.latest_response_id,
findings: [],
};
byAct.set(row.act_id, act);
}
act.findings.push({
id: row.finding_id,
code: row.finding_code,
title: row.finding_title,
status: row.finding_status,
severity: row.severity,
nextControlOn: row.next_control_on,
asset: {
id: row.asset_id,
code: row.asset_code,
name: row.asset_name,
typeName: row.asset_type_name,
},
});
}
const acts = [...byAct.values()];
const assetIds = new Set<string>();
let findingCount = 0;
for (const act of acts) {
findingCount += act.findings.length;
for (const finding of act.findings) assetIds.add(finding.asset.id);
}
return {
inspection: {
id: visit.id,
code: visit.code,
status: visit.status,
plannedStartAt: visit.plannedStartAt,
area: visit.areaId ? { id: visit.areaId, code: visit.areaCode, name: visit.areaName } : null,
operatorCompany: visit.companyId ? { id: visit.companyId, code: visit.companyCode, name: visit.companyName } : null,
},
plannedOn,
summary: {
acts: acts.length,
findings: findingCount,
inventoryItems: assetIds.size,
responseOverdue: acts.filter((item) => item.adminState === 'ACT_RESPONSE_OVERDUE').length,
commitmentOverdue: acts.filter((item) => item.adminState === 'COMPANY_COMMITMENT_OVERDUE').length,
verificationPending: acts.filter((item) => item.adminState === 'VERIFICATION_PENDING').length,
},
acts,
};
}
}
+2
View File
@@ -24,6 +24,7 @@ import { InspectionClosingModule } from './inspection-closing/inspection-closing
import { AssetImportsModule } from './asset-imports/asset-imports.module'; import { AssetImportsModule } from './asset-imports/asset-imports.module';
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module'; import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module'; import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
import { ActAdministrationModule } from './act-administration/act-administration.module';
function required(config: ConfigService, key: string): string { function required(config: ConfigService, key: string): string {
const value = config.get<string>(key); const value = config.get<string>(key);
@@ -76,6 +77,7 @@ function required(config: ConfigService, key: string): string {
InspectionClosingModule, InspectionClosingModule,
InspectionReportsModule, InspectionReportsModule,
InspectionVerificationsModule, InspectionVerificationsModule,
ActAdministrationModule,
AssetImportsModule, AssetImportsModule,
], ],
controllers: [HealthController], controllers: [HealthController],
@@ -49,6 +49,12 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
AssetRegistryService, AssetRegistryService,
FieldDiscoveryInspectionLinkService, FieldDiscoveryInspectionLinkService,
], ],
exports: [AssetHistoryService], exports: [
AssetHistoryService,
AssetsService,
AssetGeometriesService,
AssetMediaService,
FieldDiscoveryInspectionLinkService,
],
}) })
export class AssetMasterModule {} export class AssetMasterModule {}
+4 -1
View File
@@ -5,6 +5,8 @@ import { AuthorizationModule } from '../authorization/authorization.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module'; import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { MobileAuthController } from './mobile-auth.controller';
import { MobileAuthService } from './mobile-auth.service';
import { AccessTokenGuard } from './guards/access-token.guard'; import { AccessTokenGuard } from './guards/access-token.guard';
import { CsrfGuard } from './guards/csrf.guard'; import { CsrfGuard } from './guards/csrf.guard';
import { AuthConfigService } from '../common/config/auth-config.service'; import { AuthConfigService } from '../common/config/auth-config.service';
@@ -18,6 +20,7 @@ const providers = [
PasswordService, PasswordService,
TokenService, TokenService,
AuthService, AuthService,
MobileAuthService,
AccessTokenGuard, AccessTokenGuard,
CsrfGuard, CsrfGuard,
]; ];
@@ -29,7 +32,7 @@ const providers = [
AuditModule, AuditModule,
AuthorizationModule, AuthorizationModule,
], ],
controllers: [AuthController], controllers: [AuthController, MobileAuthController],
providers, providers,
exports: [ exports: [
AuthConfigService, AuthConfigService,
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class MobileRefreshDto {
@IsString()
@MinLength(32)
@MaxLength(256)
refreshToken!: string;
}
+56
View File
@@ -0,0 +1,56 @@
import {
Body,
Controller,
HttpCode,
Post,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { CurrentAuth } from './decorators/current-auth.decorator';
import { Public } from './decorators/public.decorator';
import { SkipCsrf } from './decorators/skip-csrf.decorator';
import { LoginDto } from './dto/login.dto';
import { MobileRefreshDto } from './dto/mobile-refresh.dto';
import { MobileAuthService } from './mobile-auth.service';
@Controller('auth/mobile')
export class MobileAuthController {
constructor(private readonly mobileAuth: MobileAuthService) {}
@Post('login')
@HttpCode(200)
@Public()
@SkipCsrf()
@Throttle({ default: { limit: 5, ttl: 60_000, blockDuration: 60_000 } })
login(
@Body() dto: LoginDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.login(dto, request);
}
@Post('refresh')
@HttpCode(200)
@Public()
@SkipCsrf()
@Throttle({ default: { limit: 20, ttl: 60_000 } })
refresh(
@Body() dto: MobileRefreshDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.refresh(dto.refreshToken, request);
}
@Post('logout')
@HttpCode(200)
logout(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.logout(principal, request);
}
}
+386
View File
@@ -0,0 +1,386 @@
import { isIP } from 'node:net';
import {
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AuditService } from '../audit/audit.service';
import type {
AuthPrincipal,
RequestWithContext,
} from '../common/http/request-context';
import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository';
import { RolesRepository } from '../core-data/repositories/roles.repository';
import { UsersRepository } from '../core-data/repositories/users.repository';
import {
AuditAction,
AuditSource,
AuthSession,
User,
UserStatus,
} from '../database/entities';
import type { LoginDto } from './dto/login.dto';
import { PasswordService } from './services/password.service';
import {
IssuedRefreshToken,
TokenService,
} from './services/token.service';
interface RequestMetadata {
ip: string | null;
userAgent: string | null;
}
interface SuccessfulAuthentication {
user: User;
accessToken: string;
refreshToken: IssuedRefreshToken;
}
type LoginOutcome = SuccessfulAuthentication | null;
type RefreshOutcome =
| ({ kind: 'ok' } & SuccessfulAuthentication)
| { kind: 'reuse' }
| { kind: 'invalid' };
function invalidCredentials(): UnauthorizedException {
return new UnauthorizedException({
code: 'INVALID_CREDENTIALS',
message: 'Credenciales inválidas',
});
}
function invalidSession(): UnauthorizedException {
return new UnauthorizedException({
code: 'INVALID_SESSION',
message: 'Sesión móvil inválida o vencida',
});
}
function inspectorRequired(): ForbiddenException {
return new ForbiddenException({
code: 'INSPECTION_INSPECTOR_ROLE_REQUIRED',
message: 'Sólo un usuario con rol inspector puede ingresar a la aplicación móvil',
});
}
function requestMetadata(request: RequestWithContext): RequestMetadata {
const candidateIp = request.ip || request.socket.remoteAddress || '';
const ip = isIP(candidateIp) ? candidateIp : null;
const rawUserAgent = request.header('user-agent')?.trim();
return {
ip,
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
};
}
@Injectable()
export class MobileAuthService {
constructor(
private readonly dataSource: DataSource,
private readonly users: UsersRepository,
private readonly sessions: AuthSessionsRepository,
private readonly roles: RolesRepository,
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly audit: AuditService,
) {}
async login(dto: LoginDto, request: RequestWithContext) {
const identifier = dto.identifier.trim().toLowerCase();
const candidate = await this.users.findForAuthentication(identifier);
const metadata = requestMetadata(request);
if (!candidate) {
await this.passwords.verifyUnknown(dto.password);
await this.audit.record({
action: AuditAction.AUTH_LOGIN_FAILED,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: { identifier, reason: 'UNKNOWN_IDENTIFIER' },
});
throw invalidCredentials();
}
const outcome = await this.dataSource.transaction<LoginOutcome>(
async (manager) => {
const user = await manager
.getRepository(User)
.createQueryBuilder('user')
.addSelect('user.passwordHash')
.where('user.id = :id', { id: candidate.id })
.setLock('pessimistic_write')
.getOne();
if (!user) return null;
const now = new Date();
const passwordMatches = await this.passwords.verify(
user.passwordHash,
dto.password,
);
const locked = Boolean(user.lockedUntil && user.lockedUntil > now);
const active = user.status === UserStatus.ACTIVE;
if (!passwordMatches || locked || !active) {
if (!passwordMatches && active && !locked) {
await manager.query(
`
UPDATE users
SET
failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= $2
THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second')
ELSE locked_until
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`,
[
user.id,
this.tokens.maxLoginAttempts,
this.tokens.lockoutSeconds,
],
);
}
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_LOGIN_FAILED,
entityType: 'user',
entityId: user.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: {
reason: !active
? 'INACTIVE_USER'
: locked
? 'LOCKED_USER'
: 'INVALID_PASSWORD',
},
},
manager,
);
return null;
}
if (this.passwords.needsRehash(user.passwordHash)) {
user.passwordHash = await this.passwords.hash(dto.password);
}
user.failedLoginAttempts = 0;
user.lockedUntil = null;
user.lastLoginAt = now;
await manager.getRepository(User).save(user);
const refreshToken = this.tokens.issueRefreshToken();
const session = manager.getRepository(AuthSession).create({
id: refreshToken.sessionId,
userId: user.id,
refreshTokenHash: refreshToken.tokenHash,
expiresAt: this.tokens.refreshExpiresAt(now),
lastUsedAt: now,
revokedAt: null,
replacedBySessionId: null,
ip: metadata.ip,
userAgent: metadata.userAgent,
deviceLabel: dto.deviceLabel?.trim() || 'DH Android',
});
await manager.getRepository(AuthSession).save(session);
const accessToken = await this.tokens.issueAccessToken({
userId: user.id,
sessionId: session.id,
username: user.username,
});
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_LOGIN_SUCCESS,
entityType: 'auth_session',
entityId: session.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: { deviceLabel: session.deviceLabel },
},
manager,
);
return { user, accessToken, refreshToken };
},
);
if (!outcome) throw invalidCredentials();
return this.complete(outcome);
}
async refresh(rawToken: string, request: RequestWithContext) {
const parsed = this.tokens.parseRefreshToken(rawToken);
if (!parsed) throw invalidSession();
const metadata = requestMetadata(request);
const outcome = await this.dataSource.transaction<RefreshOutcome>(
async (manager) => {
const session = await manager
.getRepository(AuthSession)
.createQueryBuilder('session')
.addSelect('session.refreshTokenHash')
.where('session.id = :id', { id: parsed.sessionId })
.setLock('pessimistic_write')
.getOne();
if (
!session ||
!this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash)
) {
return { kind: 'invalid' };
}
const user = await manager.getRepository(User).findOne({
where: { id: session.userId },
});
if (session.revokedAt) {
await this.sessions.revokeSessionFamily(session.id, manager);
await this.audit.record(
{
actorUserId: user?.id ?? null,
actorUsername: user?.username ?? null,
action: AuditAction.AUTH_REFRESH_REUSE_DETECTED,
entityType: 'auth_session',
entityId: session.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
},
manager,
);
return { kind: 'reuse' };
}
const now = new Date();
if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) {
session.revokedAt = now;
session.lastUsedAt = now;
await manager.getRepository(AuthSession).save(session);
if (user?.status === UserStatus.INACTIVE) {
await this.sessions.revokeUserSessions(user.id, undefined, manager);
}
return { kind: 'invalid' };
}
const refreshToken = this.tokens.issueRefreshToken();
const replacement = manager.getRepository(AuthSession).create({
id: refreshToken.sessionId,
userId: user.id,
refreshTokenHash: refreshToken.tokenHash,
expiresAt: this.tokens.refreshExpiresAt(now),
lastUsedAt: now,
revokedAt: null,
replacedBySessionId: null,
ip: metadata.ip,
userAgent: metadata.userAgent,
deviceLabel: session.deviceLabel,
});
await manager.getRepository(AuthSession).save(replacement);
session.revokedAt = now;
session.lastUsedAt = now;
session.replacedBySessionId = replacement.id;
await manager.getRepository(AuthSession).save(session);
const accessToken = await this.tokens.issueAccessToken({
userId: user.id,
sessionId: replacement.id,
username: user.username,
});
await this.audit.record(
{
actorUserId: user.id,
actorUsername: user.username,
action: AuditAction.AUTH_REFRESH,
entityType: 'auth_session',
entityId: replacement.id,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
metadata: { replacedSessionId: session.id },
},
manager,
);
return { kind: 'ok', user, accessToken, refreshToken };
},
);
if (outcome.kind !== 'ok') throw invalidSession();
return this.complete(outcome);
}
async logout(
principal: AuthPrincipal,
request: RequestWithContext,
) {
const metadata = requestMetadata(request);
await this.dataSource.transaction(async (manager) => {
await this.sessions.revokeSession(
principal.sessionId,
principal.userId,
manager,
);
await this.audit.record(
{
actorUserId: principal.userId,
actorUsername: principal.username,
action: AuditAction.AUTH_LOGOUT,
entityType: 'auth_session',
entityId: principal.sessionId,
requestId: request.requestId,
source: AuditSource.ANDROID,
...metadata,
},
manager,
);
});
return { status: 'ok' };
}
private async complete(authentication: SuccessfulAuthentication) {
const [roleCodes, permissionCodes] = await Promise.all([
this.roles.findRoleCodesForUser(authentication.user.id),
this.roles.findPermissionCodesForUser(authentication.user.id),
]);
if (!roleCodes.includes('inspector')) {
await this.sessions.revokeSession(
authentication.refreshToken.sessionId,
authentication.user.id,
);
throw inspectorRequired();
}
return {
user: {
id: authentication.user.id,
username: authentication.user.username,
firstName: authentication.user.firstName,
lastName: authentication.user.lastName,
email: authentication.user.email,
mustChangePassword: authentication.user.mustChangePassword,
roles: roleCodes,
permissions: permissionCodes,
},
accessToken: authentication.accessToken,
refreshToken: authentication.refreshToken.token,
accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds,
};
}
}
+1
View File
@@ -93,6 +93,7 @@ export {
InspectionActSignatureSource, InspectionActSignatureSource,
InspectionActSignatureStatus, InspectionActSignatureStatus,
InspectionActSignerType, InspectionActSignerType,
InspectionCompanySignatureManifestation,
} from './inspection-act-signature.entity'; } from './inspection-act-signature.entity';
export { FindingCategory } from './finding-category.entity'; export { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity'; export { FindingCatalogItem } from './finding-catalog-item.entity';
@@ -17,6 +17,11 @@ export enum InspectionActSignatureSource {
ANDROID = 'ANDROID', ANDROID = 'ANDROID',
} }
export enum InspectionCompanySignatureManifestation {
CONFORMITY = 'CONFORMITY',
DISSENT = 'DISSENT',
}
@Entity({ name: 'inspection_act_signatures' }) @Entity({ name: 'inspection_act_signatures' })
@Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt']) @Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt'])
@Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256']) @Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256'])
@@ -51,6 +56,12 @@ export class InspectionActSignature {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
reason!: string | null; reason!: string | null;
@Column({ name: 'company_manifestation', type: 'varchar', length: 20, nullable: true })
companyManifestation!: InspectionCompanySignatureManifestation | null;
@Column({ name: 'company_statement', type: 'text', nullable: true })
companyStatement!: string | null;
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true }) @Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
originalName!: string | null; originalName!: string | null;
@@ -10,7 +10,7 @@ export enum InspectionActStatus {
} }
@Entity({ name: 'inspection_acts' }) @Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_visit', ['visitId'], { unique: true }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@Index('uq_inspection_acts_code', ['code'], { unique: true }) @Index('uq_inspection_acts_code', ['code'], { unique: true })
@Index('idx_inspection_acts_visit_status', ['visitId', 'status']) @Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
@@ -25,7 +25,7 @@ export enum InspectionReportReviewStatus {
} }
@Entity({ name: 'inspection_reports' }) @Entity({ name: 'inspection_reports' })
@Index('uq_inspection_reports_visit', ['visitId'], { unique: true }) @Index('idx_inspection_reports_visit_id', ['visitId'])
@Index('uq_inspection_reports_act', ['actId'], { unique: true }) @Index('uq_inspection_reports_act', ['actId'], { unique: true })
@Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true }) @Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true })
@Index('uq_inspection_reports_code', ['code'], { unique: true }) @Index('uq_inspection_reports_code', ['code'], { unique: true })
@@ -0,0 +1,173 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* One-time production reset requested before the first clean Android rollout.
*
* Keeps only structural product configuration plus the single `admin` account.
* Operational/business data is removed. Recovery is intentionally performed
* from the deploy PRE backup, not through a synthetic down migration.
*/
export class ResetProductionOperationalData1788652800000 implements MigrationInterface {
name = 'ResetProductionOperationalData1788652800000';
public async up(queryRunner: QueryRunner): Promise<void> {
const adminRows: Array<{ id: string; username: string }> = await queryRunner.query(`
SELECT id, username
FROM users
WHERE lower(trim(username)) = 'admin'
ORDER BY id
`);
if (adminRows.length !== 1) {
throw new Error(
`Production reset aborted: expected exactly one username admin, found ${adminRows.length}`,
);
}
const adminId = adminRows[0].id;
const structuralTables = [
'roles',
'permissions',
'role_permissions',
'asset_types',
'asset_attribute_definitions',
'asset_type_parent_rules',
'finding_categories',
'finding_catalog_items',
'finding_catalog_item_asset_types',
'finding_catalog_asset_type_profiles',
];
// Snapshot structural row counts so TRUNCATE ... CASCADE can never silently
// remove product configuration while still leaving operational tables empty.
const structuralCounts = new Map<string, string>();
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows: Array<{ total: string }> = await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
);
structuralCounts.set(table, rows[0]?.total ?? '0');
}
const adminRolesBefore: Array<{ total: string }> = await queryRunner.query(
`SELECT count(*)::text AS total FROM user_roles WHERE user_id = $1`,
[adminId],
);
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
if (adminRoleCount === '0') {
throw new Error('Production reset aborted: admin has no assigned role');
}
// Product configuration that must survive a clean operational start.
const preservedTables = new Set([
'typeorm_migrations',
'users',
'user_roles',
...structuralTables,
]);
const tableRows: Array<{ table_name: string }> = await queryRunner.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
const operationalTables = tableRows
.map((row) => row.table_name)
.filter((table) => !preservedTables.has(table));
if (operationalTables.length > 0) {
const quoted = operationalTables
.map((table) => `"${table.replace(/"/g, '""')}"`)
.join(', ');
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
}
// Remove every user except the explicitly validated administrator.
// user_roles for removed users follow their FK cascade.
await queryRunner.query(`DELETE FROM users WHERE id <> $1`, [adminId]);
// A reset must invalidate every prior login token, including admin's.
// auth_sessions is operational and was truncated above; admin simply logs in again.
await queryRunner.query(
`
UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL,
last_login_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`,
[adminId],
);
const finalUsers: Array<{ total: string; admins: string }> = await queryRunner.query(`
SELECT
count(*)::text AS total,
count(*) FILTER (WHERE lower(trim(username)) = 'admin')::text AS admins
FROM users
`);
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
throw new Error('Production reset verification failed: users table is not admin-only');
}
const finalAdminRoles: Array<{ total: string; foreign_users: string }> = await queryRunner.query(
`
SELECT
count(*) FILTER (WHERE user_id = $1)::text AS total,
count(*) FILTER (WHERE user_id <> $1)::text AS foreign_users
FROM user_roles
`,
[adminId],
);
if (
finalAdminRoles[0]?.total !== adminRoleCount ||
finalAdminRoles[0]?.foreign_users !== '0'
) {
throw new Error('Production reset verification failed: admin role assignments changed');
}
// Assert every structural table kept exactly the same number of rows.
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows: Array<{ total: string }> = await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
);
const before = structuralCounts.get(table) ?? '0';
if (rows[0]?.total !== before) {
throw new Error(
`Production reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
);
}
}
// Assert that every operational table is empty. This makes the migration
// fail atomically if a table was repopulated during the reset transaction.
for (const table of operationalTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows: Array<{ total: string }> = await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
);
if (rows[0]?.total !== '0') {
throw new Error(`Production reset verification failed: ${table} is not empty`);
}
}
// Keep a concise server-side record in the migration log for deploy diagnostics.
// eslint-disable-next-line no-console
console.log(
`[production-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} structural tables; cleared ${operationalTables.length} operational tables`,
);
}
public async down(): Promise<void> {
throw new Error(
'ResetProductionOperationalData is irreversible by migration; restore the deploy PRE database backup instead.',
);
}
}
@@ -0,0 +1,108 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF11MultiActInspections1789495200000 implements MigrationInterface {
name = 'PhaseF11MultiActInspections1789495200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit
ON inspection_acts (visit_id)
WHERE status = 'DRAFT'
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS uq_inspection_reports_visit
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_reports_visit_id
ON inspection_reports (visit_id)
`);
await queryRunner.query(`
ALTER TABLE inspection_act_signatures
ADD COLUMN company_manifestation varchar(20),
ADD COLUMN company_statement text
`);
await queryRunner.query(`
UPDATE inspection_act_signatures
SET company_manifestation = 'CONFORMITY'
WHERE signer_type = 'COMPANY_RESPONSIBLE'
AND status = 'SIGNED'
`);
await queryRunner.query(`
ALTER TABLE inspection_act_signatures
ADD CONSTRAINT chk_inspection_act_signatures_company_manifestation CHECK (
(
signer_type = 'COMPANY_RESPONSIBLE'
AND status = 'SIGNED'
AND company_manifestation IN ('CONFORMITY', 'DISSENT')
AND (
(company_manifestation = 'CONFORMITY' AND company_statement IS NULL)
OR
(company_manifestation = 'DISSENT' AND LENGTH(TRIM(COALESCE(company_statement, ''))) >= 10)
)
)
OR
(
NOT (signer_type = 'COMPANY_RESPONSIBLE' AND status = 'SIGNED')
AND company_manifestation IS NULL
AND company_statement IS NULL
)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT visit_id FROM inspection_acts
GROUP BY visit_id HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'F1.1 no puede revertirse: existen inspecciones con múltiples actas';
END IF;
IF EXISTS (
SELECT visit_id FROM inspection_reports
GROUP BY visit_id HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'F1.1 no puede revertirse: existen inspecciones con múltiples informes';
END IF;
IF EXISTS (
SELECT 1 FROM inspection_act_signatures
WHERE company_manifestation = 'DISSENT'
) THEN
RAISE EXCEPTION 'F1.1 no puede revertirse: existen firmas en disidencia';
END IF;
END
$$
`);
await queryRunner.query(`
ALTER TABLE inspection_act_signatures
DROP CONSTRAINT chk_inspection_act_signatures_company_manifestation
`);
await queryRunner.query(`
ALTER TABLE inspection_act_signatures
DROP COLUMN company_statement,
DROP COLUMN company_manifestation
`);
await queryRunner.query(`DROP INDEX idx_inspection_reports_visit_id`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT uq_inspection_reports_visit UNIQUE (visit_id)
`);
await queryRunner.query(`DROP INDEX uq_inspection_acts_one_draft_per_visit`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT uq_inspection_acts_visit UNIQUE (visit_id)
`);
}
}
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF12ActAdministration1789581600000 implements MigrationInterface {
name = 'PhaseF12ActAdministration1789581600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE inspection_act_deadline_events (
id uuid PRIMARY KEY, act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE RESTRICT,
response_due_on date NOT NULL, reason text NOT NULL, created_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_act_created ON inspection_act_deadline_events (act_id, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_due ON inspection_act_deadline_events (response_due_on)`);
await queryRunner.query(`CREATE TABLE inspection_act_company_responses (
id uuid PRIMARY KEY, act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE RESTRICT,
received_on date NOT NULL, details text NULL, committed_correction_on date NULL,
contact_name varchar(200) NULL, contact_email varchar(320) NULL,
original_name varchar(255) NULL, stored_name varchar(100) NULL, mime_type varchar(120) NULL,
size_bytes bigint NULL, sha256 char(64) NULL, created_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_act_company_response_file CHECK ((stored_name IS NULL AND original_name IS NULL AND mime_type IS NULL AND size_bytes IS NULL AND sha256 IS NULL) OR (stored_name IS NOT NULL AND original_name IS NOT NULL AND mime_type = 'application/pdf' AND size_bytes > 0 AND sha256 IS NOT NULL))
)`);
await queryRunner.query(`CREATE INDEX idx_act_company_responses_act_received ON inspection_act_company_responses (act_id, received_on DESC, created_at DESC)`);
await queryRunner.query(`CREATE INDEX idx_act_company_responses_commitment ON inspection_act_company_responses (committed_correction_on)`);
await queryRunner.query(`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by, created_at)
SELECT gen_random_uuid(), act_id, MIN(correction_due_on), 'Migrado desde plazo histórico coincidente de hallazgos', NULL, CURRENT_TIMESTAMP
FROM inspection_findings WHERE correction_due_on IS NOT NULL GROUP BY act_id HAVING COUNT(DISTINCT correction_due_on) = 1`);
await queryRunner.query(`CREATE OR REPLACE FUNCTION prevent_f12_append_only_mutation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'F1.2 append-only: update/delete no permitido'; END; $$ LANGUAGE plpgsql`);
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_events_append_only BEFORE UPDATE OR DELETE ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION prevent_f12_append_only_mutation()`);
await queryRunner.query(`CREATE TRIGGER trg_act_company_responses_append_only BEFORE UPDATE OR DELETE ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION prevent_f12_append_only_mutation()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_company_responses_append_only ON inspection_act_company_responses`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_events_append_only ON inspection_act_deadline_events`);
await queryRunner.query(`DROP FUNCTION IF EXISTS prevent_f12_append_only_mutation()`);
await queryRunner.query(`DROP TABLE inspection_act_company_responses`);
await queryRunner.query(`DROP TABLE inspection_act_deadline_events`);
}
}
@@ -0,0 +1,197 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class PhaseF21FieldInventory1789668000000 implements MigrationInterface {
name = 'PhaseF21FieldInventory1789668000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE field_inventory_code_sequences (
year integer PRIMARY KEY,
last_value integer NOT NULL DEFAULT 0,
CONSTRAINT chk_field_inventory_code_sequences_year CHECK (year BETWEEN 2000 AND 9999),
CONSTRAINT chk_field_inventory_code_sequences_value CHECK (last_value >= 0)
)
`);
await queryRunner.query(`
CREATE TABLE asset_field_capture_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
visit_id uuid NOT NULL,
asset_id uuid NOT NULL,
media_id uuid,
event_type varchar(24) NOT NULL,
device_latitude numeric(9, 6) NOT NULL,
device_longitude numeric(9, 6) NOT NULL,
device_accuracy_m numeric(12, 3),
device_captured_at timestamptz NOT NULL,
device_label varchar(255),
exif_latitude numeric(9, 6),
exif_longitude numeric(9, 6),
exif_captured_at timestamptz,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_asset_field_capture_visit FOREIGN KEY (visit_id)
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_field_capture_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_field_capture_media FOREIGN KEY (media_id)
REFERENCES asset_media(id) ON DELETE RESTRICT,
CONSTRAINT fk_asset_field_capture_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT chk_asset_field_capture_event_type CHECK (event_type IN ('CREATED','PHOTO')),
CONSTRAINT chk_asset_field_capture_device_coordinates CHECK (
device_latitude BETWEEN -90 AND 90
AND device_longitude BETWEEN -180 AND 180
),
CONSTRAINT chk_asset_field_capture_accuracy CHECK (
device_accuracy_m IS NULL OR device_accuracy_m BETWEEN 0 AND 100000
),
CONSTRAINT chk_asset_field_capture_exif_coordinates CHECK (
(exif_latitude IS NULL AND exif_longitude IS NULL)
OR (
exif_latitude IS NOT NULL
AND exif_longitude IS NOT NULL
AND exif_latitude BETWEEN -90 AND 90
AND exif_longitude BETWEEN -180 AND 180
)
),
CONSTRAINT chk_asset_field_capture_media_event CHECK (
(event_type = 'CREATED' AND media_id IS NULL)
OR (event_type = 'PHOTO' AND media_id IS NOT NULL)
)
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_field_capture_visit ON asset_field_capture_events (visit_id, created_at, id)`);
await queryRunner.query(`CREATE INDEX idx_asset_field_capture_asset ON asset_field_capture_events (asset_id, created_at, id)`);
await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_field_capture_media ON asset_field_capture_events (media_id) WHERE media_id IS NOT NULL`);
await queryRunner.query(`
ALTER TABLE inspection_visit_assets
DROP CONSTRAINT chk_inspection_visit_assets_planning_source
`);
await queryRunner.query(`
ALTER TABLE inspection_visit_assets
ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK (
planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION','FIELD')
)
`);
await queryRunner.query(`
ALTER TABLE inspection_visit_asset_events
DROP CONSTRAINT chk_inspection_visit_asset_events_type
`);
await queryRunner.query(`
ALTER TABLE inspection_visit_asset_events
ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK (
event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','FIELD_INCLUDED','EXCLUDED','REINCLUDED')
)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_field_inventory_capture_before_finding()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
finding_visit_id uuid;
BEGIN
SELECT act.visit_id INTO finding_visit_id
FROM inspection_acts act
WHERE act.id = NEW.act_id;
IF finding_visit_id IS NULL THEN
RETURN NEW;
END IF;
IF EXISTS (
SELECT 1
FROM asset_field_discoveries discovery
WHERE discovery.visit_id = finding_visit_id
AND discovery.asset_id = NEW.asset_id
) THEN
IF NOT EXISTS (
SELECT 1
FROM asset_field_capture_events capture
WHERE capture.visit_id = finding_visit_id
AND capture.asset_id = NEW.asset_id
AND capture.event_type = 'CREATED'
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'FIELD_INVENTORY_GPS_REQUIRED';
END IF;
IF NOT EXISTS (
SELECT 1
FROM asset_field_capture_events capture
WHERE capture.visit_id = finding_visit_id
AND capture.asset_id = NEW.asset_id
AND capture.event_type = 'PHOTO'
) THEN
RAISE EXCEPTION USING
ERRCODE = '23514',
MESSAGE = 'FIELD_INVENTORY_PHOTO_REQUIRED';
END IF;
END IF;
RETURN NEW;
END
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_findings_field_capture
BEFORE INSERT OR UPDATE OF act_id, asset_id ON inspection_findings
FOR EACH ROW
EXECUTE FUNCTION enforce_field_inventory_capture_before_finding()
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE field_inventory_code_sequences TO ${applicationRole}`);
await queryRunner.query(`GRANT SELECT, INSERT ON TABLE asset_field_capture_events TO ${applicationRole}`);
await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE asset_field_capture_events FROM ${applicationRole}`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM asset_field_capture_events LIMIT 1) THEN
RAISE EXCEPTION 'F2.1 no puede revertirse: existen capturas GPS/EXIF de campo';
END IF;
IF EXISTS (SELECT 1 FROM inspection_visit_assets WHERE planning_source = 'FIELD' LIMIT 1) THEN
RAISE EXCEPTION 'F2.1 no puede revertirse: existen registros seleccionados o creados en campo';
END IF;
IF EXISTS (SELECT 1 FROM inspection_visit_asset_events WHERE event_type = 'FIELD_INCLUDED' LIMIT 1) THEN
RAISE EXCEPTION 'F2.1 no puede revertirse: existen eventos de Inventario de campo';
END IF;
END
$$
`);
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_inspection_findings_field_capture ON inspection_findings`);
await queryRunner.query(`DROP FUNCTION IF EXISTS enforce_field_inventory_capture_before_finding()`);
await queryRunner.query(`ALTER TABLE inspection_visit_asset_events DROP CONSTRAINT chk_inspection_visit_asset_events_type`);
await queryRunner.query(`
ALTER TABLE inspection_visit_asset_events
ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK (
event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED')
)
`);
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`);
await queryRunner.query(`
ALTER TABLE inspection_visit_assets
ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK (
planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION')
)
`);
await queryRunner.query(`DROP TABLE asset_field_capture_events`);
await queryRunner.query(`DROP TABLE field_inventory_code_sequences`);
}
}
@@ -308,7 +308,7 @@ 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);
await this.assertVisitHasNoAct(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);
const actYear = await this.yearAtProjectTimezone(manager, occurredAt); const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
@@ -632,20 +632,20 @@ export class InspectionActsService {
} }
} }
private async assertVisitHasNoAct( private async assertVisitHasNoDraftAct(
manager: EntityManager, manager: EntityManager,
visitId: string, visitId: string,
): Promise<void> { ): Promise<void> {
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT code SELECT code
FROM inspection_acts FROM inspection_acts
WHERE visit_id = $1 WHERE visit_id = $1 AND status = 'DRAFT'
LIMIT 1 LIMIT 1
`, [visitId])) as Array<{ code: string }>; `, [visitId])) as Array<{ code: string }>;
if (rows.length > 0) { if (rows.length > 0) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_VISIT_ACT_ALREADY_EXISTS', code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
message: 'La visita ya tiene un acta; cada visita admite una única acta', message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra',
}); });
} }
} }
@@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
import { InspectionCompanySignatureManifestation } from '../../database/entities';
import { CreateInspectionSignatureDto } from './create-inspection-signature.dto';
export class CreateCompanySignatureDto extends CreateInspectionSignatureDto {
@IsOptional()
@IsEnum(InspectionCompanySignatureManifestation)
manifestation?: InspectionCompanySignatureManifestation;
@ValidateIf((dto: CreateCompanySignatureDto) =>
dto.manifestation === InspectionCompanySignatureManifestation.DISSENT,
)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(10)
@MaxLength(4000)
statement?: string;
}
@@ -19,6 +19,7 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
import { CloseInspectionActDto } from './dto/close-inspection-act.dto'; import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
import { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
import { InspectionClosingService } from './inspection-closing.service'; import { InspectionClosingService } from './inspection-closing.service';
import { import {
@@ -89,7 +90,7 @@ export class InspectionClosingController {
})) }))
companySignature( companySignature(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CreateInspectionSignatureDto, @Body() dto: CreateCompanySignatureDto,
@UploadedFile() file: UploadedInspectionSignatureFile | undefined, @UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
@@ -21,6 +21,7 @@ import {
InspectionActSignatureSource, InspectionActSignatureSource,
InspectionActSignatureStatus, InspectionActSignatureStatus,
InspectionActSignerType, InspectionActSignerType,
InspectionCompanySignatureManifestation,
InspectionActStatus, InspectionActStatus,
InspectionActUploadMode, InspectionActUploadMode,
InspectionActVersionEvent, InspectionActVersionEvent,
@@ -33,6 +34,7 @@ import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
import { import {
inspectInspectionSignatureFile, inspectInspectionSignatureFile,
@@ -70,6 +72,8 @@ interface SignatureView {
position: string | null; position: string | null;
status: InspectionActSignatureStatus; status: InspectionActSignatureStatus;
reason: string | null; reason: string | null;
companyManifestation: InspectionCompanySignatureManifestation | null;
companyStatement: string | null;
mimeType: string | null; mimeType: string | null;
sizeBytes: number | null; sizeBytes: number | null;
imageSha256: string | null; imageSha256: string | null;
@@ -429,7 +433,7 @@ export class InspectionClosingService {
async signCompany( async signCompany(
actId: string, actId: string,
dto: CreateInspectionSignatureDto, dto: CreateCompanySignatureDto,
file: UploadedInspectionSignatureFile | undefined, file: UploadedInspectionSignatureFile | undefined,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
@@ -447,7 +451,7 @@ export class InspectionClosingService {
assertMobileInspector(principal); assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const { act, visit } = await this.lockContext(manager, actId); const { act, visit } = await this.lockContext(manager, actId);
this.assertReadyInProgress(act, visit); this.assertReadyForCompanyOutcome(act, visit);
await this.assertActorAssigned(manager, visit.id, principal, true); await this.assertActorAssigned(manager, visit.id, principal, true);
const responsible = await this.requireResponsible(manager, actId); const responsible = await this.requireResponsible(manager, actId);
if ( if (
@@ -540,7 +544,8 @@ export class InspectionClosingService {
assertMobileInspector(principal); assertMobileInspector(principal);
const closed = await this.dataSource.transaction(async (manager) => { const closed = await this.dataSource.transaction(async (manager) => {
const { act, visit } = await this.lockContext(manager, actId); const { act, visit } = await this.lockContext(manager, actId);
this.assertReadyInProgress(act, visit); this.assertReadyForFinalClosure(act, visit);
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); const signatures = await this.loadSignatures(manager, actId);
if (!signatures.some((item) => ( if (!signatures.some((item) => (
@@ -576,6 +581,8 @@ export class InspectionClosingService {
position: item.position, position: item.position,
status: item.status, status: item.status,
reason: item.reason, reason: item.reason,
companyManifestation: item.companyManifestation,
companyStatement: item.companyStatement,
imageSha256: item.imageSha256, imageSha256: item.imageSha256,
consentText: item.consentText, consentText: item.consentText,
consentVersion: item.consentVersion, consentVersion: item.consentVersion,
@@ -636,14 +643,6 @@ export class InspectionClosingService {
WHERE id = $1 WHERE id = $1
RETURNING current_version AS "versionNumber" RETURNING current_version AS "versionNumber"
`, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
await manager.query(`
UPDATE inspection_visits
SET status = 'CLOSED',
actual_closed_at = $2,
updated_by = $3,
updated_at = $2
WHERE id = $1
`, [visit.id, serverClosedAt, principal.userId]);
await manager.query(` await manager.query(`
INSERT INTO inspection_act_versions ( INSERT INTO inspection_act_versions (
act_id, version_number, event, snapshot, actor_user_id, actor_username act_id, version_number, event, snapshot, actor_user_id, actor_username
@@ -672,6 +671,7 @@ export class InspectionClosingService {
visitId: visit.id, visitId: visit.id,
versionNumber: Number(updated.versionNumber), versionNumber: Number(updated.versionNumber),
findingsRemainOpen: true, findingsRemainOpen: true,
visitRemainsIndependent: true,
}, },
}, manager); }, manager);
await this.reports.ensureFrozenReport(manager, actId, principal, request); await this.reports.ensureFrozenReport(manager, actId, principal, request);
@@ -710,7 +710,7 @@ export class InspectionClosingService {
private async createSignedSignature( private async createSignedSignature(
actId: string, actId: string,
dto: CreateInspectionSignatureDto, dto: CreateInspectionSignatureDto | CreateCompanySignatureDto,
file: UploadedInspectionSignatureFile | undefined, file: UploadedInspectionSignatureFile | undefined,
fixedIdentity: null | { fixedIdentity: null | {
signerType: InspectionActSignerType; signerType: InspectionActSignerType;
@@ -737,7 +737,8 @@ export class InspectionClosingService {
try { try {
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const { act, visit } = await this.lockContext(manager, actId); const { act, visit } = await this.lockContext(manager, actId);
this.assertReadyInProgress(act, visit); if (fixedIdentity) this.assertReadyInProgress(act, visit);
else this.assertReadyForCompanyOutcome(act, visit);
await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment);
const closure = await this.requireClosure(manager, actId); const closure = await this.requireClosure(manager, actId);
const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId);
@@ -778,6 +779,13 @@ export class InspectionClosingService {
const source = this.signatureSource(principal); const source = this.signatureSource(principal);
const signedAt = new Date(); const signedAt = new Date();
const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null;
const companyDto = fixedIdentity ? null : dto as CreateCompanySignatureDto;
const companyManifestation = fixedIdentity
? null
: companyDto?.manifestation ?? InspectionCompanySignatureManifestation.CONFORMITY;
const companyStatement = companyManifestation === InspectionCompanySignatureManifestation.DISSENT
? companyDto?.statement?.trim() ?? null
: null;
const payload = { const payload = {
actId, actId,
preparedSha256: closure.preparedSha256, preparedSha256: closure.preparedSha256,
@@ -788,6 +796,8 @@ export class InspectionClosingService {
documentNumber: identity.documentNumber, documentNumber: identity.documentNumber,
position: identity.position, position: identity.position,
status: InspectionActSignatureStatus.SIGNED, status: InspectionActSignatureStatus.SIGNED,
companyManifestation,
companyStatement,
imageSha256, imageSha256,
consentText: identity.consentText, consentText: identity.consentText,
consentVersion: CONSENT_VERSION, consentVersion: CONSENT_VERSION,
@@ -806,6 +816,7 @@ export class InspectionClosingService {
INSERT INTO inspection_act_signatures ( INSERT INTO inspection_act_signatures (
id, act_id, signer_type, signer_user_id, signer_name, id, act_id, signer_type, signer_user_id, signer_name,
document_type, document_number, position, status, document_type, document_number, position, status,
company_manifestation, company_statement,
original_name, stored_name, mime_type, size_bytes, image_sha256, original_name, stored_name, mime_type, size_bytes, image_sha256,
consent_text, consent_version, consent_accepted_at, consent_text, consent_version, consent_accepted_at,
client_signed_at, signed_at, latitude, longitude, accuracy_m, client_signed_at, signed_at, latitude, longitude, accuracy_m,
@@ -814,11 +825,12 @@ export class InspectionClosingService {
) VALUES ( ) VALUES (
$1, $2, $3, $4, $5, $1, $2, $3, $4, $5,
$6, $7, $8, 'SIGNED', $6, $7, $8, 'SIGNED',
$9, $10, $11, $12, $13, $9, $10,
$14, $15, $16, $11, $12, $13, $14, $15,
$17, $18, $19, $20, $21, $16, $17, $18,
$22, $23, $24, $25, $19, $20, $21, $22, $23,
$26, $18 $24, $25, $26, $27,
$28, $20
) )
`, [ `, [
id, id,
@@ -829,6 +841,8 @@ export class InspectionClosingService {
identity.documentType, identity.documentType,
identity.documentNumber, identity.documentNumber,
identity.position, identity.position,
companyManifestation,
companyStatement,
inspected.originalName, inspected.originalName,
storedName, storedName,
inspected.mimeType, inspected.mimeType,
@@ -1266,6 +1280,8 @@ export class InspectionClosingService {
signature.position, signature.position,
signature.status, signature.status,
signature.reason, signature.reason,
signature.company_manifestation AS "companyManifestation",
signature.company_statement AS "companyStatement",
signature.original_name AS "originalName", signature.original_name AS "originalName",
signature.stored_name AS "storedName", signature.stored_name AS "storedName",
signature.mime_type AS "mimeType", signature.mime_type AS "mimeType",
@@ -1365,6 +1381,26 @@ export class InspectionClosingService {
} }
} }
private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void {
if (act.status !== InspectionActStatus.READY
|| ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) {
throw new ConflictException({
code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY',
message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre',
});
}
}
private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void {
if (act.status !== InspectionActStatus.READY
|| ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) {
throw new ConflictException({
code: 'INSPECTION_ACT_NOT_READY',
message: 'El acta debe estar preparada para completar su cierre definitivo',
});
}
}
private async assertActorAssigned( private async assertActorAssigned(
manager: EntityManager, manager: EntityManager,
visitId: string, visitId: string,
@@ -1,4 +1,4 @@
import { ConflictException, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { EntityManager } from 'typeorm'; import type { EntityManager } from 'typeorm';
export interface FieldDiscoveryInspectionLinkResult { export interface FieldDiscoveryInspectionLinkResult {
@@ -18,7 +18,7 @@ export class FieldDiscoveryInspectionLinkService {
SELECT id, status SELECT id, status
FROM inspection_acts FROM inspection_acts
WHERE visit_id = $1 WHERE visit_id = $1
AND status <> 'CANCELLED' AND status = 'DRAFT'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
FOR UPDATE FOR UPDATE
@@ -26,25 +26,60 @@ export class FieldDiscoveryInspectionLinkService {
[visitId], [visitId],
)) as Array<{ id: string; status: string }>; )) as Array<{ id: string; status: string }>;
if (act && act.status !== 'DRAFT') { const [existing] = (await manager.query(
throw new ConflictException({ `
code: 'FIELD_DISCOVERY_ACT_NOT_EDITABLE', SELECT included, planning_source AS "planningSource"
message: 'El Acta ya no admite nuevos elementos ni Hallazgos', FROM inspection_visit_assets
}); WHERE visit_id = $1 AND asset_id = $2
} FOR UPDATE
`,
[visitId, assetId],
)) as Array<{ included: boolean; planningSource: string }>;
await manager.query( await manager.query(
` `
INSERT INTO inspection_visit_assets (visit_id, asset_id, included, added_by) INSERT INTO inspection_visit_assets (
VALUES ($1, $2, true, $3) visit_id, asset_id, included, planning_source, added_by,
exclusion_reason, excluded_by, excluded_at
)
VALUES ($1, $2, true, 'FIELD', $3, NULL, NULL, NULL)
ON CONFLICT (visit_id, asset_id) DO UPDATE SET ON CONFLICT (visit_id, asset_id) DO UPDATE SET
included = true, included = true,
planning_source = CASE
WHEN inspection_visit_assets.planning_source IN ('AUTOMATIC','VERIFICATION')
THEN inspection_visit_assets.planning_source
ELSE 'FIELD'
END,
added_by = EXCLUDED.added_by, added_by = EXCLUDED.added_by,
exclusion_reason = NULL,
excluded_by = NULL,
excluded_at = NULL,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
`, `,
[visitId, assetId, userId], [visitId, assetId, userId],
); );
const eventType = existing && existing.included ? null : 'FIELD_INCLUDED';
if (eventType) {
await manager.query(
`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, actor_user_id, metadata
)
VALUES ($1, $2, 'FIELD_INCLUDED', $3, $4::jsonb)
`,
[
visitId,
assetId,
userId,
JSON.stringify({
fieldSelection: true,
previousPlanningSource: existing?.planningSource ?? null,
}),
],
);
}
if (act) { if (act) {
await manager.query( await manager.query(
` `
@@ -16,6 +16,8 @@ function lines(snapshot: Record<string, unknown>): string[] {
const team=asArray(prepared.team); const team=asArray(prepared.team);
const assets=asArray(prepared.assets); const assets=asArray(prepared.assets);
const findings=asArray(prepared.findings); const findings=asArray(prepared.findings);
const signatures=asArray(snapshot.signatures);
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE');
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))]; const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))]; const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean); const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean);
@@ -42,6 +44,15 @@ function lines(snapshot: Record<string, unknown>): string[] {
out.push('', 'HALLAZGOS'); out.push('', 'HALLAZGOS');
if(!findings.length) out.push('Sin hallazgos registrados.'); if(!findings.length) out.push('Sin hallazgos registrados.');
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); } for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
out.push('', 'CONSTANCIA DE LA EMPRESA');
if(!companySignature) out.push('Firma o constancia pendiente.');
else if(text(companySignature.status,'')==='SIGNED'){
const manifestation=text(companySignature.companyManifestation,'CONFORMITY');
out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad');
if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else {
out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`));
}
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`); out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
return out; return out;
} }
@@ -0,0 +1,6 @@
import { IsISO8601 } from 'class-validator';
export class CloseInspectionVisitDto {
@IsISO8601({ strict: true })
clientClosedAt!: string;
}
@@ -0,0 +1,85 @@
import { Transform, Type } from 'class-transformer';
import {
IsISO8601,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class CreateFieldInventoryDto {
@IsUUID('4')
typeId!: string;
@IsOptional()
@IsUUID('4')
parentId?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : undefined)
@IsString()
@MaxLength(120)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(200)
commonName?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
discoveryNotes?: string | null;
@IsObject()
attributes!: Record<string, unknown>;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
deviceLatitude!: number;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
deviceLongitude!: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
deviceAccuracyM?: number;
@IsISO8601({ strict: true })
deviceCapturedAt!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(255)
deviceLabel?: string | null;
}
@@ -0,0 +1,25 @@
import { Transform, Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class ListFieldInventoryQueryDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : undefined)
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
parentId?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 50;
}
@@ -0,0 +1,70 @@
import { Transform, Type } from 'class-transformer';
import {
IsISO8601,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class UploadFieldInventoryPhotoDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(200)
title?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
deviceLatitude!: number;
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
deviceLongitude!: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 3 })
@Min(0)
@Max(100000)
deviceAccuracyM?: number;
@IsISO8601({ strict: true })
deviceCapturedAt!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(255)
deviceLabel?: string | null;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-90)
@Max(90)
exifLatitude?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 6 })
@Min(-180)
@Max(180)
exifLongitude?: number;
@IsOptional()
@IsISO8601({ strict: true })
exifCapturedAt?: string;
}
@@ -0,0 +1,93 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { MAX_ASSET_MEDIA_BYTES, type UploadedAssetFile } from '../asset-master/asset-media-file';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
import { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
import { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
import { FieldInventoryService } from './field-inventory.service';
@Controller('inspection-visits/:visitId/field-inventory')
export class FieldInventoryController {
constructor(private readonly fieldInventory: FieldInventoryService) {}
@Get()
@RequirePermissions('assets.read', 'inspections.execute')
list(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Query() query: ListFieldInventoryQueryDto,
@CurrentAuth() principal: AuthPrincipal,
) {
return this.fieldInventory.list(visitId, query, principal);
}
@Get('types')
@RequirePermissions('assets.read', 'inspections.execute')
types(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Query() query: ListFieldInventoryQueryDto,
@CurrentAuth() principal: AuthPrincipal,
) {
return this.fieldInventory.types(visitId, query.parentId, principal);
}
@Get(':assetId')
@RequirePermissions('assets.read', 'inspections.execute')
detail(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
return this.fieldInventory.detail(visitId, assetId, principal);
}
@Post(':assetId/select')
@RequirePermissions('assets.read', 'inspections.execute')
selectExisting(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
return this.fieldInventory.selectExisting(visitId, assetId, principal);
}
@Post()
@RequirePermissions('assets.create', 'inspections.execute')
create(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Body() dto: CreateFieldInventoryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.fieldInventory.create(visitId, dto, principal, request);
}
@Post(':assetId/photos')
@RequirePermissions('assets.create', 'inspections.execute')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 },
}))
uploadPhoto(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: UploadFieldInventoryPhotoDto,
@UploadedFile() file: UploadedAssetFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.fieldInventory.uploadPhoto(visitId, assetId, dto, file, principal, request);
}
}
@@ -0,0 +1,648 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AssetGeometriesService } from '../asset-master/asset-geometries.service';
import { AssetMediaService, type AssetMediaView } from '../asset-master/asset-media.service';
import { AssetsService } from '../asset-master/assets.service';
import type { UploadedAssetFile } from '../asset-master/asset-media-file';
import type { CreateFieldDiscoveryDto } from '../asset-master/dto/create-field-discovery.dto';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetGeometryType, AssetMediaKind } from '../database/entities';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
import type { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
import type { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
interface MobileVisitContext {
id: string;
code: string;
status: string;
areaId: string;
areaCode: string;
areaName: string;
companyId: string;
companyCode: string;
companyName: string;
assigned: boolean;
}
interface FieldDiscoveryCreated {
id: string;
asset: {
id: string;
code: string;
name: string;
informationStatus: string;
};
}
export interface FieldInventoryCaptureStatus {
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
readyForFinding: boolean;
latestDeviceCapture: {
latitude: number;
longitude: number;
accuracyM: number | null;
capturedAt: Date;
deviceLabel: string | null;
} | null;
latestPhotoExif: {
latitude: number | null;
longitude: number | null;
capturedAt: Date | null;
} | null;
}
@Injectable()
export class FieldInventoryService {
constructor(
private readonly dataSource: DataSource,
private readonly assets: AssetsService,
private readonly geometries: AssetGeometriesService,
private readonly media: AssetMediaService,
private readonly links: FieldDiscoveryInspectionLinkService,
) {}
async list(
visitId: string,
query: ListFieldInventoryQueryDto,
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context);
const args: unknown[] = [context.areaId, context.companyId, visitId];
const conditions = [
'asset.operational_area_id = $1::uuid',
'asset.operator_company_id = $2::uuid',
"asset.information_status <> 'INACTIVE'",
];
if (query.search?.trim()) {
args.push(`%${query.search.trim()}%`);
const p = `$${args.length}`;
conditions.push(`(
asset.code ILIKE ${p}
OR asset.name ILIKE ${p}
OR asset.common_name ILIKE ${p}
OR EXISTS (
SELECT 1
FROM asset_attribute_values value
JOIN asset_attribute_definitions definition ON definition.id = value.definition_id
WHERE value.asset_id = asset.id
AND definition.is_active = true
AND (value.value #>> '{}') ILIKE ${p}
)
)`);
}
if (query.parentId) {
args.push(query.parentId);
conditions.push(`asset.parent_id = $${args.length}::uuid`);
}
if (query.typeId) {
args.push(query.typeId);
conditions.push(`asset.asset_type_id = $${args.length}::uuid`);
}
args.push(query.limit);
const limit = `$${args.length}`;
const data = await this.dataSource.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.common_name AS "commonName",
asset.information_status AS "informationStatus",
asset.data_origin AS "dataOrigin",
JSONB_BUILD_OBJECT('id', type.id, 'code', type.code, 'name', type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id, 'code', parent.code, 'name', parent.name
) END AS parent,
EXISTS (
SELECT 1 FROM inspection_visit_assets link
WHERE link.visit_id = $3::uuid
AND link.asset_id = asset.id
AND link.included = true
) AS "selectedInInspection",
EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
) AS "captureRequired",
EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
) AS "hasGeometry",
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
) AS "fieldPhotoCount",
(
NOT EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
)
OR (
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'CREATED'
)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
)
)
) AS "readyForFinding"
FROM assets asset
JOIN asset_types type ON type.id = asset.asset_type_id
LEFT JOIN assets parent ON parent.id = asset.parent_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name, asset.code
LIMIT ${limit}
`, args);
return { context: this.publicContext(context), data };
}
async types(
visitId: string,
parentId: string | undefined,
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, false);
const effectiveParentId = parentId ?? context.areaId;
const parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(`
SELECT
type.id,
type.code,
type.name,
type.description,
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order
) ORDER BY definition.sort_order, definition.name
) FILTER (WHERE definition.id IS NOT NULL),
'[]'::jsonb
) AS attributes
FROM asset_type_parent_rules rule
JOIN asset_types type ON type.id = rule.child_type_id
LEFT JOIN asset_attribute_definitions definition
ON definition.asset_type_id = type.id
AND definition.is_active = true
WHERE rule.parent_type_id = $1::uuid
AND type.is_active = true
AND type.operational_role = 'GENERIC'
GROUP BY type.id, type.code, type.name, type.description
ORDER BY type.name, type.code
`, [parent.typeId]);
return {
context: this.publicContext(context),
parent: { id: parent.id, code: parent.code, name: parent.name },
data,
};
}
async detail(visitId: string, assetId: string, principal: AuthPrincipal) {
const context = await this.requireVisitContext(visitId, principal, false);
await this.requireAssetInContext(assetId, context);
return {
context: this.publicContext(context),
asset: await this.assets.getById(assetId),
selectedInInspection: await this.isSelected(visitId, assetId),
capture: await this.captureStatus(visitId, assetId),
};
}
async selectExisting(
visitId: string,
assetId: string,
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, true);
await this.requireAssetInContext(assetId, context);
await this.dataSource.transaction((manager) =>
this.links.attach(manager, visitId, assetId, principal.userId),
);
return this.detail(visitId, assetId, principal);
}
async create(
visitId: string,
dto: CreateFieldInventoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const context = await this.requireVisitContext(visitId, principal, true);
const parentId = dto.parentId ?? context.areaId;
await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt);
if (!Number.isFinite(capturedAt.getTime())) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE',
message: 'La fecha de captura del dispositivo no es válida',
});
}
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
const discoveryDto: CreateFieldDiscoveryDto = {
visitId,
code,
name: dto.name,
commonName: dto.commonName ?? null,
typeId: dto.typeId,
parentId,
operationalAreaId: context.areaId,
operatorCompanyId: context.companyId,
description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null,
attributes: dto.attributes,
};
const discovery = await this.assets.createFieldDiscovery(
discoveryDto,
principal,
request,
) as FieldDiscoveryCreated;
const assetId = discovery.asset?.id;
if (!assetId) {
throw new Error('El alta de campo no devolvió el identificador del Inventario creado');
}
await this.geometries.upsert(assetId, {
geometry: {
type: AssetGeometryType.POINT,
coordinates: [dto.deviceLongitude, dto.deviceLatitude],
},
accuracyM: dto.deviceAccuracyM ?? null,
capturedAt: dto.deviceCapturedAt,
deviceLabel: dto.deviceLabel ?? null,
}, principal, request);
await this.dataSource.query(`
INSERT INTO asset_field_capture_events (
visit_id, asset_id, event_type,
device_latitude, device_longitude, device_accuracy_m,
device_captured_at, device_label, created_by
) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8)
`, [
visitId,
assetId,
dto.deviceLatitude,
dto.deviceLongitude,
dto.deviceAccuracyM ?? null,
capturedAt,
dto.deviceLabel ?? null,
principal.userId,
]);
return this.detail(visitId, assetId, principal);
}
async uploadPhoto(
visitId: string,
assetId: string,
dto: UploadFieldInventoryPhotoDto,
file: UploadedAssetFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
const context = await this.requireVisitContext(visitId, principal, true);
await this.requireAssetInContext(assetId, context);
if (!await this.isSelected(visitId, assetId)) {
throw new ConflictException({
code: 'FIELD_INVENTORY_NOT_SELECTED',
message: 'Seleccioná el registro dentro de la inspección antes de agregar fotografías',
});
}
const exifHasLatitude = dto.exifLatitude !== undefined;
const exifHasLongitude = dto.exifLongitude !== undefined;
if (exifHasLatitude !== exifHasLongitude) {
throw new BadRequestException({
code: 'FIELD_PHOTO_EXIF_COORDINATES_INCOMPLETE',
message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas',
});
}
const uploaded = await this.media.upload(assetId, {
kind: AssetMediaKind.PHOTO,
title: dto.title ?? undefined,
description: dto.description ?? undefined,
capturedAt: dto.deviceCapturedAt,
latitude: dto.deviceLatitude,
longitude: dto.deviceLongitude,
accuracyM: dto.deviceAccuracyM,
}, file, principal, request);
await this.dataSource.query(`
INSERT INTO asset_field_capture_events (
visit_id, asset_id, media_id, event_type,
device_latitude, device_longitude, device_accuracy_m,
device_captured_at, device_label,
exif_latitude, exif_longitude, exif_captured_at,
created_by
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12)
`, [
visitId,
assetId,
uploaded.id,
dto.deviceLatitude,
dto.deviceLongitude,
dto.deviceAccuracyM ?? null,
new Date(dto.deviceCapturedAt),
dto.deviceLabel ?? null,
dto.exifLatitude ?? null,
dto.exifLongitude ?? null,
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
principal.userId,
]);
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
}
private async requireVisitContext(
visitId: string,
principal: AuthPrincipal,
requireInProgress: boolean,
): Promise<MobileVisitContext> {
assertMobileInspector(principal);
const [context] = (await this.dataSource.query(`
SELECT
visit.id,
visit.code,
visit.status,
visit.operational_area_id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName",
visit.operator_company_id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName",
(
visit.lead_inspector_user_id = $2::uuid
OR EXISTS (
SELECT 1
FROM inspection_visit_members member
WHERE member.visit_id = visit.id
AND member.user_id = $2::uuid
AND member.included = true
)
) AS assigned
FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
WHERE visit.id = $1::uuid
`, [visitId, principal.userId])) as MobileVisitContext[];
if (!context) {
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
}
if (!context.areaId || !context.companyId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_CONTEXT_REQUIRED',
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!context.assigned) {
throw new ConflictException({
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
message: 'El inspector no está asignado a esta inspección',
});
}
if (requireInProgress && context.status !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FIELD_INVENTORY_VISIT_NOT_IN_PROGRESS',
message: 'El Inventario sólo puede modificarse cuando la inspección está en curso',
});
}
if (!requireInProgress && !['PLANNED', 'IN_PROGRESS'].includes(context.status)) {
throw new ConflictException({
code: 'FIELD_INVENTORY_VISIT_NOT_AVAILABLE',
message: 'El Inventario de campo está disponible para inspecciones planificadas o en curso',
});
}
return context;
}
private publicContext(context: MobileVisitContext) {
return {
inspection: { id: context.id, code: context.code, status: context.status },
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: {
id: context.companyId,
code: context.companyCode,
name: context.companyName,
},
};
}
private async requireParentInContext(parentId: string, context: MobileVisitContext) {
const [parent] = (await this.dataSource.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset.asset_type_id AS "typeId",
asset.operational_area_id AS "areaId",
asset.operator_company_id AS "companyId"
FROM assets asset
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [parentId])) as Array<{
id: string;
code: string;
name: string;
typeId: string;
areaId: string | null;
companyId: string | null;
}>;
if (!parent) {
throw new NotFoundException({
code: 'FIELD_INVENTORY_PARENT_NOT_FOUND',
message: 'La ubicación padre no existe',
});
}
if (
parent.id !== context.areaId
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área y Operadora de la inspección',
});
}
return parent;
}
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
const [asset] = (await this.dataSource.query(`
SELECT id,
operational_area_id AS "areaId",
operator_company_id AS "companyId"
FROM assets
WHERE id = $1::uuid
AND information_status <> 'INACTIVE'
`, [assetId])) as Array<{
id: string;
areaId: string | null;
companyId: string | null;
}>;
if (!asset) {
throw new NotFoundException({
code: 'FIELD_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área y Operadora de esta inspección',
});
}
return asset;
}
private async isSelected(visitId: string, assetId: string): Promise<boolean> {
const rows = await this.dataSource.query(`
SELECT 1
FROM inspection_visit_assets
WHERE visit_id = $1::uuid
AND asset_id = $2::uuid
AND included = true
`, [visitId, assetId]) as unknown[];
return rows.length > 0;
}
private async captureStatus(
visitId: string,
assetId: string,
): Promise<FieldInventoryCaptureStatus> {
const [row] = (await this.dataSource.query(`
SELECT
EXISTS (
SELECT 1
FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $1::uuid
AND discovery.asset_id = $2::uuid
) AS "captureRequired",
EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = $2::uuid
) AS "hasGeometry",
EXISTS (
SELECT 1
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
AND event.event_type = 'CREATED'
) AS "creationGpsCaptured",
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
AND event.event_type = 'PHOTO'
) AS "fieldPhotoCount",
(
SELECT JSONB_BUILD_OBJECT(
'latitude', event.device_latitude,
'longitude', event.device_longitude,
'accuracyM', event.device_accuracy_m,
'capturedAt', event.device_captured_at,
'deviceLabel', event.device_label
)
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
LIMIT 1
) AS "latestDeviceCapture",
(
SELECT JSONB_BUILD_OBJECT(
'latitude', event.exif_latitude,
'longitude', event.exif_longitude,
'capturedAt', event.exif_captured_at
)
FROM asset_field_capture_events event
WHERE event.visit_id = $1::uuid
AND event.asset_id = $2::uuid
AND event.event_type = 'PHOTO'
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
LIMIT 1
) AS "latestPhotoExif"
`, [visitId, assetId])) as Array<{
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
latestDeviceCapture: FieldInventoryCaptureStatus['latestDeviceCapture'];
latestPhotoExif: FieldInventoryCaptureStatus['latestPhotoExif'];
}>;
const captureRequired = Boolean(row?.captureRequired);
const hasGeometry = Boolean(row?.hasGeometry);
const creationGpsCaptured = Boolean(row?.creationGpsCaptured);
const fieldPhotoCount = Number(row?.fieldPhotoCount ?? 0);
return {
captureRequired,
hasGeometry,
creationGpsCaptured,
fieldPhotoCount,
readyForFinding: !captureRequired || (
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
),
latestDeviceCapture: row?.latestDeviceCapture ?? null,
latestPhotoExif: row?.latestPhotoExif ?? null,
};
}
private async nextFieldCode(observedAt: Date): Promise<string> {
const year = observedAt.getUTCFullYear();
if (!Number.isInteger(year) || year < 2000 || year > 9999) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE',
message: 'La fecha de captura no es válida para generar el código',
});
}
const [row] = (await this.dataSource.query(`
INSERT INTO field_inventory_code_sequences (year, last_value)
VALUES ($1, 1)
ON CONFLICT (year)
DO UPDATE SET last_value = field_inventory_code_sequences.last_value + 1
RETURNING last_value AS value
`, [year])) as Array<{ value: number }>;
const value = Number(row?.value ?? 0);
if (!value) throw new Error('No se pudo generar el código de Inventario de campo');
return `CAM-${year}-${String(value).padStart(6, '0')}`;
}
}
@@ -14,6 +14,7 @@ import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
@@ -154,4 +155,15 @@ export class InspectionVisitsController {
) { ) {
return this.visits.start(id, principal, request); return this.visits.start(id, principal, request);
} }
@Post(':id/close')
@RequirePermissions('inspections.execute')
close(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CloseInspectionVisitDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.visits.close(id, dto, principal, request);
}
} }
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AssetMasterModule } from '../asset-master/asset-master.module';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule, AssetMasterModule],
controllers: [InspectionVisitsController], controllers: [InspectionVisitsController, FieldInventoryController],
providers: [InspectionVisitsService], providers: [InspectionVisitsService, FieldInventoryService],
}) })
export class InspectionVisitsModule {} export class InspectionVisitsModule {}
@@ -19,6 +19,7 @@ import {
InspectionVisitStatus, InspectionVisitStatus,
} from '../database/entities'; } from '../database/entities';
import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto'; import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
@@ -810,6 +811,107 @@ export class InspectionVisitsService {
}); });
} }
async close(
id: string,
dto: CloseInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
message: 'La inspección debe estar en curso para cerrarse',
});
}
if (!principal.permissions.includes('inspections.manage')) {
const memberIds = await this.activeMemberIds(manager, id);
if (!memberIds.includes(principal.userId)) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La inspección no está asignada al usuario actual',
});
}
}
const [acts] = (await manager.query(`
SELECT
COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts,
COUNT(*) FILTER (
WHERE act.status = 'READY'
AND NOT EXISTS (
SELECT 1 FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'INSPECTOR'
AND signature.status = 'SIGNED'
)
)::integer AS "readyWithoutInspector",
COUNT(*) FILTER (
WHERE act.status = 'READY'
AND NOT EXISTS (
SELECT 1 FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'COMPANY_RESPONSIBLE'
)
)::integer AS "pendingCompany"
FROM inspection_acts act
WHERE act.visit_id = $1
AND act.status <> 'CANCELLED'
`, [id])) as Array<{ drafts: number; readyWithoutInspector: number; pendingCompany: number }>;
if (Number(acts?.drafts ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING',
message: 'Prepará o cancelá las actas en borrador antes de cerrar la inspección',
});
}
if (Number(acts?.readyWithoutInspector ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING',
message: 'Toda acta preparada debe tener firma de inspector antes de cerrar la inspección',
});
}
const serverClosedAt = new Date();
const clientClosedAt = new Date(dto.clientClosedAt);
if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la inspección',
});
}
if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME',
message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro',
});
}
visit.status = InspectionVisitStatus.CLOSED;
visit.actualClosedAt = serverClosedAt;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: InspectionVisitStatus.IN_PROGRESS },
afterData: {
status: InspectionVisitStatus.CLOSED,
clientClosedAt: clientClosedAt.toISOString(),
serverClosedAt: serverClosedAt.toISOString(),
pendingCompanySignatures: Number(acts?.pendingCompany ?? 0),
},
metadata: {
closeSource: 'ANDROID',
actsMayCompleteCompanySignatureLater: true,
},
}, manager);
return updated;
});
}
private visitSelect(where: string): string { private visitSelect(where: string): string {
return ` return `
SELECT SELECT
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.19.6-4'; export const API_VERSION = '0.22.0-1';
export const API_PHASE = 'D5.6.4'; export const API_PHASE = 'F2.2';
@@ -0,0 +1,67 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { FieldInventoryController } from '../../src/inspection-visits/field-inventory.controller';
import { CreateFieldInventoryDto } from '../../src/inspection-visits/dto/create-field-inventory.dto';
import { UploadFieldInventoryPhotoDto } from '../../src/inspection-visits/dto/upload-field-inventory-photo.dto';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
function permissions(method: string): string[] {
const controller = FieldInventoryController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F2.1 mantiene Inventario de campo bajo política móvil y permisos explícitos', () => {
assert.deepEqual(permissions('list'), ['assets.read', 'inspections.execute']);
assert.deepEqual(permissions('types'), ['assets.read', 'inspections.execute']);
assert.deepEqual(permissions('selectExisting'), ['assets.read', 'inspections.execute']);
assert.deepEqual(permissions('create'), ['assets.create', 'inspections.execute']);
assert.deepEqual(permissions('uploadPhoto'), ['assets.create', 'inspections.execute']);
});
test('F2.1 exige GPS del dispositivo al crear Inventario en campo', async () => {
const dto = plainToInstance(CreateFieldInventoryDto, {
typeId: '11111111-1111-4111-8111-111111111111',
name: 'Batería 28',
attributes: {},
deviceCapturedAt: '2026-09-05T17:30:00-03:00',
});
const errors = await validate(dto);
const properties = new Set(errors.map((error) => error.property));
assert.equal(properties.has('deviceLatitude'), true);
assert.equal(properties.has('deviceLongitude'), true);
});
test('F2.1 exige GPS del dispositivo en cada fotografía de campo', async () => {
const dto = plainToInstance(UploadFieldInventoryPhotoDto, {
deviceCapturedAt: '2026-09-05T17:30:00-03:00',
});
const errors = await validate(dto);
const properties = new Set(errors.map((error) => error.property));
assert.equal(properties.has('deviceLatitude'), true);
assert.equal(properties.has('deviceLongitude'), true);
});
test('F2.1 deriva Área y Operadora desde la Inspección y usa el asset anidado del alta provisional', () => {
const source = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
assert.match(source, /visit\.operational_area_id AS \"areaId\"/);
assert.match(source, /visit\.operator_company_id AS \"companyId\"/);
assert.match(source, /operationalAreaId: context\.areaId/);
assert.match(source, /operatorCompanyId: context\.companyId/);
assert.match(source, /const assetId = discovery\.asset\?\.id/);
});
test('F2.1 bloquea hallazgos sobre altas de campo hasta tener GPS y foto', () => {
const migration = readFileSync(
'src/database/migrations/1789668000000-phase-f2-1-field-inventory.ts',
'utf8',
);
assert.match(migration, /enforce_field_inventory_capture_before_finding/);
assert.match(migration, /FIELD_INVENTORY_GPS_REQUIRED/);
assert.match(migration, /FIELD_INVENTORY_PHOTO_REQUIRED/);
assert.match(migration, /REVOKE UPDATE, DELETE ON TABLE asset_field_capture_events/);
});
@@ -4,37 +4,29 @@ import { ConflictException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm'; import type { DataSource, EntityManager } from 'typeorm';
import { InspectionActsService } from '../../src/inspection-acts/inspection-acts.service'; import { InspectionActsService } from '../../src/inspection-acts/inspection-acts.service';
type SingleActGuard = { type DraftActGuard = {
assertVisitHasNoAct(manager: EntityManager, visitId: string): Promise<void>; assertVisitHasNoDraftAct(manager: EntityManager, visitId: string): Promise<void>;
}; };
function serviceGuard(): SingleActGuard { function serviceGuard(): DraftActGuard {
return new InspectionActsService( return new InspectionActsService(
{} as DataSource, {} as DataSource,
{} as never, {} as never,
) as unknown as SingleActGuard; ) as unknown as DraftActGuard;
} }
test('single-act guard allows a visit without an act', async () => { test('multi-act guard allows a new act when the inspection has no draft act', async () => {
const manager = { const manager = { query: async () => [] } as unknown as EntityManager;
query: async () => [], await assert.doesNotReject(serviceGuard().assertVisitHasNoDraftAct(manager, 'visit-id'));
} as unknown as EntityManager;
await assert.doesNotReject(
serviceGuard().assertVisitHasNoAct(manager, 'visit-id'),
);
}); });
test('single-act guard rejects a second act for the same visit', async () => { test('multi-act guard blocks only a second draft act for the same inspection', async () => {
const manager = { const manager = { query: async () => [{ code: 'ACTA-2026-000001' }] } as unknown as EntityManager;
query: async () => [{ code: 'ACTA-2026-000001' }],
} as unknown as EntityManager;
await assert.rejects( await assert.rejects(
serviceGuard().assertVisitHasNoAct(manager, 'visit-id'), serviceGuard().assertVisitHasNoDraftAct(manager, 'visit-id'),
(error: unknown) => error instanceof ConflictException (error: unknown) => error instanceof ConflictException
&& error.getResponse() instanceof Object && error.getResponse() instanceof Object
&& (error.getResponse() as { code?: string }).code && (error.getResponse() as { code?: string }).code
=== 'INSPECTION_VISIT_ACT_ALREADY_EXISTS', === 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
); );
}); });
@@ -20,4 +20,5 @@ test('inspection visit endpoints separate reading, planning, assignment and exec
assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']); assert.deepEqual(permissionFor('replaceTeam'), ['inspections.assign']);
assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']); assert.deepEqual(permissionFor('changeStatus'), ['inspections.manage']);
assert.deepEqual(permissionFor('start'), ['inspections.execute']); assert.deepEqual(permissionFor('start'), ['inspections.execute']);
assert.deepEqual(permissionFor('close'), ['inspections.execute']);
}); });
@@ -19,7 +19,7 @@ test('D5.1 applies the mobile-inspector policy to every field operation', async
source('inspection-findings/inspection-evidence.service.ts'), source('inspection-findings/inspection-evidence.service.ts'),
source('inspection-closing/inspection-closing.service.ts'), source('inspection-closing/inspection-closing.service.ts'),
]); ]);
assert.equal(policyCalls(visits), 1); assert.equal(policyCalls(visits), 2);
assert.equal(policyCalls(acts), 3); assert.equal(policyCalls(acts), 3);
assert.equal(policyCalls(findings), 2); assert.equal(policyCalls(findings), 2);
assert.equal(policyCalls(evidence), 1); assert.equal(policyCalls(evidence), 1);
@@ -46,9 +46,9 @@ test('D5.3.19 exposes company area inspector and date filters across operational
} }
}); });
test('D5.3.19 keeps one visit equal to one act', () => { test('F1.1 supersedes the historical single-act rule while retaining the D5.3.19 filters', () => {
assert.match(actsService, /await this\.assertVisitHasNoAct\(manager, visitId\)/); assert.match(actsService, /await this\.assertVisitHasNoDraftAct\(manager, visitId\)/);
assert.match(actsService, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); assert.match(actsService, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/);
}); });
test('D5.3.19 does not introduce the contextual finding catalog before its office definition', () => { test('D5.3.19 does not introduce the contextual finding catalog before its office definition', () => {
@@ -35,7 +35,7 @@ test('D5.3.20 builds OOXML Word from the frozen act snapshot without external do
assert.doesNotMatch(builder, /nodemailer|libreoffice|pandoc/i); assert.doesNotMatch(builder, /nodemailer|libreoffice|pandoc/i);
}); });
test('D5.3.20 preserves one visit one act and leaves email delivery for a configured phase', () => { test('D5.3.20 keeps frozen report generation act-scoped and leaves email delivery for a configured phase', () => {
assert.match(closing, /findingsRemainOpen: true/); assert.match(closing, /findingsRemainOpen: true/);
assert.doesNotMatch(migration, /smtp|email_outbox|mail_delivery/i); assert.doesNotMatch(migration, /smtp|email_outbox|mail_delivery/i);
}); });
@@ -4,4 +4,4 @@ test('D5.3.21 stores institutional and company recipients without hardcoding add
test('D5.3.21 creates an immutable act PDF and separates its delivery from the report Word',()=>{assert.match(migration,/inspection_act_pdf_artifacts/);assert.match(migration,/ACT_PDF/);assert.match(migration,/REPORT_WORD/);assert.match(pdf,/%PDF-1\.4/);assert.match(delivery,/this\.pdf\.ensure/);assert.match(delivery,/this\.word\.ensure/);}); test('D5.3.21 creates an immutable act PDF and separates its delivery from the report Word',()=>{assert.match(migration,/inspection_act_pdf_artifacts/);assert.match(migration,/ACT_PDF/);assert.match(migration,/REPORT_WORD/);assert.match(pdf,/%PDF-1\.4/);assert.match(delivery,/this\.pdf\.ensure/);assert.match(delivery,/this\.word\.ensure/);});
test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states',()=>{assert.match(migration,/WAITING_RECIPIENT/);assert.match(migration,/WAITING_TRANSPORT/);assert.match(migration,/WAITING_ARTIFACT/);assert.match(delivery,/export interface DeliveryRow/);assert.match(controller,/Promise<DeliveryRow>/);assert.match(controller,/outbox\/:id\/retry/);assert.match(controller,/retry-pending/);}); test('D5.3.21 keeps an auditable retryable outbox with explicit waiting states',()=>{assert.match(migration,/WAITING_RECIPIENT/);assert.match(migration,/WAITING_TRANSPORT/);assert.match(migration,/WAITING_ARTIFACT/);assert.match(delivery,/export interface DeliveryRow/);assert.match(controller,/Promise<DeliveryRow>/);assert.match(controller,/outbox\/:id\/retry/);assert.match(controller,/retry-pending/);});
test('D5.3.21 only attempts SMTP when transport and recipients are configured',()=>{assert.match(smtp,/SMTP_HOST/);assert.match(smtp,/MAIL_FROM/);assert.match(smtp,/STARTTLS/);assert.match(delivery,/this\.smtp\.configured\(\)/);assert.match(delivery,/WAITING_TRANSPORT/);}); test('D5.3.21 only attempts SMTP when transport and recipients are configured',()=>{assert.match(smtp,/SMTP_HOST/);assert.match(smtp,/MAIL_FROM/);assert.match(smtp,/STARTTLS/);assert.match(delivery,/this\.smtp\.configured\(\)/);assert.match(delivery,/WAITING_TRANSPORT/);});
test('D5.3.21 triggers document delivery after the frozen report Word step without changing one visit one act',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoAct/);assert.match(acts,/INSPECTION_VISIT_ACT_ALREADY_EXISTS/);}); test('D5.3.21 delivery stays act-scoped after F1.1 enables multiple acts',()=>{assert.match(reports,/await this\.delivery\.dispatchForAct\(actId\)/); const acts=read('src/inspection-acts/inspection-acts.service.ts');assert.match(acts,/assertVisitHasNoDraftAct/);assert.match(acts,/INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/);});
@@ -48,8 +48,8 @@ test('D5.3.22 binds the final Director signature to report and revision hashes',
}); });
test('D5.3.22 changes only the report review layer and preserves inspection immutability', () => { test('D5.3.22 changes only the report review layer and preserves inspection immutability', () => {
assert.match(acts, /assertVisitHasNoAct/); assert.match(acts, /assertVisitHasNoDraftAct/);
assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/);
assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/); assert.match(findings, /INSPECTION_FINDING_RESPONSE_IMMUTABLE/);
assert.doesNotMatch(migration, /ALTER TABLE inspection_acts/); assert.doesNotMatch(migration, /ALTER TABLE inspection_acts/);
assert.doesNotMatch(migration, /ALTER TABLE inspection_findings/); assert.doesNotMatch(migration, /ALTER TABLE inspection_findings/);
@@ -26,12 +26,14 @@ test('D5.3.23 only lets a mobile assigned inspector create during an active visi
assert.match(assets, /FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED/); assert.match(assets, /FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED/);
}); });
test('D5.3.23 immediately links the provisional object to the visit and open act', () => { test('F1.1 preserves D5.3.23 field discovery while linking only to the current draft act', () => {
assert.match(assets, /fieldDiscoveryInspectionLinks\.attach/); assert.match(assets, /fieldDiscoveryInspectionLinks\.attach/);
assert.doesNotMatch(assets, /INSERT INTO inspection_/); assert.doesNotMatch(assets, /INSERT INTO inspection_/);
assert.match(inspectionLinks, /INSERT INTO inspection_visit_assets/); assert.match(inspectionLinks, /INSERT INTO inspection_visit_assets/);
assert.match(inspectionLinks, /INSERT INTO inspection_act_assets/); assert.match(inspectionLinks, /INSERT INTO inspection_act_assets/);
assert.match(inspectionLinks, /FIELD_DISCOVERY_ACT_NOT_EDITABLE/); assert.match(inspectionLinks, /status = 'DRAFT'/);
assert.match(inspectionLinks, /if \(act\)/);
assert.match(inspectionLinks, /actId: act\?\.id \?\? null/);
assert.match(findings, /assertActAsset/); assert.match(findings, /assertActAsset/);
}); });
@@ -6,10 +6,10 @@ import test from 'node:test';
const root = process.cwd(); const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8'); const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('D5.6 preserves one visit one immutable act with a frozen closure snapshot', () => { test('D5.6 immutable act closure remains intact after the multi-act transition', () => {
const acts = read('src/inspection-acts/inspection-acts.service.ts'); const acts = read('src/inspection-acts/inspection-acts.service.ts');
const closing = read('src/inspection-closing/inspection-closing.service.ts'); const closing = read('src/inspection-closing/inspection-closing.service.ts');
assert.match(acts, /INSPECTION_VISIT_ACT_ALREADY_EXISTS/); assert.match(acts, /INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS/);
assert.match(closing, /final_snapshot = \$2/); assert.match(closing, /final_snapshot = \$2/);
assert.match(closing, /final_sha256 = \$3/); assert.match(closing, /final_sha256 = \$3/);
assert.match(closing, /InspectionActVersionEvent\.CLOSED/); assert.match(closing, /InspectionActVersionEvent\.CLOSED/);
@@ -74,10 +74,8 @@ test('D5.6 preserves auditable document delivery while allowing SMTP to remain p
assert.match(smtp, /MAIL_FROM/); assert.match(smtp, /MAIL_FROM/);
}); });
test('D5.6 declares a stable Phase D closure across maintenance hotfixes without introducing another functional migration', () => { test('D5.6 remains a historical closure baseline while Phase F introduces an explicit new migration', () => {
const apiVersion = read('src/version.ts');
assert.match(apiVersion, /API_VERSION = '0\.19\.6(?:-\d+)?'/);
assert.match(apiVersion, /API_PHASE = 'D5\.6(?:\.\d+)?'/);
const migrations = fs.readdirSync(path.join(root, 'src/database/migrations')); const migrations = fs.readdirSync(path.join(root, 'src/database/migrations'));
assert.equal(migrations.some((name) => /phase-d5-6/i.test(name)), false); assert.equal(migrations.some((name) => /phase-d5-6/i.test(name)), false);
assert.equal(migrations.some((name) => /phase-f1-1-multi-act-inspections/i.test(name)), true);
}); });
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const root = process.cwd();
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
test('F1.1 migration allows many acts per inspection while keeping only one draft at a time', () => {
const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts');
assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/);
assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/);
assert.match(migration, /WHERE status = 'DRAFT'/);
assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_reports_visit/);
assert.match(migration, /CREATE INDEX idx_inspection_reports_visit_id/);
});
test('F1.1 preserves exactly one frozen report per act instead of per inspection', () => {
const reportEntity = read('src/database/entities/inspection-report.entity.ts');
const reports = read('src/inspection-reports/inspection-reports.service.ts');
assert.match(reportEntity, /@Index\('uq_inspection_reports_act', \['actId'\], \{ unique: true \}\)/);
assert.match(reportEntity, /@Index\('idx_inspection_reports_visit_id', \['visitId'\]\)/);
assert.doesNotMatch(reportEntity, /visitId'\], \{ unique: true \}/);
assert.match(reports, /WHERE act_id = \$1/);
});
test('F1.1 closes an act without closing its parent inspection', () => {
const closing = read('src/inspection-closing/inspection-closing.service.ts');
const closeBody = closing.slice(closing.indexOf(' async close('), closing.indexOf(' async signatureContent('));
assert.match(closeBody, /UPDATE inspection_acts/);
assert.doesNotMatch(closeBody, /UPDATE inspection_visits/);
assert.match(closeBody, /visitRemainsIndependent: true/);
assert.match(closeBody, /ensureFrozenReport\(manager, actId, principal, request\)/);
});
test('F1.1 exposes an APK-only explicit inspection close and permits pending company signature', () => {
const controller = read('src/inspection-visits/inspection-visits.controller.ts');
const visits = read('src/inspection-visits/inspection-visits.service.ts');
assert.match(controller, /@Post\(':id\/close'\)/);
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(visits, /async close\(/);
assert.match(visits, /assertMobileInspector\(principal\)/);
assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/);
assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/);
assert.match(visits, /pendingCompanySignatures/);
assert.match(visits, /actsMayCompleteCompanySignatureLater: true/);
});
test('F1.1 lets the company sign after the inspection closes and records conformity or dissent', () => {
const closing = read('src/inspection-closing/inspection-closing.service.ts');
const dto = read('src/inspection-closing/dto/create-company-signature.dto.ts');
const signatureEntity = read('src/database/entities/inspection-act-signature.entity.ts');
assert.match(closing, /InspectionVisitStatus\.IN_PROGRESS, InspectionVisitStatus\.CLOSED/);
assert.match(closing, /InspectionCompanySignatureManifestation\.CONFORMITY/);
assert.match(closing, /InspectionCompanySignatureManifestation\.DISSENT/);
assert.match(dto, /@ValidateIf/);
assert.match(dto, /@MinLength\(10\)/);
assert.match(signatureEntity, /CONFORMITY = 'CONFORMITY'/);
assert.match(signatureEntity, /DISSENT = 'DISSENT'/);
assert.match(signatureEntity, /companyStatement/);
});
test('F1.1 keeps field-created Inventory linked only to the current draft act', () => {
const link = read('src/inspection-operations/field-discovery-inspection-link.service.ts');
assert.match(link, /AND status = 'DRAFT'/);
assert.match(link, /if \(act\)/);
assert.match(link, /inspection_visit_assets/);
assert.match(link, /inspection_act_assets/);
assert.match(link, /return \{ actId: act\?\.id \?\? null \}/);
assert.doesNotMatch(link, /status <> 'CANCELLED'/);
});
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { SetActResponseDeadlineDto } from '../../src/act-administration/dto/set-act-response-deadline.dto';
import { CreateActCompanyResponseDto } from '../../src/act-administration/dto/create-act-company-response.dto';
const source = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const migration = source('src/database/migrations/1789581600000-phase-f1-2-act-administration.ts');
const service = source('src/act-administration/act-administration.service.ts');
const controller = source('src/act-administration/act-administration.controller.ts');
test('F1.2 stores one append-only administrative deadline ledger per Act', () => {
assert.match(migration, /inspection_act_deadline_events/);
assert.match(migration, /append_only/);
assert.match(service, /ACT_RESPONSE_DEADLINE_SET/);
});
test('F1.2 preserves ambiguous historical finding deadlines instead of collapsing them', () => {
assert.match(migration, /HAVING COUNT\(DISTINCT correction_due_on\) = 1/);
});
test('F1.2 stores company responses and protected PDF metadata at Act level', () => {
assert.match(migration, /inspection_act_company_responses/);
assert.match(service, /%PDF-/);
assert.match(controller, /inspection-acts\/:actId\/administration/);
});
test('F1.2 deadline and company response DTOs reject invalid office input', async () => {
const deadline = plainToInstance(SetActResponseDeadlineDto, { responseDueOn: 'bad', reason: 'x' });
assert.ok((await validate(deadline)).length >= 2);
const response = plainToInstance(CreateActCompanyResponseDto, { receivedOn: 'bad', contactEmail: 'bad' });
assert.ok((await validate(response)).length >= 2);
});
test('F1.2 derives administrative queues from Acts instead of replacing finding history', () => {
assert.match(service, /WAITING_RESPONSE/);
assert.match(service, /COMMITMENT_OVERDUE/);
assert.match(service, /inspection_findings/);
});
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const service = readFileSync('src/act-administration/field-briefing.service.ts', 'utf8');
const controller = readFileSync('src/act-administration/field-briefing.controller.ts', 'utf8');
const moduleSource = readFileSync('src/act-administration/act-administration.module.ts', 'utf8');
test('F1.3 agrupa pendientes de campo por Acta y Hallazgo', () => {
assert.match(service, /inspection_act_deadline_events/);
assert.match(service, /inspection_act_company_responses/);
assert.match(service, /source_visit\.operational_area_id = \$2::uuid/);
assert.match(service, /source_visit\.operator_company_id = \$3::uuid/);
assert.match(service, /ACT_RESPONSE_OVERDUE/);
assert.match(service, /COMPANY_COMMITMENT_OVERDUE/);
assert.match(service, /VERIFICATION_PENDING/);
assert.match(service, /findings: \[\]/);
});
test('F1.3 expone el briefing para una inspección planificada', () => {
assert.match(controller, /inspection-visits\/:visitId\/field-briefing/);
assert.match(controller, /RequirePermissions\('inspections\.read'\)/);
assert.match(moduleSource, /FieldBriefingController/);
assert.match(moduleSource, /FieldBriefingService/);
});
test('F1.3 no usa el plazo del Hallazgo como plazo administrativo', () => {
assert.doesNotMatch(service, /correction_due_on/);
assert.match(service, /deadline\.response_due_on/);
});
+11
View File
@@ -0,0 +1,11 @@
# F1.3 · Planificación inteligente de pendientes
Objetivo: al planificar una inspección para un Área/Yacimiento + Operadora, generar un paquete de campo con pendientes administrativos y operativos provenientes de Actas anteriores del mismo contexto.
Reglas:
- La unidad administrativa es el Acta.
- Los hallazgos se muestran dentro de su Acta de origen.
- El checklist de planificación incluye Actas con respuesta vencida o por vencer, respuestas de empresa con fecha comprometida de regularización, y hallazgos con control/verificación pendiente.
- El inspector puede ver antecedentes antes de salir y en la APK.
- La planificación no modifica ni cierra Actas/hallazgos previos.
- La verificación en campo se registra append-only.
+24 -9
View File
@@ -16,6 +16,7 @@ PREV_SHA=""
TARGET_SHA="" TARGET_SHA=""
EXPECTED_API_VERSION="" EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION="" EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP" cd "$APP"
export GIT_SSH_COMMAND="ssh -i $KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" export GIT_SSH_COMMAND="ssh -i $KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
@@ -52,6 +53,7 @@ publish_status() {
echo "current_sha=$current" echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}" echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}" echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}" echo "backup=${BACKUP:-unknown}"
} > "$status_file" } > "$status_file"
@@ -90,21 +92,32 @@ rollback() {
echo "============================================================" echo "============================================================"
cd "$APP" cd "$APP"
if [ -n "${PREV_SHA:-}" ]; then if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi fi
echo echo
echo "Estado tras rollback:" echo "Estado actual:"
docker compose ps -a </dev/null || true docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo echo
echo "Últimos logs:" if [ -d "$BACKUP" ]; then
docker compose logs --tail=160 api web </dev/null || true echo "Backup PRE disponible en: $BACKUP"
echo echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
echo "Backup PRE disponible en: $BACKUP" else
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta." echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc" exit "$rc"
} }
trap rollback ERR trap rollback ERR
@@ -119,8 +132,9 @@ for cmd in git docker curl tar node; do
done done
[ -f "$KEY" ] || { echo "ERROR: falta deploy key $KEY"; false; } [ -f "$KEY" ] || { echo "ERROR: falta deploy key $KEY"; false; }
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; } [ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
git config --global --add safe.directory "$APP" >/dev/null 2>&1 || true git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción." echo "ERROR: hay cambios locales versionados en producción."
@@ -148,7 +162,7 @@ if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
fi fi
PHASE="candidate-preflight" PHASE="candidate-preflight"
mkdir -p "$STAGE" rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")" EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
@@ -204,6 +218,7 @@ PHASE="fast-forward"
echo echo
echo "========== FAST-FORWARD ==========" echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA" git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF" git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build" PHASE="build"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.19.6-4", "version": "0.20.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.19.6-4", "version": "0.20.0-2",
"dependencies": { "dependencies": {
"maplibre-gl": "^6.0.0", "maplibre-gl": "^6.0.0",
"react": "^19.0.0", "react": "^19.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.19.6-4", "version": "0.20.0-4",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+5 -1
View File
@@ -32,6 +32,9 @@ import { ActsPage } from '../pages/ActsPage';
import { ReportsPage } from '../pages/ReportsPage'; import { ReportsPage } from '../pages/ReportsPage';
import { ReportDetailPage } from '../pages/ReportDetailPage'; import { ReportDetailPage } from '../pages/ReportDetailPage';
import { VerificationPlanningPage } from '../pages/VerificationPlanningPage'; import { VerificationPlanningPage } from '../pages/VerificationPlanningPage';
import { ActAdministrationPage } from '../pages/ActAdministrationPage';
import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage';
import { FieldBriefingsPage } from '../pages/FieldBriefingsPage';
const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage }))); const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage })));
@@ -63,6 +66,7 @@ export function App() {
<Route element={<PermissionRoute permission="inspections.read" />}> <Route element={<PermissionRoute permission="inspections.read" />}>
<Route path="/inspecciones" element={<InspectionVisitsPage />} /> <Route path="/inspecciones" element={<InspectionVisitsPage />} />
<Route path="/inspecciones/:id" element={<InspectionVisitEditorPage />} /> <Route path="/inspecciones/:id" element={<InspectionVisitEditorPage />} />
<Route path="/preparacion-campo" element={<FieldBriefingsPage />} />
</Route> </Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorPage />} /></Route> <Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}> <Route element={<PermissionRoute permission="inspection_acts.read" />}>
@@ -71,7 +75,7 @@ export function App() {
</Route> </Route>
<Route element={<PermissionRoute permission="inspection_findings.read" />}><Route path="/hallazgos" element={<FindingsPage />} /><Route path="/hallazgos/:id" element={<FindingDetailPage />} /></Route> <Route element={<PermissionRoute permission="inspection_findings.read" />}><Route path="/hallazgos" element={<FindingsPage />} /><Route path="/hallazgos/:id" element={<FindingDetailPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_verifications.plan" />}><Route path="/hallazgos/planificacion" element={<VerificationPlanningPage />} /></Route> <Route element={<PermissionRoute permission="inspection_verifications.plan" />}><Route path="/hallazgos/planificacion" element={<VerificationPlanningPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}><Route path="/actas" element={<ActsPage />} /></Route> <Route element={<PermissionRoute permission="inspection_acts.read" />}><Route path="/actas" element={<ActsPage />} /><Route path="/seguimiento-actas" element={<ActAdministrationPage />} /><Route path="/seguimiento-actas/:actId" element={<ActAdministrationDetailPage />} /></Route>
<Route element={<PermissionRoute permission="inspection_reports.read" />}><Route path="/informes" element={<ReportsPage />} /><Route path="/informes/:id" element={<ReportDetailPage />} /></Route> <Route element={<PermissionRoute permission="inspection_reports.read" />}><Route path="/informes" element={<ReportsPage />} /><Route path="/informes/:id" element={<ReportDetailPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read_history" />}><Route path="/historial" element={<HistoryPage />} /></Route> <Route element={<PermissionRoute permission="assets.read_history" />}><Route path="/historial" element={<HistoryPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read_temporal" />}><Route path="/consulta-temporal" element={<TemporalAssetsPage />} /></Route> <Route element={<PermissionRoute permission="assets.read_temporal" />}><Route path="/consulta-temporal" element={<TemporalAssetsPage />} /></Route>
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.19.6-4'; export const APP_VERSION = '0.20.0-4';
export const APP_PHASE = 'Fase D5.6.4 · Inspecciones simples y combobox buscables'; export const APP_PHASE = 'Fase F1.4 · Briefing contextual antes de salir';
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import type { InspectionVisit } from '../../lib/api';
import { formatDate, formatDateOnly } from '../../lib/format';
import {
getInspectionFieldBriefing,
type FieldBriefing,
type FieldBriefingActState,
} from './fieldBriefingApi';
function stateLabel(state: FieldBriefingActState): string {
if (state === 'ACT_RESPONSE_OVERDUE') return 'Respuesta del Acta vencida';
if (state === 'ACT_RESPONSE_DUE_SOON') return 'Respuesta próxima a vencer';
if (state === 'COMPANY_COMMITMENT_OVERDUE') return 'Compromiso de empresa vencido';
if (state === 'VERIFICATION_PENDING') return 'Verificación pendiente';
if (state === 'RESPONSE_RECEIVED') return 'Respuesta recibida';
return 'Esperando respuesta';
}
function stateClass(state: FieldBriefingActState): string {
if (state === 'ACT_RESPONSE_OVERDUE' || state === 'COMPANY_COMMITMENT_OVERDUE') return 'danger';
if (state === 'ACT_RESPONSE_DUE_SOON' || state === 'VERIFICATION_PENDING') return 'warning';
return 'pending';
}
export function FieldBriefingPanel({ visit }: { visit: InspectionVisit }) {
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
setLoading(true);
setError('');
getInspectionFieldBriefing(visit.id)
.then((value) => { if (active) setBriefing(value); })
.catch((requestError) => { if (active) setError(errorMessage(requestError)); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; };
}, [visit.id, visit.updatedAt]);
return <section className="panel inspection-checklist-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">ANTES DE CONFIRMAR LA SALIDA</span>
<h2>Pendientes para revisar en campo</h2>
<p className="section-copy">Actas anteriores del mismo Área y Operadora que requieren atención durante esta inspección. El plazo administrativo pertenece al Acta y los hallazgos se mantienen dentro de su expediente de origen.</p>
</div>
<div className="form-actions">
<Link className="button secondary" to="/preparacion-campo"><Icon name="clipboard" />Vista imprimible</Link>
</div>
</div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Preparando pendientes de campo…" /> : briefing && <>
<div className="inspection-checklist-metrics">
<div><strong>{briefing.summary.acts}</strong><span>actas con pendientes</span></div>
<div><strong>{briefing.summary.findings}</strong><span>hallazgos a revisar</span></div>
<div><strong>{briefing.summary.inventoryItems}</strong><span>registros involucrados</span></div>
<div><strong>{briefing.summary.responseOverdue}</strong><span>respuestas vencidas</span></div>
<div><strong>{briefing.summary.commitmentOverdue}</strong><span>compromisos vencidos</span></div>
<div><strong>{briefing.summary.verificationPending}</strong><span>verificaciones pendientes</span></div>
</div>
<div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Contexto de salida:</strong> {briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · {formatDateOnly(briefing.plannedOn)}.</p>
</div>
{briefing.acts.length === 0
? <EmptyState title="Sin pendientes anteriores" text="No hay Actas anteriores con acciones de campo pendientes para este Área y Operadora." />
: <div>{briefing.acts.map((act) => <div className="table-panel" key={act.actId}>
<div className="table-summary">
<div><strong>{act.actCode}</strong><span>{formatDate(act.occurredAt)} · {act.findings.length} hallazgo{act.findings.length === 1 ? '' : 's'}</span></div>
<span className={`status-badge ${stateClass(act.adminState)}`}>{stateLabel(act.adminState)}</span>
</div>
<div className="table-scroll"><table>
<thead><tr><th>Hallazgo</th><th>Inventario</th><th>Gravedad</th><th>Control</th><th /></tr></thead>
<tbody>{act.findings.map((finding) => <tr key={finding.id}>
<td><Link className="history-asset-link" to={`/hallazgos/${finding.id}`}><strong>{finding.title}</strong><small>{finding.code} · {finding.status}</small></Link></td>
<td><Link className="history-asset-link" to={`/inventarios/${finding.asset.id}`}><strong>{finding.asset.name}</strong><small>{finding.asset.code} · {finding.asset.typeName}</small></Link></td>
<td>{finding.severity ?? '—'}</td>
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'A definir'}</td>
<td className="action-cell"><Link className="button secondary compact" to={`/seguimiento-actas/${act.actId}`}>Ver Acta</Link></td>
</tr>)}</tbody>
</table></div>
<div className="table-summary">
<span>Plazo empresa: <strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'sin definir'}</strong></span>
<span>Respuesta: <strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'pendiente'}</strong></span>
<span>Compromiso: <strong>{act.committedCorrectionOn ? formatDateOnly(act.committedCorrectionOn) : '—'}</strong></span>
</div>
</div>)}</div>}
</>}
</section>;
}
@@ -6,6 +6,7 @@ import { Icon } from '../../components/Icon';
import { listInspectionActs } from '../../lib/api'; import { listInspectionActs } from '../../lib/api';
import type { InspectionActListItem, InspectionVisit } from '../../lib/api'; import type { InspectionActListItem, InspectionVisit } from '../../lib/api';
import { formatDate } from '../../lib/format'; import { formatDate } from '../../lib/format';
import { FieldBriefingPanel } from './FieldBriefingPanel';
import { inspectionActStatusClass, inspectionActStatusLabel } from './inspectionActPresentation'; import { inspectionActStatusClass, inspectionActStatusLabel } from './inspectionActPresentation';
export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) { export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
@@ -25,17 +26,18 @@ export function InspectionActsPanel({ visit }: { visit: InspectionVisit }) {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [canRead, visit.id]); }, [canRead, visit.id]);
if (!canRead) return null; return <>
<FieldBriefingPanel visit={visit} />
return <section className="inspection-acts-panel"> {canRead && <section className="inspection-acts-panel">
<div className="panel-heading"> <div className="panel-heading">
<div><span className="eyebrow">ACTA DE LA VISITA</span><h2>Acta única</h2><p className="section-copy">Cada visita genera una única acta, con numeración anual oficial y versiones inmutables.</p></div> <div><span className="eyebrow">ACTAS DE LA INSPECCIÓN</span><h2>Actas generadas</h2><p className="section-copy">Una inspección puede generar múltiples actas. Cada acta agrupa sus propios hallazgos y tendrá un único informe.</p></div>
<span className="status-badge pending">Sólo lectura</span> <span className="status-badge pending">Sólo lectura</span>
</div> </div>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>El acta se crea en la APK.</strong> El dashboard permite consultar el acta, sus hallazgos, firmas e informe después de la sincronización.</p></div> <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Las actas se crean en la APK.</strong> El dashboard permite consultar cada acta, sus hallazgos, firmas e informe después de la sincronización.</p></div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0 {loading ? <LoadingBlock label="Cargando actas…" /> : acts.length === 0
? <EmptyState title="Sin acta sincronizada" text={visit.status === 'IN_PROGRESS' ? 'El inspector debe crearla desde la APK.' : 'Aparecerá aquí cuando el inspector la genere desde la APK.'} /> ? <EmptyState title="Sin actas sincronizadas" text={visit.status === 'IN_PROGRESS' ? 'El inspector puede crear la primera acta desde la APK.' : 'Aparecerán aquí a medida que el inspector genere actas desde la APK.'} />
: <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Acta de inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div></div>} : <div className="table-panel inspection-acts-table"><div className="table-summary"><strong>Actas de la inspección</strong><span>Numeración oficial global por año</span></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Estado</th><th>Fecha</th><th>Registros</th><th>Hallazgos</th><th>Versión</th><th /></tr></thead><tbody>{acts.map((act) => <tr key={act.id}><td><div className="asset-cell"><span className="asset-symbol"><Icon name="clipboard" size={17} /></span><div><strong>{act.title}</strong><small>{act.code}</small></div></div></td><td><span className={`status-badge ${inspectionActStatusClass(act.status)}`}>{inspectionActStatusLabel(act.status)}</span></td><td>{formatDate(act.occurredAt)}</td><td>{act.assetCount}</td><td>{act.findingCount}</td><td>v{act.currentVersion}</td><td className="action-cell"><Link className="icon-button" to={`/inspecciones/actas/${act.id}`} aria-label={`Abrir ${act.code}`}><Icon name="chevron" /></Link></td></tr>)}</tbody></table></div></div>}
</section>; </section>}
</>;
} }
@@ -62,7 +62,7 @@ export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome); const constanciasCompletas = inspectorSignatures.length > 0 && Boolean(companyOutcome);
return <section className="panel inspection-closure-panel"> return <section className="panel inspection-closure-panel">
<div className="panel-heading"><div><span className="eyebrow">CIERRE DE LA INSPECCIÓN · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">La preparación, las firmas y el cierre son operaciones exclusivas de la APK para inspectores.</p></div><span className={`status-badge large ${act.status === 'CLOSED' ? 'active' : act.status === 'READY' ? 'observed' : 'pending'}`}>{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}</span></div> <div className="panel-heading"><div><span className="eyebrow">CIERRE DEL ACTA · SÓLO LECTURA</span><h2>Responsable, firmas y sellado</h2><p className="section-copy">La preparación y firma del acta se realizan desde la APK. La inspección puede continuar con otras actas.</p></div><span className={`status-badge large ${act.status === 'CLOSED' ? 'active' : act.status === 'READY' ? 'observed' : 'pending'}`}>{act.status === 'CLOSED' ? 'Cierre sellado' : act.status === 'READY' ? 'Esperando firmas' : 'Pendiente en APK'}</span></div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Operación exclusiva en APK.</strong> El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.</p></div> <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Operación exclusiva en APK.</strong> El dashboard muestra las constancias sincronizadas, pero no permite identificar al responsable, firmar, reabrir ni cerrar el acta.</p></div>
@@ -78,8 +78,8 @@ export function InspectionClosurePanel({ act }: { act: InspectionAct }) {
{closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO CONGELADO</span><strong>{closure.closure.schemaVersion}</strong><small>Preparado {formatDate(closure.closure.preparedAt)}</small></div><code>{closure.closure.preparedSha256}</code></div>} {closure.closure?.isCurrent && <div className="closure-hash-card"><div><span className="eyebrow">CONTENIDO CONGELADO</span><strong>{closure.closure.schemaVersion}</strong><small>Preparado {formatDate(closure.closure.preparedAt)}</small></div><code>{closure.closure.preparedSha256}</code></div>}
{closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button> : <p>{signature.reason}</p>}</article>)}</div>} {closure.signatures.length > 0 && <div className="signature-records">{closure.signatures.map((signature) => <article key={signature.id}><div><span className={`status-badge ${signature.status === 'SIGNED' ? 'active' : 'observed'}`}>{signatureStatusLabel(signature.status)}</span><strong>{signature.signerName}</strong><small>{signerTypeLabel(signature.signerType)} · {formatDate(signature.createdAt)}</small></div><code title={signature.signaturePayloadSha256}>{signature.signaturePayloadSha256}</code>{signature.status === 'SIGNED' ? <><button type="button" className="button secondary" onClick={() => viewSignature(signature)}>Ver firma</button>{signature.signerType === 'COMPANY_RESPONSIBLE' && <p><strong>{signature.companyManifestation === 'DISSENT' ? 'Firma en disidencia' : 'Firma en conformidad'}</strong>{signature.companyStatement ? ` · ${signature.companyStatement}` : ''}</p>}</> : <p>{signature.reason}</p>}</article>)}</div>}
{act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA Y VISITA CERRADAS</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>Los hallazgos continúan abiertos para la respuesta de la empresa y el próximo control.</p><code>{closure.closure.finalSha256}</code></div></div>} {act.status === 'CLOSED' && closure.closure?.finalSha256 && <div className="closed-seal"><Icon name="check" /><div><span className="eyebrow">ACTA CERRADA</span><strong>{formatDate(closure.closure.serverClosedAt)}</strong><p>El acta quedó sellada. La inspección y las demás actas tienen un ciclo independiente.</p><code>{closure.closure.finalSha256}</code></div></div>}
</section>; </section>;
} }
@@ -0,0 +1,56 @@
import { apiRequest } from '../../lib/api';
export type FieldBriefingActState =
| 'ACT_RESPONSE_OVERDUE'
| 'ACT_RESPONSE_DUE_SOON'
| 'WAITING_RESPONSE'
| 'COMPANY_COMMITMENT_OVERDUE'
| 'VERIFICATION_PENDING'
| 'RESPONSE_RECEIVED';
export interface FieldBriefingFinding {
id: string;
code: string;
title: string;
status: string;
severity: number | null;
nextControlOn: string | null;
asset: { id: string; code: string; name: string; typeName: string };
}
export interface FieldBriefingAct {
actId: string;
actCode: string;
occurredAt: string;
adminState: FieldBriefingActState;
responseDueOn: string | null;
responseReceivedOn: string | null;
committedCorrectionOn: string | null;
latestResponseId: string | null;
findings: FieldBriefingFinding[];
}
export interface FieldBriefing {
inspection: {
id: string;
code: string;
status: string;
plannedStartAt: string | null;
area: { id: string; code: string; name: string } | null;
operatorCompany: { id: string; code: string; name: string } | null;
};
plannedOn: string;
summary: {
acts: number;
findings: number;
inventoryItems: number;
responseOverdue: number;
commitmentOverdue: number;
verificationPending: number;
};
acts: FieldBriefingAct[];
}
export function getInspectionFieldBriefing(visitId: string) {
return apiRequest<FieldBriefing>(`/inspection-visits/${visitId}/field-briefing`);
}
+2
View File
@@ -16,9 +16,11 @@ interface NavItem {
const operational: NavItem[] = [ const operational: NavItem[] = [
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' }, { to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' }, { to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
{ to: '/preparacion-campo', label: 'Preparación de campo', icon: 'clipboard', permission: 'inspections.read' },
]; ];
const followUp: NavItem[] = [ const followUp: NavItem[] = [
{ to: '/seguimiento-actas', label: 'Seguimiento de actas', icon: 'clipboard', permission: 'inspection_acts.read' },
{ to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' }, { to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' },
]; ];
+35
View File
@@ -1129,6 +1129,8 @@ export interface InspectionActSignature {
position: string | null; position: string | null;
status: InspectionActSignatureStatus; status: InspectionActSignatureStatus;
reason: string | null; reason: string | null;
companyManifestation: 'CONFORMITY' | 'DISSENT' | null;
companyStatement: string | null;
mimeType: string | null; mimeType: string | null;
sizeBytes: number | null; sizeBytes: number | null;
imageSha256: string | null; imageSha256: string | null;
@@ -3094,3 +3096,36 @@ export function updateDocumentDeliverySettings(input:{officeEmail?:string|null;d
export function listDocumentDeliveries(){return apiRequest<{data:DocumentDeliveryItem[]}>('/document-delivery/outbox');} export function listDocumentDeliveries(){return apiRequest<{data:DocumentDeliveryItem[]}>('/document-delivery/outbox');}
export function retryDocumentDelivery(id:string){return apiRequest<DocumentDeliveryItem>(`/document-delivery/outbox/${id}/retry`,{method:'POST'});} export function retryDocumentDelivery(id:string){return apiRequest<DocumentDeliveryItem>(`/document-delivery/outbox/${id}/retry`,{method:'POST'});}
export function retryPendingDocumentDeliveries(){return apiRequest<{processed:number}>('/document-delivery/outbox/retry-pending',{method:'POST'});} export function retryPendingDocumentDeliveries(){return apiRequest<{processed:number}>('/document-delivery/outbox/retry-pending',{method:'POST'});}
export type ActAdministrationState = 'NEW' | 'WAITING_RESPONSE' | 'DUE_SOON' | 'OVERDUE' | 'RESPONSE_RECEIVED' | 'VERIFICATION_PENDING' | 'COMMITMENT_OVERDUE' | 'REGULARIZED';
export interface ActAdministrationQueueItem {
actId: string; actCode: string; actStatus: string; occurredAt: string; closedAt: string | null;
visitId: string; visitCode: string; areaId: string | null; companyId: string | null; areaName: string | null; companyName: string | null;
responseDueOn: string | null; deadlineReason: string | null; responseReceivedOn: string | null; committedCorrectionOn: string | null;
findingCount: number; openFindingCount: number; scheduledControlCount: number; adminState: ActAdministrationState;
}
export interface ActAdministrationQueuePage { data: ActAdministrationQueueItem[]; counters: Partial<Record<ActAdministrationState, number>>; meta: PageMeta }
export interface ActAdministrationDetail {
act: ActAdministrationQueueItem;
deadlines: Array<{ id:string; responseDueOn:string; reason:string; createdAt:string }>;
responses: Array<{ id:string; receivedOn:string; details:string|null; committedCorrectionOn:string|null; contactName:string|null; contactEmail:string|null; originalName:string|null; sizeBytes:number|null; sha256:string|null; createdAt:string }>;
findings: Array<{ id:string; code:string; title:string; status:string; nextControlOn:string|null }>;
}
export function listActAdministrationQueue(params: { state?: ActAdministrationState | 'ALL'; areaId?: string; companyId?: string; page?: number; pageSize?: number } = {}) {
const q = new URLSearchParams({ page: String(params.page ?? 1), pageSize: String(params.pageSize ?? 25) });
if (params.state && params.state !== 'ALL') q.set('state', params.state);
if (params.areaId) q.set('areaId', params.areaId);
if (params.companyId) q.set('companyId', params.companyId);
return apiRequest<ActAdministrationQueuePage>(`/act-administration/queue?${q}`);
}
export function getActAdministration(actId: string) { return apiRequest<ActAdministrationDetail>(`/inspection-acts/${actId}/administration`); }
export function setActResponseDeadline(actId: string, input: { responseDueOn: string; reason: string }) { return apiRequest(`/inspection-acts/${actId}/administration/deadline`, { method: 'PATCH', body: JSON.stringify(input) }); }
export function addActCompanyResponse(actId: string, input: { receivedOn: string; details?: string; committedCorrectionOn?: string; contactName?: string; contactEmail?: string; file?: File }) {
const body = new FormData(); body.append('receivedOn', input.receivedOn);
if (input.details) body.append('details', input.details); if (input.committedCorrectionOn) body.append('committedCorrectionOn', input.committedCorrectionOn);
if (input.contactName) body.append('contactName', input.contactName); if (input.contactEmail) body.append('contactEmail', input.contactEmail); if (input.file) body.append('file', input.file);
return apiRequest(`/inspection-acts/${actId}/administration/responses`, { method: 'POST', body });
}
export function actCompanyResponseContentUrl(responseId: string) { return `${API_BASE}/act-company-responses/${responseId}/content`; }
export function listActAdministrationCalendar(from?: string, to?: string) { const q=new URLSearchParams(); if(from)q.set('from',from); if(to)q.set('to',to); return apiRequest<{from:string;to:string;data:Array<{type:'RESPONSE_DUE'|'COMMITMENT_DUE';date:string;actId:string;actCode:string;areaName:string|null;companyName:string|null;state:ActAdministrationState}>}>(`/act-administration/calendar${q.size?`?${q}`:''}`); }
+1 -1
View File
@@ -76,7 +76,7 @@ const actions: Record<string, string> = {
INSPECTION_ACT_REOPENED: 'Acta devuelta a borrador', INSPECTION_ACT_REOPENED: 'Acta devuelta a borrador',
INSPECTION_ACT_SIGNATURE_RECORDED: 'Firma manuscrita incorporada al acta', INSPECTION_ACT_SIGNATURE_RECORDED: 'Firma manuscrita incorporada al acta',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED: 'Resultado de recepción empresarial registrado', INSPECTION_ACT_COMPANY_OUTCOME_RECORDED: 'Resultado de recepción empresarial registrado',
INSPECTION_ACT_CLOSED: 'Acta y visita cerradas', INSPECTION_ACT_CLOSED: 'Acta cerrada',
INSPECTION_REPORT_GENERATED: 'Informe de inspección generado', INSPECTION_REPORT_GENERATED: 'Informe de inspección generado',
INSPECTION_VERIFICATION_PLANNED: 'Visita de verificación preparada', INSPECTION_VERIFICATION_PLANNED: 'Visita de verificación preparada',
INSPECTION_FINDING_CREATED: 'Hallazgo abierto', INSPECTION_FINDING_CREATED: 'Hallazgo abierto',
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { addActCompanyResponse, actCompanyResponseContentUrl, getActAdministration, setActResponseDeadline } from '../lib/api';
import type { ActAdministrationDetail } from '../lib/api';
import { formatDate } from '../lib/format';
export function ActAdministrationDetailPage(){
const {actId=''}=useParams(); const [data,setData]=useState<ActAdministrationDetail|null>(null); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const [due,setDue]=useState(''); const [reason,setReason]=useState('Plazo administrativo otorgado a la empresa'); const [received,setReceived]=useState(new Date().toISOString().slice(0,10)); const [details,setDetails]=useState(''); const [commitment,setCommitment]=useState(''); const [file,setFile]=useState<File|undefined>();
const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]);
const deadline=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await setActResponseDeadline(actId,{responseDueOn:due,reason});setDue('');await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
const response=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await addActCompanyResponse(actId,{receivedOn:received,details:details||undefined,committedCorrectionOn:commitment||undefined,file});setDetails('');setCommitment('');setFile(undefined);await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}};
if(!data&&!error)return <LoadingBlock label="Cargando expediente administrativo…"/>;
return <section>{error&&<Alert>{error}</Alert>}{data&&<><div className="page-heading"><div><span className="eyebrow">EXPEDIENTE ADMINISTRATIVO</span><h1>{data.act.actCode}</h1><p>{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos</p></div><Link className="button secondary" to={`/inspecciones/actas/${data.act.actId}`}>Ver Acta</Link></div>
<div className="detail-grid"><div className="card"><h2>Plazo de respuesta</h2><p>El plazo aplica al conjunto completo de hallazgos del Acta.</p><form className="form-grid" onSubmit={deadline}><label><span>Vencimiento *</span><input type="date" required value={due} onChange={e=>setDue(e.target.value)}/></label><label className="full"><span>Motivo *</span><textarea required minLength={3} value={reason} onChange={e=>setReason(e.target.value)}/></label><button className="button primary" disabled={busy}>Registrar nuevo plazo</button></form>{data.deadlines.length>0&&<div className="stack-list">{data.deadlines.map(d=><div key={d.id}><strong>{formatDate(d.responseDueOn)}</strong><small>{d.reason}</small></div>)}</div>}</div>
<div className="card"><h2>Respuesta de la empresa</h2><form className="form-grid" onSubmit={response}><label><span>Recibida el *</span><input type="date" required value={received} onChange={e=>setReceived(e.target.value)}/></label><label><span>Fecha comprometida</span><input type="date" value={commitment} onChange={e=>setCommitment(e.target.value)}/></label><label className="full"><span>Detalle</span><textarea value={details} onChange={e=>setDetails(e.target.value)} placeholder="Resumen de la presentación de la empresa"/></label><label className="full"><span>PDF presentado</span><input type="file" accept="application/pdf,.pdf" onChange={e=>setFile(e.target.files?.[0])}/></label><button className="button primary" disabled={busy}>Registrar respuesta</button></form>{data.responses.length>0&&<div className="stack-list">{data.responses.map(r=><div key={r.id}><strong>{formatDate(r.receivedOn)}</strong><small>{r.details??'Sin detalle'}{r.committedCorrectionOn?` · compromiso ${formatDate(r.committedCorrectionOn)}`:''}</small>{r.originalName&&<a className="text-link" href={actCompanyResponseContentUrl(r.id)} target="_blank" rel="noreferrer">Abrir PDF</a>}</div>)}</div>}</div></div>
<div className="card"><h2>Hallazgos del Acta</h2><div className="table-scroll"><table><thead><tr><th>Código</th><th>Hallazgo</th><th>Estado</th><th>Próximo control</th></tr></thead><tbody>{data.findings.map(f=><tr key={f.id}><td><Link className="text-link" to={`/hallazgos/${f.id}`}>{f.code}</Link></td><td>{f.title}</td><td>{f.status}</td><td>{f.nextControlOn?formatDate(f.nextControlOn):'Sin programar'}</td></tr>)}</tbody></table></div></div></>}</section>;
}
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { useOperationalContext } from '../context/OperationalContext';
import { listActAdministrationQueue } from '../lib/api';
import type { ActAdministrationQueueItem, ActAdministrationState, PageMeta } from '../lib/api';
import { formatDate } from '../lib/format';
const states: Array<{value:'ALL'|ActAdministrationState;label:string}> = [
{value:'ALL',label:'Todas'}, {value:'NEW',label:'Nuevas'}, {value:'WAITING_RESPONSE',label:'Esperando respuesta'},
{value:'DUE_SOON',label:'Próximas a vencer'}, {value:'OVERDUE',label:'Vencidas'}, {value:'RESPONSE_RECEIVED',label:'Respondidas'},
{value:'VERIFICATION_PENDING',label:'A verificar'}, {value:'COMMITMENT_OVERDUE',label:'Compromiso vencido'}, {value:'REGULARIZED',label:'Regularizadas'},
];
const label=(s:ActAdministrationState)=>states.find(x=>x.value===s)?.label??s;
export function ActAdministrationPage(){
const ctx=useOperationalContext(); const [state,setState]=useState<'ALL'|ActAdministrationState>('ALL'); const [items,setItems]=useState<ActAdministrationQueueItem[]>([]); const [meta,setMeta]=useState<PageMeta>({page:1,pageSize:25,total:0,totalPages:0}); const [counters,setCounters]=useState<Partial<Record<ActAdministrationState,number>>>({}); const [loading,setLoading]=useState(true); const [error,setError]=useState('');
useEffect(()=>{setLoading(true);setError('');listActAdministrationQueue({state,areaId:ctx.areaId||undefined,companyId:ctx.companyId||undefined,page:1,pageSize:50}).then(r=>{setItems(r.data);setMeta(r.meta);setCounters(r.counters)}).catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[state,ctx.areaId,ctx.companyId]);
return <section><div className="page-heading"><div><span className="eyebrow">SEGUIMIENTO ADMINISTRATIVO</span><h1>Seguimiento de Actas</h1><p>El plazo y la respuesta de la empresa se gestionan sobre el Acta completa. Los hallazgos quedan dentro de su expediente.</p></div></div>
<div className="status-tabs">{states.map(s=><button key={s.value} className={state===s.value?'active':''} onClick={()=>setState(s.value)}>{s.label}{s.value!=='ALL'&&<small>{counters[s.value]??0}</small>}</button>)}</div>
{error&&<Alert>{error}</Alert>}{loading?<LoadingBlock label="Cargando seguimiento…"/>:items.length===0?<EmptyState title="Sin actas" text="No hay actas en este estado para el contexto seleccionado."/>:<div className="table-panel"><div className="table-summary"><strong>{meta.total} acta{meta.total===1?'':'s'}</strong></div><div className="table-scroll"><table><thead><tr><th>Acta</th><th>Área / empresa</th><th>Hallazgos</th><th>Plazo empresa</th><th>Respuesta</th><th>Estado</th><th/></tr></thead><tbody>{items.map(a=><tr key={a.actId}><td><strong>{a.actCode}</strong><small className="block-muted">{formatDate(a.occurredAt)}</small></td><td><strong>{a.areaName??'—'}</strong><small className="block-muted">{a.companyName??'—'}</small></td><td>{a.openFindingCount} abiertos / {a.findingCount}</td><td>{a.responseDueOn?formatDate(a.responseDueOn):'Sin definir'}</td><td>{a.responseReceivedOn?formatDate(a.responseReceivedOn):'Pendiente'}</td><td><span className={`status-badge ${a.adminState==='OVERDUE'||a.adminState==='COMMITMENT_OVERDUE'?'danger':'pending'}`}>{label(a.adminState)}</span></td><td><Link className="button secondary" to={`/seguimiento-actas/${a.actId}`}>Gestionar</Link></td></tr>)}</tbody></table></div></div>}</section>;
}
+265
View File
@@ -0,0 +1,265 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect';
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
import { formatDate, formatDateOnly } from '../lib/format';
type AdminState =
| 'ACT_RESPONSE_OVERDUE'
| 'ACT_RESPONSE_DUE_SOON'
| 'WAITING_RESPONSE'
| 'COMPANY_COMMITMENT_OVERDUE'
| 'VERIFICATION_PENDING'
| 'RESPONSE_RECEIVED';
interface VisitOption {
id: string;
code: string;
status: string;
plannedStartAt: string | null;
operationalArea: { name: string } | null;
operatorCompany: { name: string } | null;
}
interface BriefingFinding {
id: string;
code: string;
title: string;
status: string;
severity: number | null;
nextControlOn: string | null;
asset: {
id: string;
code: string;
name: string;
typeName: string;
};
}
interface BriefingAct {
actId: string;
actCode: string;
occurredAt: string;
adminState: AdminState;
responseDueOn: string | null;
responseReceivedOn: string | null;
committedCorrectionOn: string | null;
latestResponseId: string | null;
findings: BriefingFinding[];
}
interface FieldBriefing {
inspection: {
id: string;
code: string;
status: string;
plannedStartAt: string | null;
area: { id: string; code: string | null; name: string | null } | null;
operatorCompany: { id: string; code: string | null; name: string | null } | null;
};
plannedOn: string;
summary: {
acts: number;
findings: number;
inventoryItems: number;
responseOverdue: number;
commitmentOverdue: number;
verificationPending: number;
};
acts: BriefingAct[];
}
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = typeof payload?.message === 'string'
? payload.message
: 'No se pudo preparar el paquete de campo.';
throw new Error(message);
}
return payload as T;
}
function adminStateLabel(state: AdminState): string {
const labels: Record<AdminState, string> = {
ACT_RESPONSE_OVERDUE: 'Respuesta del Acta vencida',
ACT_RESPONSE_DUE_SOON: 'Respuesta próxima a vencer',
WAITING_RESPONSE: 'Esperando respuesta',
COMPANY_COMMITMENT_OVERDUE: 'Compromiso de empresa vencido',
VERIFICATION_PENDING: 'Verificación pendiente',
RESPONSE_RECEIVED: 'Respuesta recibida',
};
return labels[state];
}
function findingAction(finding: BriefingFinding, act: BriefingAct, plannedOn: string) {
if (finding.nextControlOn) {
if (finding.nextControlOn <= plannedOn) {
return { label: 'Revisar en campo', detail: 'Control programado o vencido', tone: 'danger' };
}
return { label: 'Próximo control', detail: `Previsto ${formatDateOnly(finding.nextControlOn)}`, tone: 'pending' };
}
if (act.adminState === 'VERIFICATION_PENDING') {
return { label: 'Revisar en campo', detail: 'Respuesta recibida sin control programado', tone: 'danger' };
}
if (act.adminState === 'COMPANY_COMMITMENT_OVERDUE') {
return { label: 'Revisar en campo', detail: 'Regularización comprometida vencida', tone: 'danger' };
}
if (act.adminState === 'RESPONSE_RECEIVED') {
return { label: 'Antecedente', detail: 'Respuesta de empresa recibida', tone: 'pending' };
}
return { label: 'Antecedente', detail: adminStateLabel(act.adminState), tone: 'pending' };
}
export function FieldBriefingsPage() {
const [visits, setVisits] = useState<VisitOption[]>([]);
const [visitId, setVisitId] = useState('');
const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
const [loadingVisits, setLoadingVisits] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoadingVisits(true);
Promise.all([
getJson<{ data: VisitOption[] }>('/api/v3/inspection-visits?status=PLANNED&page=1&pageSize=100'),
getJson<{ data: VisitOption[] }>('/api/v3/inspection-visits?status=DRAFT&page=1&pageSize=100'),
]).then(([planned, drafts]) => {
const all = [...planned.data, ...drafts.data]
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
setVisits(all);
const onlyVisit = all[0];
if (all.length === 1 && onlyVisit) setVisitId(onlyVisit.id);
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoadingVisits(false));
}, []);
useEffect(() => {
if (!visitId) {
setBriefing(null);
return;
}
setLoading(true);
setError('');
getJson<FieldBriefing>(`/api/v3/inspection-visits/${visitId}/field-briefing`)
.then(setBriefing)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setLoading(false));
}, [visitId]);
const selected = useMemo(
() => visits.find((visit) => visit.id === visitId) ?? null,
[visits, visitId],
);
return <section className="field-briefing-page">
<div className="page-heading">
<div>
<span className="eyebrow">ANTES DE SALIR A CAMPO</span>
<h1>Preparación de campo</h1>
<p>Seleccioná la inspección y revisá los pendientes de Actas anteriores del mismo Área/Yacimiento y Operadora.</p>
</div>
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
Imprimir preparación
</button>}
</div>
<div className="form-card no-print">
<label>Inspección planificada</label>
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
value={visitId}
onChange={(event) => setVisitId(event.target.value)}
searchPlaceholder="Buscar por código, Área u Operadora…"
>
<option value="">Seleccionar inspección</option>
{visits.map((visit) => <option key={visit.id} value={visit.id}>
{visit.code} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
</option>)}
</SearchableSelect>}
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
</div>
{error && <Alert>{error}</Alert>}
{loading && <LoadingBlock label="Armando paquete de campo…" />}
{!loading && !briefing && !error && <EmptyState
title="Seleccioná una inspección"
text="El sistema reunirá automáticamente los pendientes de inspecciones anteriores del mismo contexto."
/>}
{!loading && briefing && <>
<div className="page-heading compact">
<div>
<span className="eyebrow">{briefing.inspection.code}</span>
<h2>{briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'}</h2>
<p>Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
</div>
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
Abrir planificación
</Link>
</div>
<div className="status-tabs briefing-summary">
<span>Actas <strong>{briefing.summary.acts}</strong></span>
<span>Hallazgos <strong>{briefing.summary.findings}</strong></span>
<span>Inventario <strong>{briefing.summary.inventoryItems}</strong></span>
<span>Respuestas vencidas <strong>{briefing.summary.responseOverdue}</strong></span>
<span>Compromisos vencidos <strong>{briefing.summary.commitmentOverdue}</strong></span>
<span>Verificaciones pendientes <strong>{briefing.summary.verificationPending}</strong></span>
</div>
{briefing.acts.length === 0 ? <EmptyState
title="Sin pendientes anteriores"
text="No hay Actas anteriores con hallazgos abiertos que requieran atención para esta inspección."
/> : briefing.acts.map((act) => <article className="table-panel" key={act.actId}>
<div className="table-summary">
<div>
<strong>{act.actCode}</strong>
<small className="block-muted">{formatDate(act.occurredAt)} · {adminStateLabel(act.adminState)}</small>
</div>
<div>
<small>Plazo del Acta</small>
<strong>{act.responseDueOn ? formatDateOnly(act.responseDueOn) : 'Sin definir'}</strong>
</div>
<div>
<small>Respuesta empresa</small>
<strong>{act.responseReceivedOn ? formatDateOnly(act.responseReceivedOn) : 'Pendiente'}</strong>
</div>
<div>
<small>Compromiso empresa</small>
<strong>{act.committedCorrectionOn ? formatDateOnly(act.committedCorrectionOn) : 'Sin fecha'}</strong>
</div>
</div>
<div className="table-scroll">
<table>
<thead><tr><th>Qué hacer</th><th>Hallazgo</th><th>Inventario</th><th>Control</th></tr></thead>
<tbody>{act.findings.map((finding) => {
const action = findingAction(finding, act, briefing.plannedOn);
return <tr key={finding.id}>
<td>
<span className={`status-badge ${action.tone}`}>{action.label}</span>
<small className="block-muted">{action.detail}</small>
</td>
<td>
<strong>{finding.code}</strong>
<small className="block-muted">{finding.title}</small>
</td>
<td>
<strong>{finding.asset.code}</strong>
<small className="block-muted">{finding.asset.name} · {finding.asset.typeName}</small>
</td>
<td>{finding.nextControlOn ? formatDateOnly(finding.nextControlOn) : 'Sin fecha'}</td>
</tr>;
})}</tbody>
</table>
</div>
</article>)}
</>}
</section>;
}
@@ -404,7 +404,7 @@ export function InspectionVisitEditorPage() {
{visit && <div className="survey-status-actions panel"><div><strong>Flujo de la inspección</strong><p>Oficina planifica y asigna. El inspector responsable inicia la inspección desde la APK; toda la ejecución de campo sigue siendo exclusiva del dispositivo móvil.</p></div><div>{canManage && visit.status === 'DRAFT' && <button type="button" className="button secondary" onClick={() => void changeStatus('PLANNED')} disabled={busy}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" onClick={() => void changeStatus('DRAFT')} disabled={busy}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" onClick={() => void changeStatus('CANCELLED')} disabled={busy}>Cancelar planificación</button>}</div></div>} {visit && <div className="survey-status-actions panel"><div><strong>Flujo de la inspección</strong><p>Oficina planifica y asigna. El inspector responsable inicia la inspección desde la APK; toda la ejecución de campo sigue siendo exclusiva del dispositivo móvil.</p></div><div>{canManage && visit.status === 'DRAFT' && <button type="button" className="button secondary" onClick={() => void changeStatus('PLANNED')} disabled={busy}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" onClick={() => void changeStatus('DRAFT')} disabled={busy}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" onClick={() => void changeStatus('CANCELLED')} disabled={busy}>Cancelar planificación</button>}</div></div>}
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El inspector asignado debe iniciar la inspección desde la APK. El dashboard no dispone de acción de inicio.</p></div>} {visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El inspector asignado debe iniciar la inspección desde la APK. El dashboard no dispone de acción de inicio.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección iniciada {formatDate(visit.actualStartedAt)}.</strong> El acta, hallazgos, evidencias y cierre se registran desde la APK; aquí se consultan.</p></div>} {visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección iniciada {formatDate(visit.actualStartedAt)}.</strong> Las actas, hallazgos, evidencias y el cierre de la inspección se registran desde la APK; aquí se consultan.</p></div>}
{visit?.status === 'CANCELLED' && <Alert>Cancelada: {visit.cancellationReason}</Alert>} {visit?.status === 'CANCELLED' && <Alert>Cancelada: {visit.cancellationReason}</Alert>}
</section>; </section>;
} }
+1 -1
View File
@@ -100,7 +100,7 @@ export function InspectionVisitsPage() {
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)} {statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
</nav> </nav>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un solo ciclo.</strong> La planificación, ejecución, Acta, Hallazgos e Informe pertenecen a la misma inspección. El inicio en campo sigue siendo exclusivo de la APK.</p></div> <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un recorrido, varias actas.</strong> La inspección representa la salida a un Área/Operadora y puede generar múltiples actas durante el recorrido. El inicio y cierre en campo siguen siendo exclusivos de la APK.</p></div>
<form className="toolbar survey-toolbar" onSubmit={applySearch}> <form className="toolbar survey-toolbar" onSubmit={applySearch}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label> <label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>