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
admin 29c1abf907 fix: preservar etiquetas técnicas extensas del Excel 2026-09-06 22:48:01 -03:00
admin 7b5050e08e fix: garantizar tipos base Área y Organización 2026-09-06 22:44:30 -03:00
admin 5c0b8ed786 ci: corregir sintaxis del smoke de migraciones 2026-09-06 22:43:02 -03:00
admin ba4752a700 ci: ejecutar migraciones F2.2.1 sobre PostgreSQL real 2026-09-06 22:41:40 -03:00
admin cf3327a4da fix: endurecer migración F2.2.1 contra PostgreSQL real 2026-09-06 22:41:03 -03:00
admin e09930d411 F2.2.1: crear baseline temporal del maestro importado 2026-09-06 21:32:19 -03:00
admin 012703336a ci: validar integración F2.2.1 2026-09-06 21:31:00 -03:00
admin 5f5c790301 ci: validar Android F2.2.1 2026-09-06 21:30:50 -03:00
admin 6ed0ce4731 F2.2.1: identificar health de API 2026-09-06 21:30:31 -03:00
admin f78781ed9a F2.2.1: versionar API 0.22.1-1 2026-09-06 21:30:25 -03:00
admin 60ecfff794 test: validar normalización de planillas F2.2.1 2026-09-06 21:30:03 -03:00
admin 3db62aad77 F2.2.1: importar maestros y catálogo desde planillas oficiales 2026-09-06 21:29:46 -03:00
admin 83efd2cac2 F2.2.1: versionar fuente normalizada de planillas 2026-09-06 21:28:05 -03:00
admin e5b7d72951 android: usar puerta de acceso biométrica 2026-09-06 21:25:21 -03:00
admin f9cc561098 android: agregar ojo y desbloqueo biométrico 2026-09-06 21:25:13 -03:00
admin 7673afcc4f android: agregar biometría y visibilidad de contraseña 2026-09-06 21:24:49 -03:00
23 changed files with 2465 additions and 16 deletions
+4 -2
View File
@@ -3,7 +3,9 @@ name: Android APK
on:
push:
branches:
- feature/f2-2-android-v2
- '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-0.10.0-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
+85 -1
View File
@@ -3,7 +3,7 @@ name: F2.2 Integration CI
on:
push:
branches:
- feature/f2-2-android-v2
- 'feature/f2-2*'
paths:
- 'api-v3/**'
- 'web-v2/**'
@@ -34,6 +34,90 @@ jobs:
- run: npm test
- run: npm run build
migrations:
name: DB · migrations on PostgreSQL 16/PostGIS
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: dhv2_owner
POSTGRES_PASSWORD: owner_test_password
POSTGRES_DB: dhv2_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U dhv2_owner -d dhv2_ci"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_NAME: dhv2_ci
DB_MIGRATION_USER: dhv2_owner
DB_MIGRATION_PASSWORD: owner_test_password
DB_APP_USER: dhv2_app
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
- name: PostgreSQL client
run: |
command -v psql >/dev/null || {
sudo apt-get update
sudo apt-get install -y postgresql-client
}
- name: Prepare roles and extensions
env:
PGPASSWORD: owner_test_password
run: |
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U dhv2_owner -d dhv2_ci <<'SQL'
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
CREATE ROLE dhv2_app LOGIN PASSWORD 'app_test_password';
END IF;
END
$$;
GRANT CONNECT ON DATABASE dhv2_ci TO dhv2_app;
GRANT USAGE ON SCHEMA public TO dhv2_app;
-- This reset was a one-time late production operation. It is marked as
-- executed on the clean CI database so the historical schema can be
-- constructed chronologically without running that production-only wipe.
CREATE TABLE IF NOT EXISTS typeorm_migrations (
id SERIAL PRIMARY KEY,
timestamp bigint NOT NULL,
name varchar NOT NULL
);
INSERT INTO typeorm_migrations (timestamp,name)
VALUES (1788652800000,'ResetProductionOperationalData1788652800000');
SQL
- run: npm ci
- run: npm run build
- name: Run every pending migration
run: npm run migration:run
- name: Assert no pending migrations
run: |
npm run migration:show | tee /tmp/migrations.txt
grep -Fq 'Pending migrations: no' /tmp/migrations.txt
- name: Assert F2.2.1 reference import
env:
PGPASSWORD: owner_test_password
run: |
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.data_origin='IMPORT' AND a.source_name='Tablas de yacimiento(1).xlsx' AND lower(t.code)='yacimiento'")" = "230"
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)")" = "0"
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM source_documents WHERE document_number IN ('DH-F221-TERRITORIO','DH-F221-APP')")" = "2"
web:
name: WEB · typecheck, build
runs-on: ubuntu-latest
+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
+5 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 14
versionName = "0.10.0"
versionCode = 17
versionName = "0.10.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -56,11 +56,14 @@ dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.fragment:fragment-ktx:1.8.9")
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.material:material-icons-extended")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.biometric:biometric:1.1.0")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
@@ -1,19 +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.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import com.korexlabs.dhinspeccion.ui.DhApp
import com.korexlabs.dhinspeccion.ui.DhRoot
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val model: MainViewModel = viewModel()
DhApp(model)
DhRoot(model, this@MainActivity)
}
}
}
@@ -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)
@@ -0,0 +1,203 @@
package com.korexlabs.dhinspeccion.ui
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled"
private fun biometricAvailable(context: Context): Boolean =
BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
private fun biometricEnabled(context: Context): Boolean =
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
.getBoolean(BIOMETRIC_ENABLED, false)
private fun setBiometricEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
.edit().putBoolean(BIOMETRIC_ENABLED, enabled).apply()
}
@Composable
fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
val context = LocalContext.current
val capable = remember { biometricAvailable(context) }
var unlocked by rememberSaveable { mutableStateOf(false) }
var passwordLoginInFlight by rememberSaveable { mutableStateOf(false) }
var enabled by remember { mutableStateOf(biometricEnabled(context)) }
LaunchedEffect(model.session) {
if (model.session != null && passwordLoginInFlight) {
if (capable) {
setBiometricEnabled(context, true)
enabled = true
}
unlocked = true
passwordLoginInFlight = false
}
if (model.session == null) unlocked = false
}
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true
}
enabled && capable && !unlocked -> BiometricUnlockScreen(
activity = activity,
displayName = model.session?.displayName.orEmpty(),
onAuthenticated = { unlocked = true },
onUsePassword = {
setBiometricEnabled(context, false)
enabled = false
model.logout()
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
else -> DhApp(model)
}
}
}
}
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
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")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
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 = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña",
)
}
},
)
Button(
onClick = {
onPasswordLogin()
model.login(identifier, password)
},
modifier = Modifier.fillMaxWidth(),
enabled = !model.busy,
) { Text(if (model.busy) "Ingresando…" else "Ingresar") }
Text(
"Después del primer ingreso correcto, si la tablet tiene una huella fuerte configurada, se habilita el acceso biométrico. La contraseña no se almacena.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@Composable
private fun BiometricUnlockScreen(
activity: FragmentActivity,
displayName: String,
onAuthenticated: () -> Unit,
onUsePassword: () -> Unit,
) {
var error by rememberSaveable { mutableStateOf<String?>(null) }
fun authenticate() {
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
error = null
onAuthenticated()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
error = errString.toString()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
error = "Huella no reconocida."
}
},
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Ingresar a DH Inspección")
.setSubtitle(if (displayName.isBlank()) "Validá tu identidad" else "Hola, $displayName")
.setNegativeButtonText("Usar contraseña")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(info)
}
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = { authenticate() }, modifier = Modifier.fillMaxWidth()) { Text("Usar huella") }
OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") }
}
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.22.0-1",
"version": "0.23.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -43,4 +43,4 @@
"tsx": "^4.20.6",
"typescript": "^5.9.0"
}
}
}
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF221BaseOperationalTypes1789754300000 implements MigrationInterface {
name = 'PhaseF221BaseOperationalTypes1789754300000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE asset_types
SET operational_role='AREA', can_be_root=true, is_active=true, updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='area'
`);
await queryRunner.query(`
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
SELECT 'area','Área',
'Área hidrocarburífera administrada como ancla territorial.',
true,true,'AREA'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE operational_role='AREA')
AND NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='area')
`);
await queryRunner.query(`
UPDATE asset_types
SET operational_role='COMPANY', can_be_root=true, is_active=true, updated_at=CURRENT_TIMESTAMP
WHERE lower(code) IN ('empresa','organizacion')
`);
await queryRunner.query(`
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
SELECT 'empresa','Organización',
'Entidad jurídica u organización administrada. Su rol operativo se registra mediante relaciones históricas.',
true,true,'COMPANY'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE operational_role='COMPANY')
AND NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='empresa')
`);
const rows = (await queryRunner.query(`
SELECT
count(*) FILTER (WHERE operational_role='AREA')::text AS areas,
count(*) FILTER (WHERE operational_role='COMPANY')::text AS companies
FROM asset_types
`)) as Array<{ areas: string; companies: string }>;
if (Number(rows[0]?.areas ?? 0) < 1 || Number(rows[0]?.companies ?? 0) < 1) {
throw new Error('F2.2.1 could not guarantee AREA and COMPANY asset types');
}
}
public async down(): Promise<void> {
throw new Error('F2.2.1 base operational types are structural and are not removed automatically');
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF221LongReferenceLabels1789754350000 implements MigrationInterface {
name = 'PhaseF221LongReferenceLabels1789754350000';
public async up(queryRunner: QueryRunner): Promise<void> {
// La matriz APLICACION APP contiene denominaciones técnicas descriptivas que
// legítimamente superan el límite histórico de 160 caracteres. No se recorta
// información de la fuente: ampliamos sólo columnas descriptivas.
await queryRunner.query(`ALTER TABLE asset_types ALTER COLUMN name TYPE varchar(500)`);
await queryRunner.query(`ALTER TABLE asset_attribute_definitions ALTER COLUMN name TYPE varchar(500)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const rows = (await queryRunner.query(`
SELECT
coalesce(max(length(name)),0)::integer AS max_type_name,
(SELECT coalesce(max(length(name)),0)::integer FROM asset_attribute_definitions) AS max_attribute_name
FROM asset_types
`)) as Array<{ max_type_name: number; max_attribute_name: number }>;
if ((rows[0]?.max_type_name ?? 0) > 160 || (rows[0]?.max_attribute_name ?? 0) > 160) {
throw new Error('F2.2.1 no puede reducir etiquetas a 160 caracteres sin pérdida de información');
}
await queryRunner.query(`ALTER TABLE asset_types ALTER COLUMN name TYPE varchar(160)`);
await queryRunner.query(`ALTER TABLE asset_attribute_definitions ALTER COLUMN name TYPE varchar(160)`);
}
}
@@ -0,0 +1,615 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { loadF221ExcelSource } from '../../reference-data/f2-2-1-excel-source';
function slug(value: string, max = 70): string {
const normalized = value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return (normalized || 'item').slice(0, max);
}
function stableCode(prefix: string, ...parts: string[]): string {
const hash = createHash('sha1').update(parts.join('|')).digest('hex').slice(0, 8).toUpperCase();
const readable = slug(parts[0] ?? 'item', 72).toUpperCase();
return `${prefix}-${readable}-${hash}`.slice(0, 120);
}
function legalRightType(value: string): string {
const normalized = slug(value);
if (normalized.includes('explot')) return 'EXPLOITATION_CONCESSION';
if (normalized.includes('explor')) return 'EXPLORATION_PERMIT';
if (normalized.includes('transport')) return 'TRANSPORT_CONCESSION';
return 'OTHER';
}
const groupTargets: Record<string, string[]> = {
tanques: ['tanque'],
separadores: ['separador'],
bomba_zona_de_bombas: ['bomba'],
calderas: ['caldera'],
drenaje: ['drenaje'],
antorcha: ['antorcha'],
colectores: ['colector'],
sist_electrico_iluminacion: ['sistema_electrico_iluminacion'],
defensa_contra_incendios: ['defensa_contra_incendios'],
cargadero_y_descargadero_de_camiones: ['cargadero_descargadero'],
filtros: ['filtro'],
eq_flotacion: ['equipo_flotacion'],
baterias_y_plantas: ['bateria', 'planta'],
fwko_tratadores_calentadores: ['fwo', 'fwko', 'calentador', 'tratador_termico'],
bombeo_mecanico: ['bombeo_mecanico'],
bombeo_electreosumergible: ['bombeo_electreosumergible'],
bombeo_cavidad_progresiva_pcp: ['bombeo_cavidad_progresiva_pcp'],
pozos_surgentes_prod_gas: ['pozos_surgentes_prod_gas'],
pozos_inyectores_agua: ['pozos_inyectores_agua'],
};
const referenceAliases: Record<string, string> = {
tanque: 'tanque',
tanques: 'tanque',
separador: 'separador',
separadores: 'separador',
bombas: 'bomba',
bomba: 'bomba',
fwo: 'fwo',
fwko: 'fwko',
calentador: 'calentador',
piletas: 'drenaje',
pileta: 'drenaje',
cargadero_descargadero: 'cargadero_descargadero',
generador: 'generador',
};
type IdRow = { id: string };
type CountRow = { total: string };
async function assetIdByCode(queryRunner: QueryRunner, code: string): Promise<string | null> {
const rows = (await queryRunner.query(
`SELECT id FROM assets WHERE lower(code)=lower($1::text) LIMIT 1`,
[code],
)) as IdRow[];
return rows[0]?.id ?? null;
}
async function assetTypeIdByCode(queryRunner: QueryRunner, code: string): Promise<string | null> {
const rows = (await queryRunner.query(
`SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`,
[code],
)) as IdRow[];
return rows[0]?.id ?? null;
}
export class PhaseF221ReferenceExcelData1789754400000 implements MigrationInterface {
name = 'PhaseF221ReferenceExcelData1789754400000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF221ExcelSource();
await queryRunner.query(`
INSERT INTO source_documents (document_type,document_number,title,issuer,notes)
SELECT 'SPREADSHEET','DH-F221-TERRITORIO','Tablas de yacimiento(1).xlsx','Dirección de Hidrocarburos',
'Fuente F2.2.1: maestro territorial y operativo. Hoja normalizada cr26e_tabla1.'
WHERE NOT EXISTS (
SELECT 1 FROM source_documents
WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos'
)
`);
await queryRunner.query(`
INSERT INTO source_documents (document_type,document_number,title,issuer,notes)
SELECT 'SPREADSHEET','DH-F221-APP','APLICACION APP(1).xlsx','Dirección de Hidrocarburos',
'Fuente F2.2.1: matriz de jerarquía, información y catálogo contextual de hallazgos. Hojas Hoja1 y Hoja2.'
WHERE NOT EXISTS (
SELECT 1 FROM source_documents
WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos'
)
`);
const territoryDocuments = (await queryRunner.query(
`SELECT id FROM source_documents
WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
)) as IdRow[];
const appDocuments = (await queryRunner.query(
`SELECT id FROM source_documents
WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
)) as IdRow[];
const territoryDocumentId = territoryDocuments[0]?.id;
const appDocumentId = appDocuments[0]?.id;
if (!territoryDocumentId || !appDocumentId) {
throw new Error('F2.2.1 source documents could not be resolved');
}
await queryRunner.query(
`UPDATE asset_types SET can_be_root=true WHERE operational_role IN ('AREA','COMPANY')`,
);
await queryRunner.query(`
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
SELECT 'yacimiento','Yacimiento',
'Yacimiento físico/operativo perteneciente a un Área. Su operadora se determina por contexto histórico.',
false,true,'GENERIC'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='yacimiento')
`);
await queryRunner.query(`
INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
SELECT child.id,parent.id
FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)='yacimiento' AND parent.operational_role='AREA'
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
INSERT INTO asset_attribute_definitions
(asset_type_id,code,name,data_type,is_required,is_active,sort_order)
SELECT type.id,definition.code,definition.name,'TEXT',false,true,definition.sort_order
FROM asset_types type
CROSS JOIN (VALUES
('departamento_fuente','Departamento informado por la fuente',10),
('tipo_concesion_fuente','Tipo de concesión informado por la fuente',20)
) definition(code,name,sort_order)
WHERE lower(type.code)='yacimiento'
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
INSERT INTO asset_attribute_definitions
(asset_type_id,code,name,data_type,is_required,is_active,sort_order)
SELECT type.id,'tipo_concesion_fuente','Tipo de concesión informado por la fuente',
'TEXT',false,true,40
FROM asset_types type
WHERE type.operational_role='AREA'
ON CONFLICT DO NOTHING
`);
const areaCache = new Map<string, string>();
const companyCache = new Map<string, string>();
const seenRights = new Set<string>();
for (const row of source.territory) {
const areaKey = row.area.trim().toLocaleLowerCase('es');
let areaId = areaCache.get(areaKey) ?? null;
if (!areaId) {
const areaCode = stableCode('AREA', row.area);
areaId = await assetIdByCode(queryRunner, areaCode);
if (!areaId) {
const inserted = (await queryRunner.query(
`INSERT INTO assets (
asset_type_id,parent_id,code,name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,current_version
)
SELECT type.id,NULL,$1::varchar,$2::varchar,$3::text,
'VALIDATED','IN_SERVICE','IMPORT',$4::varchar,$5::varchar,$6::text,0
FROM asset_types type
WHERE type.operational_role='AREA'
LIMIT 1
RETURNING id`,
[
areaCode,
row.area,
`Área importada desde Tablas de yacimiento. Departamento: ${row.department}.`,
'Tablas de yacimiento(1).xlsx',
`cr26e_tabla1|AREA|${row.area}`,
`Departamento=${row.department}; TipoConcesion=${row.concessionType}`,
],
)) as IdRow[];
areaId = inserted[0]?.id ?? null;
}
if (!areaId) throw new Error(`Could not resolve area ${row.area}`);
areaCache.set(areaKey, areaId);
await queryRunner.query(
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1')
ON CONFLICT DO NOTHING`,
[areaId, territoryDocumentId],
);
}
for (const [definitionCode, value] of [
['departamento', row.department],
['tipo_concesion_fuente', row.concessionType],
] as const) {
await queryRunner.query(
`INSERT INTO asset_attribute_values (asset_id,definition_id,value)
SELECT $1::uuid,definition.id,to_jsonb($3::text)
FROM asset_attribute_definitions definition
JOIN asset_types type ON type.id=definition.asset_type_id
WHERE type.operational_role='AREA'
AND lower(definition.code)=lower($2::text)
ON CONFLICT (asset_id,definition_id)
DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`,
[areaId, definitionCode, value],
);
}
const noOperator = slug(row.operator) === 'sin_empresa_operadora';
let companyId: string | null = null;
if (!noOperator) {
const companyKey = row.operator.trim().toLocaleLowerCase('es');
companyId = companyCache.get(companyKey) ?? null;
if (!companyId) {
const companyCode = stableCode('ORG', row.operator);
companyId = await assetIdByCode(queryRunner, companyCode);
if (!companyId) {
const inserted = (await queryRunner.query(
`INSERT INTO assets (
asset_type_id,parent_id,code,name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,current_version
)
SELECT type.id,NULL,$1::varchar,$2::varchar,
'Organización operadora importada desde Tablas de yacimiento.',
'VALIDATED','IN_SERVICE','IMPORT',$3::varchar,$4::varchar,
'Empresa/Operadora informada por la fuente',0
FROM asset_types type
WHERE type.operational_role='COMPANY'
LIMIT 1
RETURNING id`,
[
companyCode,
row.operator,
'Tablas de yacimiento(1).xlsx',
`cr26e_tabla1|OPERADORA|${row.operator}`,
],
)) as IdRow[];
companyId = inserted[0]?.id ?? null;
}
if (!companyId) throw new Error(`Could not resolve operator ${row.operator}`);
companyCache.set(companyKey, companyId);
await queryRunner.query(
`INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
SELECT $1::uuid,
CASE WHEN lower($2::text) LIKE 'ute %' OR lower($2::text) LIKE 'ute(%'
THEN 'UTE'::organization_kind ELSE 'COMPANY'::organization_kind END,
$2::varchar
WHERE NOT EXISTS (SELECT 1 FROM organization_profiles WHERE asset_id=$1::uuid)`,
[companyId, row.operator],
);
await queryRunner.query(
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1')
ON CONFLICT DO NOTHING`,
[companyId, territoryDocumentId],
);
}
await queryRunner.query(
`INSERT INTO area_company_relations
(area_id,company_id,relation_role,valid_from,start_reason,source_document_id)
SELECT $1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,
'Importado desde Tablas de yacimiento(1).xlsx',$3::uuid
WHERE NOT EXISTS (
SELECT 1 FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid
AND relation_role='OPERATOR' AND valid_until IS NULL
)`,
[areaId, companyId, territoryDocumentId],
);
}
const fieldCode = stableCode('YAC', row.field, row.area);
let fieldId = await assetIdByCode(queryRunner, fieldCode);
if (!fieldId) {
// The operational context is intentionally all-or-nothing. A source row
// that explicitly says "Sin Empresa Operadora" stays physically under
// its Área but receives no fabricated operator and no half context.
const operationalAreaId = companyId ? areaId : null;
const inserted = (await queryRunner.query(
`INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,code,name,description,
information_status,operational_status,data_origin,source_name,source_reference,
source_notes,current_version
)
SELECT type.id,$1::uuid,$2::uuid,$3::uuid,$4::varchar,$5::varchar,$6::text,
'VALIDATED','IN_SERVICE','IMPORT',$7::varchar,$8::varchar,$9::text,0
FROM asset_types type
WHERE lower(type.code)='yacimiento'
LIMIT 1
RETURNING id`,
[
areaId,
operationalAreaId,
companyId,
fieldCode,
row.field,
`Yacimiento del Área ${row.area}.`,
'Tablas de yacimiento(1).xlsx',
`cr26e_tabla1|YACIMIENTO|${row.field}|AREA|${row.area}`,
`Departamento=${row.department}; TipoConcesion=${row.concessionType}; OperadoraFuente=${row.operator}`,
],
)) as IdRow[];
fieldId = inserted[0]?.id ?? null;
}
if (!fieldId) throw new Error(`Could not resolve field ${row.field} / ${row.area}`);
await queryRunner.query(
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1')
ON CONFLICT DO NOTHING`,
[fieldId, territoryDocumentId],
);
for (const [definitionCode, value] of [
['departamento_fuente', row.department],
['tipo_concesion_fuente', row.concessionType],
] as const) {
await queryRunner.query(
`INSERT INTO asset_attribute_values (asset_id,definition_id,value)
SELECT $1::uuid,definition.id,to_jsonb($3::text)
FROM asset_attribute_definitions definition
JOIN asset_types type ON type.id=definition.asset_type_id
WHERE lower(type.code)='yacimiento'
AND lower(definition.code)=lower($2::text)
ON CONFLICT (asset_id,definition_id)
DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`,
[fieldId, definitionCode, value],
);
}
const rightName = `Tipo informado: ${row.concessionType}`;
const rightKey = `${areaId}|${slug(row.concessionType)}`;
if (!seenRights.has(rightKey)) {
seenRights.add(rightKey);
const existing = (await queryRunner.query(
`SELECT id FROM area_legal_rights
WHERE area_id=$1::uuid AND lower(name)=lower($2::text)
AND source_document_id=$3::uuid LIMIT 1`,
[areaId, rightName, territoryDocumentId],
)) as IdRow[];
if (!existing[0]?.id) {
await queryRunner.query(
`INSERT INTO area_legal_rights
(area_id,right_type,name,status,source_document_id,notes)
VALUES ($1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5::text)`,
[
areaId,
legalRightType(row.concessionType),
rightName,
territoryDocumentId,
`Importado desde cr26e_tabla1. Departamento: ${row.department}.`,
],
);
}
}
}
const topLevelTypes = new Set(source.types.map((entry) => entry.code));
for (const pair of source.parentPairs) topLevelTypes.delete(pair.child);
const allTypeDefinitions = new Map(source.types.map((entry) => [entry.code, entry.name]));
for (const targets of Object.values(groupTargets)) {
for (const target of targets) {
if (!allTypeDefinitions.has(target)) {
allTypeDefinitions.set(target, target.replaceAll('_', ' '));
}
}
}
for (const [typeCode, typeName] of allTypeDefinitions) {
if (!(await assetTypeIdByCode(queryRunner, typeCode))) {
await queryRunner.query(
`INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
VALUES ($1::varchar,$2::varchar,
'Tipo incorporado desde APLICACION APP(1).xlsx · F2.2.1',false,true,'GENERIC')`,
[typeCode, typeName],
);
}
await queryRunner.query(
`INSERT INTO finding_catalog_asset_type_profiles (asset_type_id,reason)
SELECT id,'Aplicabilidad inicial importada desde APLICACION APP(1).xlsx'
FROM asset_types WHERE lower(code)=lower($1::text)
ON CONFLICT (asset_type_id) DO NOTHING`,
[typeCode],
);
}
for (const typeCode of topLevelTypes) {
await queryRunner.query(
`INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
SELECT child.id,parent.id FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)=lower($1::text) AND lower(parent.code)='yacimiento'
ON CONFLICT DO NOTHING`,
[typeCode],
);
}
for (const pair of source.parentPairs) {
if (pair.child === pair.parent) continue;
await queryRunner.query(
`INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
SELECT child.id,parent.id FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)=lower($1::text) AND lower(parent.code)=lower($2::text)
ON CONFLICT DO NOTHING`,
[pair.child, pair.parent],
);
}
for (const targets of Object.values(groupTargets)) {
for (const typeCode of targets) {
await queryRunner.query(
`INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
SELECT child.id,parent.id FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)=lower($1::text) AND lower(parent.code)='yacimiento'
ON CONFLICT DO NOTHING`,
[typeCode],
);
}
}
for (const entry of source.attributes) {
let sortOrder = 100;
for (const label of entry.labels) {
const attributeCode = slug(label, 72);
const typeId = await assetTypeIdByCode(queryRunner, entry.type);
if (!typeId) continue;
const existing = (await queryRunner.query(
`SELECT id FROM asset_attribute_definitions
WHERE asset_type_id=$1::uuid AND lower(code)=lower($2::text) LIMIT 1`,
[typeId, attributeCode],
)) as IdRow[];
if (!existing[0]?.id) {
await queryRunner.query(
`INSERT INTO asset_attribute_definitions
(asset_type_id,code,name,data_type,is_required,is_active,sort_order)
VALUES ($1::uuid,$2::varchar,$3::varchar,'TEXT',false,true,$4::integer)`,
[typeId, attributeCode, label, sortOrder],
);
}
sortOrder += 10;
}
}
await queryRunner.query(`
INSERT INTO finding_categories (code,name,sort_order,is_active)
SELECT 'APP26','Aplicación APP 2026',260,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26')
`);
const categoryRows = (await queryRunner.query(
`SELECT id FROM finding_categories WHERE lower(code)='app26' LIMIT 1`,
)) as IdRow[];
const categoryId = categoryRows[0]?.id;
if (!categoryId) throw new Error('Could not resolve APP26 finding category');
const titlesByType = new Map<string, Set<string>>();
const addTitle = (typeCode: string, title: string): void => {
const cleanTitle = title.trim();
if (!cleanTitle || /^idem\b/i.test(cleanTitle)) return;
const set = titlesByType.get(typeCode) ?? new Set<string>();
set.add(cleanTitle);
titlesByType.set(typeCode, set);
};
for (const entry of source.directFindings) {
for (const title of entry.titles) addTitle(entry.type, title);
}
for (const group of source.groups) {
const targets = groupTargets[slug(group.name)] ?? [slug(group.name)];
for (const target of targets) {
for (const item of group.items) addTitle(target, item.title);
}
}
for (let pass = 0; pass < 3; pass += 1) {
for (const entry of source.idemRefs) {
for (const rawReference of entry.refs) {
const reference = referenceAliases[slug(rawReference)] ?? slug(rawReference);
for (const title of titlesByType.get(reference) ?? []) addTitle(entry.type, title);
}
}
}
const allTitles = [
...new Set([...titlesByType.values()].flatMap((titles) => [...titles])),
].sort((a, b) => a.localeCompare(b, 'es'));
const itemIdByTitle = new Map<string, string>();
let sourceNumber = 1;
for (const title of allTitles) {
const itemCode = `APP26-${createHash('sha1').update(title).digest('hex').slice(0, 12).toUpperCase()}`;
let itemRows = (await queryRunner.query(
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`,
[itemCode],
)) as IdRow[];
if (!itemRows[0]?.id) {
itemRows = (await queryRunner.query(
`INSERT INTO finding_catalog_items
(category_id,code,source_number,title,import_note,revision,is_active)
VALUES ($1::uuid,$2::varchar,$3::integer,$4::varchar,
'Importado desde APLICACION APP(1).xlsx · F2.2.1',1,true)
RETURNING id`,
[categoryId, itemCode, sourceNumber, title],
)) as IdRow[];
}
const itemId = itemRows[0]?.id;
if (itemId) {
itemIdByTitle.set(title, itemId);
await queryRunner.query(
`INSERT INTO finding_catalog_item_versions
(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,
jsonb_build_object(
'id',item.id,'categoryId',category.id,'categoryCode',category.code,
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
),
'migration:F2.2.1'
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM finding_catalog_item_versions version
WHERE version.item_id=item.id AND version.revision=item.revision
)`,
[itemId],
);
}
sourceNumber += 1;
}
for (const [typeCode, titles] of titlesByType) {
const assetTypeId = await assetTypeIdByCode(queryRunner, typeCode);
if (!assetTypeId) continue;
for (const title of titles) {
const catalogItemId = itemIdByTitle.get(title);
if (!catalogItemId) continue;
await queryRunner.query(
`INSERT INTO finding_catalog_item_asset_types (catalog_item_id,asset_type_id)
VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING`,
[catalogItemId, assetTypeId],
);
}
}
await queryRunner.query(
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
SELECT asset.id,$1::uuid,'MENTIONS',
'El tipo/hallazgo del elemento se definió con la matriz APLICACION APP(1).xlsx'
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)='yacimiento'
ON CONFLICT DO NOTHING`,
[appDocumentId],
);
const importedYacimientos = (await queryRunner.query(
`SELECT count(*)::text AS total
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.data_origin='IMPORT'
AND asset.source_name='Tablas de yacimiento(1).xlsx'
AND lower(type.code)='yacimiento'`,
)) as CountRow[];
if (Number(importedYacimientos[0]?.total ?? 0) !== source.territory.length) {
throw new Error(
`F2.2.1 verification failed: expected ${source.territory.length} imported yacimientos, found ${importedYacimientos[0]?.total ?? '0'}`,
);
}
const importedCatalog = (await queryRunner.query(
`SELECT count(*)::text AS total
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='app26'`,
)) as CountRow[];
if (Number(importedCatalog[0]?.total ?? 0) !== allTitles.length) {
throw new Error(
`F2.2.1 verification failed: expected ${allTitles.length} APP26 findings, found ${importedCatalog[0]?.total ?? '0'}`,
);
}
const halfContexts = (await queryRunner.query(
`SELECT count(*)::text AS total FROM assets
WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)`,
)) as CountRow[];
if (Number(halfContexts[0]?.total ?? 0) !== 0) {
throw new Error('F2.2.1 verification failed: an imported asset has an incomplete operational context');
}
// eslint-disable-next-line no-console
console.log(
`[F2.2.1] imported territory=${source.territory.length}; yacimientos=${importedYacimientos[0]?.total}; catalog=${importedCatalog[0]?.total}; halfContexts=0`,
);
}
public async down(): Promise<void> {
throw new Error(
'F2.2.1 contiene maestros territoriales y catálogo de referencia; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.',
);
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF221ReferenceBaselines1789758000000 implements MigrationInterface {
name = 'PhaseF221ReferenceBaselines1789758000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO asset_versions (
asset_id,version_number,change_type,changed_fields,snapshot,source
)
SELECT
asset.id,
1,
'BASELINE',
ARRAY['assetTypeId','parentId','operationalAreaId','operatorCompanyId','code','name','informationStatus','operationalStatus','dataOrigin','sourceName','sourceReference'],
jsonb_build_object(
'id',asset.id,
'assetTypeId',asset.asset_type_id,
'parentId',asset.parent_id,
'operationalAreaId',asset.operational_area_id,
'operatorCompanyId',asset.operator_company_id,
'code',asset.code,
'name',asset.name,
'commonName',asset.common_name,
'description',asset.description,
'informationStatus',asset.information_status,
'operationalStatus',asset.operational_status,
'dataOrigin',asset.data_origin,
'sourceName',asset.source_name,
'sourceReference',asset.source_reference,
'sourceNotes',asset.source_notes
),
'SYSTEM'
FROM assets asset
WHERE asset.data_origin='IMPORT'
AND asset.source_name='Tablas de yacimiento(1).xlsx'
AND NOT EXISTS (
SELECT 1 FROM asset_versions version
WHERE version.asset_id=asset.id AND version.version_number=1
)
`);
await queryRunner.query(`
UPDATE assets asset
SET current_version=GREATEST(asset.current_version,1),updated_at=CURRENT_TIMESTAMP
WHERE asset.data_origin='IMPORT'
AND asset.source_name='Tablas de yacimiento(1).xlsx'
AND EXISTS (
SELECT 1 FROM asset_versions version
WHERE version.asset_id=asset.id AND version.version_number=1
)
`);
}
public async down(): Promise<void> {
throw new Error('F2.2.1 no elimina baselines históricos del maestro importado. Restaurar backup PRE si fuera necesario.');
}
}
@@ -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 {}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.22.0-1';
export const API_PHASE = 'F2.2';
export const API_VERSION = '0.23.0-1';
export const API_PHASE = 'F2.3';
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { loadF221ExcelSource } from '../src/reference-data/f2-2-1-excel-source';
const source = loadF221ExcelSource();
test('F2.2.1 preserves every normalized territorial source row', () => {
assert.equal(source.territory.length, 230);
assert.equal(new Set(source.territory.map((row) => row.field)).size, 220);
assert.equal(new Set(source.territory.map((row) => `${row.field.toLowerCase()}|${row.area.toLowerCase()}`)).size, 230);
});
test('F2.2.1 does not collapse repeated field names across different operational contexts', () => {
const byName = new Map<string, Set<string>>();
for (const row of source.territory) {
const key = row.field.toLowerCase();
const contexts = byName.get(key) ?? new Set<string>();
contexts.add(`${row.area}|${row.operator}`);
byName.set(key, contexts);
}
assert.ok([...byName.values()].some((contexts) => contexts.size > 1));
});
test('F2.2.1 keeps the APP matrix as configuration rather than physical inventory rows', () => {
assert.equal(source.types.length, 113);
assert.equal(source.parentPairs.length, 119);
assert.equal(source.attributes.length, 44);
assert.equal(source.idemRefs.length, 25);
assert.ok(source.types.some((entry) => entry.code === 'tanque'));
assert.ok(source.types.some((entry) => entry.code === 'bateria'));
});
test('F2.2.1 keeps IDEM as inheritance metadata, never as a catalog title', () => {
const directTitles = source.directFindings.flatMap((entry) => entry.titles);
assert.equal(directTitles.some((title) => /^idem\b/i.test(title.trim())), false);
assert.ok(source.idemRefs.some((entry) => entry.type === 'tanque'));
});
test('F2.2.1 imports the grouped Hallazgo catalog supplied in Hoja2', () => {
assert.equal(source.groups.length, 19);
assert.equal(source.groups.reduce((total, group) => total + group.items.length, 0), 171);
const tanks = source.groups.find((group) => group.name.trim().toUpperCase() === 'TANQUES');
assert.ok(tanks);
assert.ok(tanks.items.some((item) => /ESTADO DEL TANQUE/i.test(item.title)));
});
test('F2.2.1 preserves rows explicitly reporting no operator without fabricating an organization in source data', () => {
assert.ok(source.territory.some((row) => row.operator.toLowerCase().includes('sin empresa operadora')));
});
@@ -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/);
});