Compare commits

...
Author SHA1 Message Date
admin bdaccb66d0 F2.4 CI: compilar APK 0.10.3 2026-09-07 08:51:33 -03:00
admin f5f6afe797 F2.4 Android: versionar APK 0.10.3 2026-09-07 08:51:16 -03:00
admin c524d2ade6 F2.4 Android: capturar foto GPS por Hallazgo 2026-09-07 08:50:46 -03:00
admin a2ed3cf091 F2.4 Android: gestionar evidencias por Hallazgo 2026-09-07 08:49:30 -03:00
admin c4c6dd6c24 F2.4 Android: agregar evidencias fotográficas al Hallazgo 2026-09-07 08:48:36 -03:00
admin cb69cea3a9 F2.3: alinear versión de paquete API 2026-09-07 08:40:50 -03:00
admin 721a189b4c F2.3: versionar API 0.23.0-1 2026-09-07 08:40:20 -03:00
admin 6895edce20 F2.3: probar contrato de Hallazgos desde Inventario 2026-09-07 08:39:59 -03:00
admin 97f5fbdd9e F2.3 CI: conservar diagnóstico de build 2026-09-07 08:38:17 -03:00
admin 1a89024117 F2.3: estabilizar declaraciones públicas del controller 2026-09-07 08:37:59 -03:00
admin f9fd66d913 F2.3 CI: conservar diagnóstico de tests 2026-09-07 08:36:30 -03:00
admin 2143b9454f F2.3 CI: conservar diagnóstico de TypeScript 2026-09-07 08:35:22 -03:00
admin d765f7d65e F2.3: preservar validación runtime del DTO móvil 2026-09-07 08:34:03 -03:00
admin c5dde72b7f F2.3 CI: validar API y contrato de Hallazgos de campo 2026-09-07 08:32:07 -03:00
admin 3988918023 F2.3 CI: compilar APK 0.10.2 2026-09-07 08:31:51 -03:00
admin acb2488025 F2.3 Android: versionar APK 0.10.2 2026-09-07 08:31:33 -03:00
admin a49171fe31 F2.3 Android: enrutar Hallazgo después del desbloqueo 2026-09-07 08:31:11 -03:00
admin 1f5040f3c5 F2.3 Android: crear pantalla de Hallazgo desde Inventario 2026-09-07 08:30:39 -03:00
admin 958a4d134d F2.3 Android: integrar flujo de Hallazgos al ViewModel 2026-09-07 08:29:52 -03:00
admin e7dd2a7284 F2.3 Android: agregar contrato móvil de Hallazgos 2026-09-07 08:29:13 -03:00
admin dd4199cd91 F2.3: integrar Hallazgos de campo en visitas 2026-09-07 08:26:51 -03:00
admin 299a637901 F2.3: exportar catálogo y servicio de Hallazgos 2026-09-07 08:26:40 -03:00
admin 1334611ebc F2.3: reforzar puerta servidor para Hallazgos de campo 2026-09-07 08:26:28 -03:00
admin f82de44ded F2.3: exponer hallazgos de campo por Inventario 2026-09-07 08:25:28 -03:00
admin dd8ea8539b F2.3: crear servicio de hallazgos desde Inventario 2026-09-07 08:25:14 -03:00
admin 1c5efa8806 F2.3: agregar DTO de hallazgo de campo 2026-09-07 08:24:54 -03:00
15 changed files with 1338 additions and 10 deletions
+3 -1
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
paths:
- 'android-app/**'
- '.github/workflows/android.yml'
@@ -53,7 +55,7 @@ jobs:
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F2.2.1-0.10.1-debug
name: DH-Inspeccion-F2.4-0.10.3-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14
+62
View File
@@ -0,0 +1,62 @@
name: F2.3 Field Finding CI
on:
push:
branches:
- 'feature/f2-3*'
paths:
- 'api-v3/**'
- '.github/workflows/f2-3-ci.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: api-v3
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Node 24
uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: Install
run: npm ci
- name: Typecheck
run: npm run typecheck 2>&1 | tee typecheck.log
- name: Upload TypeScript diagnostic
if: always()
uses: actions/upload-artifact@v4
with:
name: f2-3-typecheck-diagnostic
path: api-v3/typecheck.log
if-no-files-found: warn
retention-days: 3
- name: Tests
run: npm test 2>&1 | tee test.log
- name: Upload test diagnostic
if: always()
uses: actions/upload-artifact@v4
with:
name: f2-3-test-diagnostic
path: api-v3/test.log
if-no-files-found: warn
retention-days: 3
- name: Build
run: npm run build 2>&1 | tee build.log
- name: Upload build diagnostic
if: always()
uses: actions/upload-artifact@v4
with:
name: f2-3-build-diagnostic
path: api-v3/build.log
if-no-files-found: warn
retention-days: 3
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 15
versionName = "0.10.1"
versionCode = 17
versionName = "0.10.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -6,9 +6,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.FieldAssetDetail
import com.korexlabs.dhinspeccion.data.FieldFindingEvidence
import com.korexlabs.dhinspeccion.data.FieldFindingItem
import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.StoredSession
@@ -20,6 +25,7 @@ import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
@@ -42,6 +48,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set
var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null)
private set
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
private set
var fieldFindingEvidence: Map<String, List<FieldFindingEvidence>> by mutableStateOf(emptyMap())
private set
init {
if (session != null) loadVisits()
}
@@ -72,6 +85,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
}
}
@@ -86,6 +100,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
}
fun closeVisitView() {
@@ -93,6 +108,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
loadVisits()
}
@@ -125,6 +141,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección."
inventory = repository.fieldInventory(visitId, null, null).data
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(visitId, item.id)
}
}
}
@@ -185,11 +204,124 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
if (response.capture.readyForFinding) {
loadFindingOptionsInternal(visitId, asset.id)
}
}
}
fun openFindingForSelected() {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
launchBusy { loadFindingOptionsInternal(visitId, assetId) }
}
fun createFieldFinding(
catalogItemId: String?,
customTitle: String?,
customLegalBasis: String?,
description: String,
severity: Int?,
correctionDueOn: String?,
) {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
if (description.isBlank()) {
error = "Describí el Hallazgo antes de guardarlo."
return
}
if (catalogItemId == null && customTitle.isNullOrBlank()) {
error = "Para OTROS, indicá un título para el Hallazgo."
return
}
if (severity != null && severity !in 1..10) {
error = "La gravedad debe estar entre 1 y 10."
return
}
launchBusy {
val response = findingsRepository.create(
visitId,
assetId,
CreateFieldFindingRequest(
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
),
)
lastCreatedFinding = response.finding
notice = "Hallazgo ${response.finding.code} registrado. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true)
}
}
fun uploadFindingPhoto(
findingId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String? = null,
description: String? = null,
) {
launchBusy {
findingsRepository.uploadObservationPhoto(
findingId = findingId,
file = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
title = title,
description = description,
)
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
}
}
fun reloadFindingEvidence(findingId: String) {
launchBusy { loadEvidenceInternal(findingId) }
}
fun clearFindingFlow() {
fieldFindingOptions = null
lastCreatedFinding = null
fieldFindingEvidence = emptyMap()
error = null
}
fun clearSelectedFieldAsset() {
selectedFieldAsset = null
clearFindingState()
}
private suspend fun loadFindingOptionsInternal(
visitId: String,
assetId: String,
keepLastCreated: Boolean = false,
) {
val options = findingsRepository.options(visitId, assetId)
fieldFindingOptions = options
if (!keepLastCreated) lastCreatedFinding = null
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
for (finding in options.findings) {
loaded[finding.id] = findingsRepository.evidence(finding.id).data
}
fieldFindingEvidence = loaded
}
private suspend fun loadEvidenceInternal(findingId: String) {
fieldFindingEvidence = fieldFindingEvidence + (
findingId to findingsRepository.evidence(findingId).data
)
}
private fun clearFindingState() {
fieldFindingOptions = null
lastCreatedFinding = null
fieldFindingEvidence = emptyMap()
}
private fun launchBusy(block: suspend () -> Unit) {
@@ -0,0 +1,252 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
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 java.io.File
import java.time.Instant
data class FieldFindingAct(
val id: String,
val code: String,
val status: String,
)
data class FieldFindingCatalogItem(
val id: String,
val categoryId: String,
val code: String,
val sourceNumber: Int,
val title: String,
val legalBasis: String? = null,
val glossary: String? = null,
val suggestedSeverity: Int? = null,
val revision: Int = 1,
val categoryName: String? = null,
)
data class FieldFindingOther(
val enabled: Boolean = true,
val code: String = "OTHER",
val label: String = "OTROS",
val help: String? = null,
)
data class FieldFindingCatalog(
val typeConfigured: Boolean = false,
val configurationReason: String? = null,
val items: List<FieldFindingCatalogItem> = emptyList(),
val other: FieldFindingOther = FieldFindingOther(),
)
data class FieldFindingItem(
val id: String,
val actId: String,
val assetId: String,
val catalogItemId: String? = null,
val findingNumber: Int,
val code: String,
val status: String,
val title: String,
val description: String,
val severity: Int? = null,
val suggestedSeverity: Int? = null,
val correctionDueOn: String? = null,
)
data class FieldFindingEvidence(
val id: String,
val findingId: String,
val kind: String,
val purpose: String,
val originalName: String,
val mimeType: String,
val sizeBytes: Long,
val sha256: String,
val title: String? = null,
val description: String? = null,
val capturedAt: String? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val accuracyM: Double? = null,
val deviceLabel: String? = null,
val source: String,
val createdAt: String,
)
data class FieldFindingEvidenceListResponse(
val data: List<FieldFindingEvidence> = emptyList(),
)
data class FieldFindingOptionsResponse(
val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(),
val catalog: FieldFindingCatalog,
val findings: List<FieldFindingItem> = emptyList(),
val canAddAnother: Boolean = true,
)
data class CreateFieldFindingRequest(
val catalogItemId: String? = null,
val customTitle: String? = null,
val customLegalBasis: String? = null,
val description: String,
val severity: Int? = null,
val correctionDueOn: String? = null,
)
data class FieldFindingCreateResponse(
val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(),
val finding: FieldFindingItem,
val canAddAnother: Boolean = true,
)
private interface FieldFindingsApi {
@GET("inspection-visits/{visitId}/field-findings/{assetId}/options")
suspend fun options(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
): FieldFindingOptionsResponse
@POST("inspection-visits/{visitId}/field-findings/{assetId}")
suspend fun create(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Body request: CreateFieldFindingRequest,
): FieldFindingCreateResponse
@GET("inspection-findings/{findingId}/evidence")
suspend fun evidence(
@Header("Authorization") authorization: String,
@Path("findingId") findingId: String,
): FieldFindingEvidenceListResponse
@Multipart
@POST("inspection-findings/{findingId}/evidence")
suspend fun uploadEvidence(
@Header("Authorization") authorization: String,
@Path("findingId") findingId: String,
@Part file: MultipartBody.Part,
@Part("kind") kind: RequestBody,
@Part("purpose") purpose: RequestBody,
@Part("title") title: RequestBody?,
@Part("description") description: RequestBody?,
@Part("capturedAt") capturedAt: RequestBody,
@Part("latitude") latitude: RequestBody,
@Part("longitude") longitude: RequestBody,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
/**
* Cliente de campo para Hallazgos y sus evidencias append-only.
* Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String): FieldFindingOptionsResponse =
authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId)
}
suspend fun create(
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): FieldFindingCreateResponse = authorized { session ->
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
}
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId)
}
suspend fun uploadObservationPhoto(
findingId: String,
file: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".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
}
}
}
@@ -0,0 +1,421 @@
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.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.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 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 FindingGeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun FieldFindingScreen(model: MainViewModel) {
val options = model.fieldFindingOptions ?: return
val asset = model.selectedFieldAsset?.asset ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
var search by rememberSaveable(asset.id) { mutableStateOf("") }
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
var description by rememberSaveable(asset.id) { mutableStateOf("") }
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
val findingId = pendingPhotoFindingId
if (success && file != null && geo != null && findingId != null) {
runCatching { writeFindingExif(file, geo) }
model.uploadFindingPhoto(
findingId = findingId,
file = file,
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
title = "Evidencia fotográfica de campo",
)
}
pendingPhotoFile = null
pendingPhotoGeo = null
pendingPhotoFindingId = null
}
fun beginPhoto(findingId: String) {
scope.launch {
runCatching { currentFindingGeo(context) }
.onSuccess { geo ->
val (file, uri) = newFindingPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
pendingPhotoFindingId = findingId
takePicture.launch(uri)
}
}
}
val photoPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val camera = result[Manifest.permission.CAMERA] == true || findingHasPermission(context, Manifest.permission.CAMERA)
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || findingHasLocation(context)
val findingId = requestedFindingId
requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId)
}
fun requestPhoto(findingId: String) {
requestedFindingId = findingId
if (findingHasPermission(context, Manifest.permission.CAMERA) && findingHasLocation(context)) {
requestedFindingId = null
beginPhoto(findingId)
} else {
photoPermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
val selected = options.catalog.items.firstOrNull { it.id == selectedCatalogId }
val filtered = options.catalog.items.filter {
search.isBlank() ||
it.title.contains(search, ignoreCase = true) ||
it.code.contains(search, ignoreCase = true) ||
it.categoryName.orEmpty().contains(search, ignoreCase = true)
}
LaunchedEffect(selectedCatalogId, other) {
if (!other && selected != null && severityText.isBlank() && selected.suggestedSeverity != null) {
severityText = selected.suggestedSeverity.toString()
}
}
LaunchedEffect(model.lastCreatedFinding?.id) {
if (model.lastCreatedFinding != null) {
selectedCatalogId = null
other = false
customTitle = ""
customLegalBasis = ""
description = ""
severityText = ""
correctionDueOn = ""
search = ""
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 30.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
Text("Volver")
}
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp))
}
}
model.notice?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
Text(it, Modifier.padding(12.dp))
}
}
if (options.findings.isNotEmpty()) {
Text("Hallazgos registrados en este Inventario", fontWeight = FontWeight.Bold)
options.findings.forEach { finding ->
val evidence = model.fieldFindingEvidence[finding.id].orEmpty()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
)
evidence.take(3).forEach { item ->
Text(
"${item.title ?: item.originalName}${item.capturedAt?.let { " · ${shortFindingDate(it)}" }.orEmpty()}",
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = { requestPhoto(finding.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
}
}
}
}
HorizontalDivider()
}
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (!options.catalog.typeConfigured) {
Text(
options.catalog.configurationReason
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
color = MaterialTheme.colorScheme.secondary,
)
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar en catálogo") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filtered.forEach { item ->
val chosen = !other && selectedCatalogId == item.id
Card(
modifier = Modifier.fillMaxWidth().clickable {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
},
colors = if (chosen) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
},
) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(if (chosen) "${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedButton(
onClick = {
other = true
selectedCatalogId = null
severityText = ""
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
}
if (other) {
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
OutlinedTextField(
value = customTitle,
onValueChange = { customTitle = it },
label = { Text("Título del nuevo Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = customLegalBasis,
onValueChange = { customLegalBasis = it },
label = { Text("Base legal / normativa (opcional)") },
modifier = Modifier.fillMaxWidth(),
)
}
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
selected?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(it.title, fontWeight = FontWeight.SemiBold)
it.legalBasis?.takeIf(String::isNotBlank)?.let { basis -> Text(basis, style = MaterialTheme.typography.bodySmall) }
it.glossary?.takeIf(String::isNotBlank)?.let { glossary -> Text(glossary, style = MaterialTheme.typography.bodySmall) }
}
}
}
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Descripción del Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
)
OutlinedTextField(
value = severityText,
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
label = { Text("Gravedad 1 a 10") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
OutlinedTextField(
value = correctionDueOn,
onValueChange = { correctionDueOn = it },
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
val severity = severityText.toIntOrNull()
val choiceReady = selectedCatalogId != null || (other && customTitle.isNotBlank())
Button(
onClick = {
model.createFieldFinding(
catalogItemId = if (other) null else selectedCatalogId,
customTitle = if (other) customTitle else null,
customLegalBasis = if (other) customLegalBasis else null,
description = description,
severity = severity,
correctionDueOn = correctionDueOn,
)
},
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (model.busy) "Guardando…" else "Guardar Hallazgo")
}
model.lastCreatedFinding?.let { finding ->
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
Text("${finding.code} · ${finding.title}")
Button(
onClick = { requestPhoto(finding.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text("Tomar foto con GPS")
}
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
private fun findingHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun findingHasLocation(context: Context): Boolean =
findingHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!findingHasLocation(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(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
private fun newFindingPhoto(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_HALLAZGO_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeFindingExif(file: File, geo: FindingGeoSnapshot) {
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 shortFindingDate(value: String): String = value.replace('T', ' ').take(16)
@@ -90,6 +90,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
model.logout()
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
else -> DhApp(model)
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.22.1-1",
"version": "0.23.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -43,4 +43,4 @@
"tsx": "^4.20.6",
"typescript": "^5.9.0"
}
}
}
@@ -29,5 +29,6 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
InspectionFindingsService,
InspectionEvidenceService,
],
exports: [FindingCatalogService, InspectionFindingsService],
})
export class InspectionFindingsModule {}
@@ -0,0 +1,58 @@
import { Transform, Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
/**
* Hallazgo capturado desde la APK sobre un Inventario ya seleccionado.
* El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload.
*/
export class CreateFieldFindingDto {
@IsOptional()
@Transform(optionalText)
@IsUUID('4')
catalogItemId?: string | null;
@ValidateIf((value: CreateFieldFindingDto) => !value.catalogItemId)
@Transform(optionalText)
@IsString()
@MinLength(1)
@MaxLength(500)
customTitle?: string | null;
@IsOptional()
@Transform(optionalText)
@IsString()
@MaxLength(12000)
customLegalBasis?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(20000)
description!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(10)
severity?: number;
@IsOptional()
@Transform(optionalText)
@Matches(/^\d{4}-\d{2}-\d{2}$/)
correctionDueOn?: string | null;
}
@@ -0,0 +1,51 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Req,
} from '@nestjs/common';
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 { CreateFieldFindingDto } from './dto/create-field-finding.dto';
import { FieldFindingsService } from './field-findings.service';
@Controller('inspection-visits/:visitId/field-findings')
export class FieldFindingsController {
constructor(private readonly fieldFindings: FieldFindingsService) {}
@Get(':assetId/options')
@RequirePermissions('inspection_findings.create', 'inspections.execute')
options(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@CurrentAuth() principal: AuthPrincipal,
): Promise<unknown> {
return this.fieldFindings.options(visitId, assetId, principal);
}
@Get(':assetId')
@RequirePermissions('inspection_findings.read', 'inspections.execute')
list(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@CurrentAuth() principal: AuthPrincipal,
): Promise<unknown> {
return this.fieldFindings.list(visitId, assetId, principal);
}
@Post(':assetId')
@RequirePermissions('inspection_findings.create', 'inspections.execute')
create(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: CreateFieldFindingDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<unknown> {
return this.fieldFindings.create(visitId, assetId, dto, principal, request);
}
}
@@ -0,0 +1,278 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto';
import { FindingCatalogService } from '../inspection-findings/finding-catalog.service';
import { InspectionFindingsService } from '../inspection-findings/inspection-findings.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateFieldFindingDto } from './dto/create-field-finding.dto';
interface DraftActRow {
id: string;
code: string;
status: string;
}
interface FieldFindingGate {
context: {
inspection: { id: string; code: string; status: string };
area: { id: string; code: string; name: string };
operatorCompany: { id: string; code: string; name: string };
};
capture: {
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
readyForFinding: boolean;
};
}
@Injectable()
export class FieldFindingsService {
constructor(
private readonly dataSource: DataSource,
private readonly catalog: FindingCatalogService,
private readonly findings: InspectionFindingsService,
) {}
async options(visitId: string, assetId: string, principal: AuthPrincipal) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId);
const [catalog, findings] = await Promise.all([
this.catalog.listApplicableForAsset(assetId, {}),
this.findings.listForAct(act.id),
]);
return {
context: gate.context,
act,
capture: gate.capture,
catalog,
findings: findings.data.filter((finding) => finding.assetId === assetId),
canAddAnother: true,
};
}
async list(visitId: string, assetId: string, principal: AuthPrincipal) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId);
const findings = await this.findings.listForAct(act.id);
return {
context: gate.context,
act,
capture: gate.capture,
data: findings.data.filter((finding) => finding.assetId === assetId),
};
}
async create(
visitId: string,
assetId: string,
dto: CreateFieldFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const gate = await this.requireGate(visitId, assetId, principal);
const act = await this.requireDraftAct(visitId);
const payload: CreateInspectionFindingDto = {
assetId,
catalogItemId: dto.catalogItemId ?? null,
customTitle: dto.customTitle ?? null,
customLegalBasis: dto.customLegalBasis ?? null,
description: dto.description,
severity: dto.severity,
correctionDueOn: dto.correctionDueOn ?? null,
};
const finding = await this.findings.create(act.id, payload, principal, request);
return {
context: gate.context,
act,
capture: gate.capture,
finding,
canAddAnother: true,
};
}
private async requireGate(
visitId: string,
assetId: string,
principal: AuthPrincipal,
): Promise<FieldFindingGate> {
assertMobileInspector(principal);
const [row] = await this.dataSource.query(`
SELECT
visit.id AS "visitId",
visit.code AS "visitCode",
visit.status AS "visitStatus",
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",
asset.id AS "assetId",
asset.operational_area_id AS "assetAreaId",
asset.operator_company_id AS "assetCompanyId",
EXISTS (
SELECT 1
FROM inspection_visit_members member
WHERE member.visit_id = visit.id
AND member.user_id = $3::uuid
AND member.included = true
) OR visit.lead_inspector_user_id = $3::uuid AS assigned,
EXISTS (
SELECT 1
FROM inspection_visit_assets link
WHERE link.visit_id = visit.id
AND link.asset_id = asset.id
AND link.included = true
) AS selected,
EXISTS (
SELECT 1
FROM asset_field_discoveries discovery
WHERE discovery.visit_id = visit.id
AND discovery.asset_id = asset.id
) AS "captureRequired",
EXISTS (
SELECT 1
FROM asset_geometries geometry
WHERE geometry.asset_id = asset.id
) AS "hasGeometry",
EXISTS (
SELECT 1
FROM asset_field_capture_events event
WHERE event.visit_id = visit.id
AND event.asset_id = asset.id
AND event.event_type = 'CREATED'
) AS "creationGpsCaptured",
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events event
WHERE event.visit_id = visit.id
AND event.asset_id = asset.id
AND event.event_type = 'PHOTO'
) AS "fieldPhotoCount"
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 assets asset ON asset.id = $2::uuid
AND asset.information_status <> 'INACTIVE'
WHERE visit.id = $1::uuid
`, [visitId, assetId, principal.userId]) as Array<{
visitId: string;
visitCode: string;
visitStatus: string;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
companyId: string | null;
companyCode: string | null;
companyName: string | null;
assetId: string | null;
assetAreaId: string | null;
assetCompanyId: string | null;
assigned: boolean;
selected: boolean;
captureRequired: boolean;
hasGeometry: boolean;
creationGpsCaptured: boolean;
fieldPhotoCount: number;
}>;
if (!row) {
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
}
if (row.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FIELD_FINDING_VISIT_NOT_IN_PROGRESS',
message: 'Los hallazgos sólo pueden registrarse cuando la inspección está en curso',
});
}
if (!row.assigned) {
throw new ConflictException({
code: 'FIELD_FINDING_INSPECTOR_NOT_ASSIGNED',
message: 'El inspector no está asignado a esta inspección',
});
}
if (!row.areaId || !row.companyId || !row.areaCode || !row.areaName || !row.companyCode || !row.companyName) {
throw new ConflictException({
code: 'FIELD_FINDING_CONTEXT_REQUIRED',
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!row.assetId) {
throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Área y Operadora de esta inspección',
});
}
if (!row.selected) {
throw new ConflictException({
code: 'FIELD_FINDING_INVENTORY_NOT_SELECTED',
message: 'Seleccioná el Inventario dentro de la inspección antes de registrar un hallazgo',
});
}
const captureRequired = Boolean(row.captureRequired);
const hasGeometry = Boolean(row.hasGeometry);
const creationGpsCaptured = Boolean(row.creationGpsCaptured);
const fieldPhotoCount = Number(row.fieldPhotoCount ?? 0);
const readyForFinding = !captureRequired || (
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
);
if (!readyForFinding) {
throw new ConflictException({
code: 'FIELD_FINDING_CAPTURE_REQUIRED',
message: 'Antes del hallazgo, el Inventario creado en campo debe tener GPS y al menos una foto',
capture: { captureRequired, hasGeometry, creationGpsCaptured, fieldPhotoCount },
});
}
return {
context: {
inspection: { id: row.visitId, code: row.visitCode, status: row.visitStatus },
area: { id: row.areaId, code: row.areaCode, name: row.areaName },
operatorCompany: { id: row.companyId, code: row.companyCode, name: row.companyName },
},
capture: {
captureRequired,
hasGeometry,
creationGpsCaptured,
fieldPhotoCount,
readyForFinding,
},
};
}
private async requireDraftAct(visitId: string): Promise<DraftActRow> {
const rows = await this.dataSource.query(`
SELECT id, code, status
FROM inspection_acts
WHERE visit_id = $1::uuid
AND status = 'DRAFT'
ORDER BY created_at DESC, id DESC
LIMIT 2
`, [visitId]) as DraftActRow[];
if (rows.length === 0) {
throw new ConflictException({
code: 'FIELD_FINDING_DRAFT_ACT_REQUIRED',
message: 'La inspección no tiene un Acta borrador abierta para registrar hallazgos',
});
}
if (rows.length > 1) {
throw new ConflictException({
code: 'FIELD_FINDING_MULTIPLE_DRAFT_ACTS',
message: 'La inspección tiene más de un Acta borrador. Debe resolverse antes de continuar',
});
}
return rows[0];
}
}
@@ -1,14 +1,17 @@
import { Module } from '@nestjs/common';
import { AssetMasterModule } from '../asset-master/asset-master.module';
import { AuditModule } from '../audit/audit.module';
import { InspectionFindingsModule } from '../inspection-findings/inspection-findings.module';
import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@Module({
imports: [AuditModule, AssetMasterModule],
controllers: [InspectionVisitsController, FieldInventoryController],
providers: [InspectionVisitsService, FieldInventoryService],
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [InspectionVisitsService, FieldInventoryService, FieldFindingsService],
})
export class InspectionVisitsModule {}
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.22.1-1';
export const API_PHASE = 'F2.2.1';
export const API_VERSION = '0.23.0-1';
export const API_PHASE = 'F2.3';
@@ -0,0 +1,67 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
import { CreateFieldFindingDto } from '../../src/inspection-visits/dto/create-field-finding.dto';
import { FieldFindingsController } from '../../src/inspection-visits/field-findings.controller';
function permissions(method: string): string[] {
const controller = FieldFindingsController.prototype;
const handler = controller[method as keyof typeof controller];
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
}
test('F2.3 mantiene los Hallazgos de campo bajo ejecución móvil y permisos explícitos', () => {
assert.deepEqual(permissions('options'), ['inspection_findings.create', 'inspections.execute']);
assert.deepEqual(permissions('list'), ['inspection_findings.read', 'inspections.execute']);
assert.deepEqual(permissions('create'), ['inspection_findings.create', 'inspections.execute']);
});
test('F2.3 exige descripción y título cuando el inspector usa OTROS', async () => {
const dto = plainToInstance(CreateFieldFindingDto, {
description: '',
catalogItemId: null,
customTitle: '',
});
const errors = await validate(dto);
const properties = new Set(errors.map((error) => error.property));
assert.equal(properties.has('description'), true);
assert.equal(properties.has('customTitle'), true);
});
test('F2.3 vuelve a validar contexto, selección y GPS+foto en servidor antes del Hallazgo', () => {
const source = readFileSync('src/inspection-visits/field-findings.service.ts', 'utf8');
assert.match(source, /assertMobileInspector\(principal\)/);
assert.match(source, /row\.visitStatus !== 'IN_PROGRESS'/);
assert.match(source, /FIELD_FINDING_INSPECTOR_NOT_ASSIGNED/);
assert.match(source, /FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT/);
assert.match(source, /FIELD_FINDING_INVENTORY_NOT_SELECTED/);
assert.match(source, /hasGeometry && creationGpsCaptured && fieldPhotoCount > 0/);
assert.match(source, /FIELD_FINDING_CAPTURE_REQUIRED/);
});
test('F2.3 resuelve exactamente el Acta DRAFT actual y no vuelve a la regla de un Acta por inspección', () => {
const source = readFileSync('src/inspection-visits/field-findings.service.ts', 'utf8');
assert.match(source, /FROM inspection_acts/);
assert.match(source, /status = 'DRAFT'/);
assert.match(source, /LIMIT 2/);
assert.match(source, /FIELD_FINDING_DRAFT_ACT_REQUIRED/);
assert.match(source, /FIELD_FINDING_MULTIPLE_DRAFT_ACTS/);
});
test('F2.3 reutiliza catálogo contextual y creación canónica, conserva OTROS y permite varios Hallazgos', () => {
const fieldSource = readFileSync('src/inspection-visits/field-findings.service.ts', 'utf8');
const catalogSource = readFileSync('src/inspection-findings/finding-catalog.service.ts', 'utf8');
const findingSource = readFileSync('src/inspection-findings/inspection-findings.service.ts', 'utf8');
assert.match(fieldSource, /this\.catalog\.listApplicableForAsset\(assetId, \{\}\)/);
assert.match(fieldSource, /this\.findings\.create\(act\.id, payload, principal, request\)/);
assert.match(fieldSource, /canAddAnother: true/);
assert.match(catalogSource, /code: 'OTHER'/);
assert.match(catalogSource, /label: 'OTROS'/);
assert.match(findingSource, /finding_catalog_proposals/);
assert.match(findingSource, /PENDING/);
});