Compare commits

...
Author SHA1 Message Date
admin d44ae049f3 test(android): align 0.19.1 release contract and record field review
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m11s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m12s
DH V2 CI / API · typecheck, tests, build (pull_request) Successful in 30s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
Production dependency audit / API · production dependencies (pull_request) Successful in 8s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
DH V2 CI / Docker / scripts contract (pull_request) Successful in 48s
2026-09-14 00:04:01 -03:00
admin 78e92970ff fix(android): align GPS and technical inventory field workflow
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m12s
Android CI / RC / Android · lint, tests, debug APK, release compile (pull_request) Successful in 5m9s
DH V2 CI / API · typecheck, tests, build (pull_request) Failing after 25s
DH V2 CI / WEB · typecheck, build (pull_request) Successful in 18s
DH V2 CI / Docker / scripts contract (pull_request) Skipped
Production dependency audit / API · production dependencies (pull_request) Successful in 9s
Production dependency audit / WEB · production dependencies (pull_request) Successful in 8s
2026-09-13 23:48:55 -03:00
admin 7fcb1d42b6 fix(android): harden field session and writes for 0.19.1 candidate
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m25s
2026-09-13 23:35:08 -03:00
admin e55684bf4f fix(web): compact installation parent checkbox options
DH V2 CI / API · typecheck, tests, build (push) Successful in 29s
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 58s
2026-09-13 18:37:25 -03:00
admin ddf3b158e7 deploy: serialize Gitea production deployments
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 17:11:51 -03:00
admin 8564f1933c deploy: add native Gitea production workflow
DH V2 CI / API · typecheck, tests, build (push) Successful in 30s
DH V2 CI / WEB · typecheck, build (push) Successful in 17s
Production dependency audit / API · production dependencies (push) Successful in 9s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 48s
2026-09-13 16:56:54 -03:00
admin ea890cb807 ci(gitea): make API health probe DinD-compatible
DH V2 CI / WEB · typecheck, build (push) Successful in 18s
Production dependency audit / WEB · production dependencies (push) Successful in 8s
DH V2 CI / API · typecheck, tests, build (push) Successful in 31s
Production dependency audit / API · production dependencies (push) Successful in 8s
DH V2 CI / Docker / scripts contract (push) Successful in 1m5s
2026-09-13 16:40:46 -03:00
admin ce27cd01e9 ci(gitea): pin setup-gradle v4.4.3 for act compatibility
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m39s
Production dependency audit / API · production dependencies (push) Successful in 8s
Production dependency audit / WEB · production dependencies (push) Successful in 9s
DH V2 CI / API · typecheck, tests, build (push) Successful in 32s
DH V2 CI / WEB · typecheck, build (push) Successful in 19s
DH V2 CI / Docker / scripts contract (push) Failing after 1m38s
2026-09-13 13:03:21 -03:00
admin a155d9e075 Merge PR #59 · reparar Actividad global de Inventarios
Corrige la consulta SQL común del expediente de Actividad y aísla las facetas auxiliares para evitar errores globales.
2026-09-12 12:23:15 -03:00
admin e89d815868 test(api): actualizar contrato de Actividad territorial 2026-09-12 12:14:57 -03:00
admin ff0b6d83db test(api): cubrir fallo global de actividad 2026-09-12 12:11:55 -03:00
admin d894391d97 fix(api): registrar servicio de actividad resiliente 2026-09-12 12:11:40 -03:00
admin c79537def9 fix(api): usar dossier de actividad resiliente 2026-09-12 12:11:27 -03:00
admin 83c9e2ca3e fix(api): aislar y reparar consultas de actividad de inventario 2026-09-12 12:11:02 -03:00
admin 9254583154 Merge PR #58 · reparar Actividad de Yacimiento
Desacopla el dossier territorial del subsistema de fusión y evita que merge-status bloquee la pestaña Actividad.
2026-09-12 11:40:55 -03:00
admin 0b21e6bc50 test(f7): cubrir Actividad de Yacimiento sin dependencia de merge 2026-09-12 11:33:59 -03:00
admin 350e26566f fix(web): no bloquear Actividad por estado de fusión 2026-09-12 11:33:45 -03:00
admin ba79fbf9ae fix(api): desacoplar dossier territorial de merge 2026-09-12 11:32:50 -03:00
admin 77def3b511 Merge PR #56 · simplificación integral de Inventarios
F7 · modelo simple y experiencia cotidiana de Inventarios
2026-09-11 22:20:02 -03:00
admin ba01bc844e test(f7): cover simple inventory detail experience 2026-09-11 22:12:28 -03:00
admin fa6d4bd06a refactor(web): route canonical inventory to simple detail 2026-09-11 22:11:55 -03:00
admin f54e41d9c9 style(web): add simple inventory detail layout 2026-09-11 22:11:46 -03:00
admin 1b769a0ff4 feat(web): simplify canonical inventory details 2026-09-11 22:11:28 -03:00
admin 5695643fa8 fix(inventory): keep operator snapshot audit marker 2026-09-11 21:26:32 -03:00
admin a6e71fa299 test(inventory): move Yacimiento creation contract to F7 Area companies 2026-09-11 21:24:04 -03:00
admin 02091e14e6 test(inventory): align F5 source guards with F7 simple UX 2026-09-11 21:23:47 -03:00
admin 827b172c66 test(inventory): retain historical Area relation invariant 2026-09-11 21:23:28 -03:00
admin 45a1a08b16 test(web): replace obsolete F3 inventory contract with F7 simplicity guard 2026-09-11 21:20:16 -03:00
admin 3b9153f30a fix(inventory): use simple F7 create endpoint 2026-09-11 21:18:46 -03:00
admin 4f90032d00 feat(inventory): register simple inventory service 2026-09-11 21:18:21 -03:00
admin f621677527 feat(inventory): expose simple F7 create endpoint 2026-09-11 21:18:10 -03:00
admin eeffdc441e feat(inventory): add simple F7 create service 2026-09-11 21:17:59 -03:00
admin 351752256e feat(inventory): align Yacimiento context with simple F7 model 2026-09-11 21:17:19 -03:00
admin abe7e40c99 fix(inventory): allow multiple companies per Area 2026-09-11 21:16:20 -03:00
admin 3b7db0a869 docs: define F7 simple inventory model 2026-09-11 21:13:57 -03:00
admin 0213c29465 ux(admin): replace inventory model screen with simple type and field editor 2026-09-11 21:13:39 -03:00
admin fce370b287 ux(inventory): simplify manual create flow 2026-09-11 21:13:00 -03:00
admin 537b0a6f42 refactor(inventory): expose area companies for simple Yacimiento create 2026-09-11 21:12:24 -03:00
admin a57d6633bd Merge PR #55 · activar ficha compacta de Instalación de superficie
Corrige el matcher de tipo para que Instalación de superficie y Subinstalación usen realmente el Resumen compacto.
2026-09-11 14:50:17 -03:00
admin e18e86e918 fix(web): reconocer Instalación de superficie en ficha compacta 2026-09-11 14:44:42 -03:00
admin cadf766d3c Merge PR #54 · simplificar ficha de Instalación/Subinstalación
Compacta el Resumen de Instalaciones y Subinstalaciones, mueve el contexto físico al Historial y mantiene las funciones avanzadas accesibles sin alargar la ficha principal.
2026-09-11 14:32:07 -03:00
49 changed files with 3096 additions and 416 deletions
+10 -2
View File
@@ -48,7 +48,7 @@ jobs:
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
with:
gradle-version: '8.13'
@@ -63,7 +63,7 @@ jobs:
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
run: gradle --no-daemon :app:lintDebug :app:lintRelease
- name: Print complete lint failures
if: failure()
@@ -130,6 +130,14 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Upload unsigned release candidate
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
+6 -1
View File
@@ -309,9 +309,14 @@ jobs:
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
if docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
+1 -1
View File
@@ -1,4 +1,4 @@
# Checklist final Android 0.19.0
# Checklist final Android 0.19.1
La candidata sólo se promueve si el mismo SHA cumple:
+24 -4
View File
@@ -1,10 +1,10 @@
# DH Inspección Android · release final de campo 0.19.0
# DH Inspección Android · release final de campo 0.19.1
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- `versionName`: **0.19.1**.
- `versionCode`: **29**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
@@ -13,7 +13,7 @@ La variante debug es independiente de la app productiva y puede instalarse para
## Procedimiento operativo validado
La APK 0.19.0 fija como recorrido principal de campo:
La APK 0.19.1 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo.
@@ -95,3 +95,23 @@ Antes de distribuir una APK productiva:
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida.
## Revisión 0.19.1
- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación.
- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector.
- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes.
- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior.
- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado.
- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia.
### Alcance real
Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final.
### Segunda pasada funcional
- Coordenadas normalizadas al contrato API (6 decimales, precisión 3).
- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación.
- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto.
- Se agregan siete pruebas para coordenadas y valores técnicos.
+49
View File
@@ -0,0 +1,49 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 28
versionName = "0.19.0"
versionCode = 29
versionName = "0.19.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
@@ -24,11 +25,14 @@ import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
@@ -84,7 +88,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña."
return
}
launchBusy {
launchBusy(mutation = true) {
session = repository.login(identifier, password)
notice = "Sesión iniciada."
loadVisitsInternal()
@@ -113,7 +117,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
@@ -123,6 +129,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null
inventory = emptyList()
inventoryParentId = null
@@ -135,7 +143,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() {
val id = visit?.id ?: return
launchBusy {
launchBusy(mutation = true) {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
@@ -172,7 +180,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
return
}
launchBusy {
launchBusy(mutation = true) {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
@@ -194,8 +202,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
val result = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
}
}
@@ -203,6 +213,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -216,7 +227,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy {
launchBusy(mutation = true) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
reloadCurrentInventory(visitId)
@@ -252,7 +263,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return
}
launchBusy {
launchBusy(mutation = true) {
val request = CreateFieldInventoryRequest(
typeId = type.id,
parentId = parentId,
@@ -261,9 +272,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
deviceAccuracyM = accuracyM,
deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
@@ -283,7 +294,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado."
return
}
launchBusy {
launchBusy(mutation = true) {
val result = repository.mergeFieldAsset(
visitId = visitId,
assetId = source.id,
@@ -310,7 +321,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy {
launchBusy(mutation = true) {
val response = repository.uploadFieldPhoto(
visitId = visitId,
assetId = asset.id,
@@ -345,7 +356,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
return
}
launchBusy {
launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
@@ -379,7 +390,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10."
return
}
launchBusy {
launchBusy(mutation = true) {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
@@ -410,7 +421,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null,
description: String? = null,
) {
launchBusy {
launchBusy(mutation = true) {
findingsRepository.uploadObservationPhoto(
findingId = findingId,
file = file,
@@ -442,7 +453,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
return
}
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
@@ -465,7 +476,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
@@ -479,7 +490,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
@@ -497,7 +508,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
@@ -512,7 +523,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
@@ -534,7 +545,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de al menos 10 caracteres."
return
}
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
}
@@ -543,7 +554,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy {
launchBusy(mutation = true) {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
@@ -554,7 +565,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeInspection() {
val visitId = visit?.id ?: return
launchBusy {
launchBusy(mutation = true) {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
@@ -637,17 +648,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap()
}
private fun launchBusy(block: suspend () -> Unit) {
private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
viewModelScope.launch {
busy = true
error = null
try {
block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) {
error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null
} finally {
busy = false
operations.end()
busy = operations.busy
}
}
}
@@ -0,0 +1,16 @@
package com.korexlabs.dhinspeccion
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
internal class OperationTracker {
private var pending = 0
val busy: Boolean get() = pending > 0
fun begin(mutation: Boolean): Boolean {
if (mutation && busy) return false
pending += 1
return true
}
fun end() {
check(pending > 0)
pending -= 1
}
}
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -451,7 +449,6 @@ class SecureSessionStore(context: Context) {
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -530,13 +527,13 @@ class DhRepository(context: Context) {
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
@@ -548,23 +545,19 @@ class DhRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
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
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
companion object {
fun humanError(error: Throwable): String {
if (error is java.io.IOException) {
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
}
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
@@ -0,0 +1,14 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -4,8 +4,6 @@ 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
@@ -176,7 +174,6 @@ private interface FieldFindingsApi {
*/
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)
@@ -227,9 +224,9 @@ class FieldFindingsRepository(context: Context) {
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),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
@@ -241,18 +238,11 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
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
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -4,8 +4,6 @@ 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
@@ -310,7 +308,6 @@ private interface MobileActsApi {
class MobileActsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -429,9 +426,9 @@ class MobileActsRepository(context: Context) {
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -454,18 +451,11 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
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
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -4,8 +4,6 @@ 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.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
@@ -55,7 +53,6 @@ private interface MobileInspectionOpenApi {
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -86,18 +83,11 @@ class MobileInspectionOpenRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
session = refresh(session)
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
}
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException
/** One refresh lock for every API client sharing the encrypted session. */
internal object MobileSessionCoordinator {
private val mutex = Mutex()
suspend fun refresh(
previous: StoredSession,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
request: suspend (RefreshRequest) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
if (latest.refreshToken != previous.refreshToken) return@withLock latest
val response = try {
request(RefreshRequest(previous.refreshToken))
} catch (error: HttpException) {
// Network failures and server outages must not discard the saved login.
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
throw error
}
// A logout or another login during the request must never be undone.
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
save(response)
}
}
@@ -0,0 +1,64 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.time.LocalDate
import java.time.OffsetDateTime
internal data class TechnicalDefinition(
val id: String, val code: String, val name: String, val dataType: String,
val isRequired: Boolean = false, val isActive: Boolean = true,
val unit: String? = null, val options: List<String>? = null,
)
internal data class TechnicalValues(
val assetId: String, val family: FieldInventoryFamily,
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
)
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
private interface TechnicalApi {
@GET("assets/{id}/technical-values")
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
@PUT("assets/{id}/technical-values")
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
internal class TechnicalRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
.build().create(TechnicalApi::class.java)
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
val previous = store.load() ?: error("Sesión no iniciada")
try { return block(previous) }
catch (error: HttpException) { if (error.code() != 401) throw error }
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
return block(current)
}
}
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
val value = text.trim()
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
if (value.isBlank()) return null
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
return when (definition.dataType) {
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
else -> value.takeIf { it.length <= 4000 } ?: invalid()
}
}
@@ -75,6 +75,7 @@ fun FieldFindingScreen(model: MainViewModel) {
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
@@ -110,6 +111,7 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
@@ -122,6 +124,7 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = requestedFindingId
requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId)
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
}
fun requestPhoto(findingId: String) {
@@ -192,6 +195,7 @@ fun FieldFindingScreen(model: MainViewModel) {
}
}
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp))
@@ -11,6 +11,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -80,7 +84,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}
DhTheme {
Surface(Modifier.fillMaxSize()) {
Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true
@@ -106,10 +110,10 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
@@ -203,7 +207,7 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
@@ -329,13 +329,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
model.loadFieldTypes(null)
}
fun startInstallation() {
fun startInstallation(parent: FieldInventoryItem? = null) {
model.clearSelectedFieldAsset()
modeName = ModernInventoryMode.CREATE_INSTALLATION.name
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
parentId = parent?.id
parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(null)
model.loadFieldTypes(parent?.id)
}
fun startSubinstallation() {
@@ -349,6 +349,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
fun chooseParent(item: FieldInventoryItem) {
if (modernItemTypeCode(item) == "yacimiento") {
startInstallation(item)
return
}
keyboard?.hide()
focusManager.clearFocus()
parentId = item.id
@@ -385,8 +389,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
}
if (selectedTypeId != preferred?.id) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
@@ -435,7 +439,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null
val effectiveParent = parentId
scope.launch {
runCatching { currentModernGeo(context) }
.onSuccess { geo ->
@@ -544,6 +548,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
if (selectedCapture != null) {
if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) {
TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy)
}
ModernCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
@@ -735,8 +742,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit)
}
}
if (model.fieldTypes.isEmpty() && !model.busy) {
ModernLocalError("No hay un tipo habilitado para esta ubicación.") {}
if (selectedType == null && !model.busy) {
ModernLocalError("Elegí un Yacimiento en Inventario y tocá Agregar Instalación. No se puede crear una Instalación directamente dentro de un Área.") {}
OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") }
}
if (model.fieldTypes.size > 1) {
@@ -886,7 +894,7 @@ private fun ModernInventoryBrowse(
) {
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") }
val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("yacimiento", "instalacion", "subinstalacion") }
Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) {
@@ -934,7 +942,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación")
StatusPill(when (typeCode) { "subinstalacion" -> "Subinstalación"; "yacimiento" -> "Yacimiento"; else -> "Instalación" })
}
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
@@ -946,11 +954,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit,
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo")
}
if (typeCode == "instalacion") {
if (typeCode in setOf("yacimiento", "instalacion")) {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text("Agregar Subinstalación")
Text(if (typeCode == "yacimiento") "Agregar Instalación" else "Agregar Subinstalación")
}
}
}
@@ -0,0 +1,99 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -0,0 +1,24 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import org.junit.Assert.*
import org.junit.Test
class FieldCoordinatesTest {
@Test fun realGpsIsRoundedToApiPrecision() {
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
}
@Test fun boundsAndZeroRemainValid() {
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
}
@Test fun invalidCoordinatesAreRejected() {
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
catch (_: IllegalArgumentException) { }
}
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
catch (_: IllegalArgumentException) { }
}
}
@@ -38,7 +38,7 @@ class FinalFieldFlowContractTest {
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
}
@Test
@@ -0,0 +1,105 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import java.io.IOException
import kotlinx.coroutines.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class MobileSessionCoordinatorTest {
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
private class Store(var value: StoredSession?) {
var clears = 0
fun load() = value
fun clear() { clears++; value = null }
fun save(r: MobileSessionResponse): StoredSession =
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
}
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
val previous = old(); val store = Store(previous); var calls = 0
val results = coroutineScope {
(1..4).map {
async {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
calls++; delay(20); response()
}
}
}.awaitAll()
}
assertEquals(1, calls)
assertTrue(results.all { it.refreshToken == "r2" })
assertEquals(0, store.clears)
}
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
for (failure in listOf(IOException("offline"), http(500), http(429))) {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
fail("Expected request failure")
} catch (caught: Exception) { assertSame(failure, caught) }
assertEquals(previous, store.value)
assertEquals(0, store.clears)
}
}
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
fail("Expected unauthorized")
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
assertNull(store.value)
assertEquals(1, store.clears)
}
@Test fun cancelledRefreshKeepsSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
fail("Expected cancellation")
} catch (_: CancellationException) { }
assertEquals(previous, store.value)
}
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.clear(); response()
}
fail("Must reject a refresh after logout")
} catch (_: IllegalStateException) { }
assertNull(store.value)
}
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
fail("No refresh allowed for another inspector"); response()
}
fail("Must reject account switch")
} catch (_: IllegalStateException) { }
assertEquals("u2", store.value?.userId)
}
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.value = previous.copy(refreshToken = "new-login")
throw http(401)
}
} catch (_: HttpException) { }
assertEquals("new-login", store.value?.refreshToken)
assertEquals(0, store.clears)
}
}
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.*
import org.junit.Test
class OperationTrackerTest {
@Test fun doubleTapCannotQueueASecondWrite() {
val tracker = OperationTracker()
assertTrue(tracker.begin(mutation = true))
assertFalse(tracker.begin(mutation = true))
tracker.end()
assertFalse(tracker.busy)
assertTrue(tracker.begin(mutation = true))
}
@Test fun BusyRemainsUntilEveryReadCompletes() {
val tracker = OperationTracker()
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
tracker.end(); assertTrue(tracker.busy)
assertFalse(tracker.begin(true))
tracker.end(); assertFalse(tracker.busy)
}
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
val tracker = OperationTracker()
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
tracker.end(); assertFalse(tracker.begin(true))
tracker.end(); assertTrue(tracker.begin(true))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
assertEquals(29, BuildConfig.VERSION_CODE)
assertEquals("0.19.1-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -0,0 +1,28 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
@@ -0,0 +1,407 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length
+ media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 500),
warnings: failedFacets,
};
}
}
@@ -24,6 +24,7 @@ import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
@@ -31,6 +32,7 @@ import { InventoryTechnicalValuesService } from './inventory-technical-values.se
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@@ -59,11 +61,13 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetTypesService,
AssetsService,
InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
@@ -142,27 +142,8 @@ export class AssetOperationalRelationsService {
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
const [row] = (await manager.query(`
INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
@@ -170,9 +151,6 @@ export class AssetOperationalRelationsService {
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// F5/F6: changing the Area operator is a temporal relation event only.
// Existing Inventory keeps its creation/historical operator snapshot and
// physical hierarchy unchanged. Runtime ownership must resolve this row.
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -181,7 +159,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
: undefined,
}, manager);
return created;
@@ -212,7 +190,6 @@ export class AssetOperationalRelationsService {
});
}
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(`
UPDATE area_company_relations
@@ -232,6 +209,7 @@ export class AssetOperationalRelationsService {
afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager);
@@ -4,10 +4,14 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure')
export class InventoryStructureController {
constructor(private readonly inventoryStructure: InventoryStructureService) {}
constructor(
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
@Get()
@RequirePermissions('assets.read')
@@ -24,6 +28,17 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search);
}
@Post('simple')
@RequirePermissions('assets.create')
createSimple(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.simpleInventoryStructure.create(dto, principal, request);
}
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
@Post()
@RequirePermissions('assets.create')
create(
@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
@@ -29,11 +32,22 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
@@ -44,7 +58,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const dossier = await this.activity.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
@@ -145,6 +159,9 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return {
asset: {
@@ -194,6 +211,7 @@ export class MergedInventoryDossierService {
media,
versions,
timeline: timeline.slice(0, 1000),
warnings,
};
}
}
@@ -0,0 +1,323 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
};
@Injectable()
export class SimpleInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async create(
dto: CreateInventoryStructureDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
try {
return await this.dataSource.transaction(async (manager) => {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
let operationalAreaId: string | null = null;
let operatorCompanyId: string | null = null;
if (dto.kind === 'YACIMIENTO') {
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
operationalAreaId = parent.id;
operatorCompanyId = dto.operatorCompanyId;
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
operationalAreaId = parent?.operationalAreaId ?? null;
operatorCompanyId = parent?.operatorCompanyId ?? null;
if (!operationalAreaId || !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
});
}
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
message: 'Empresa operadora sólo corresponde al Yacimiento',
});
}
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const rows = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,NULL,$5::uuid,
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
generatedCode,
dto.name,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
`inventory-simple:${dto.kind.toLowerCase()}`,
family ? `Tipo: ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = rows[0]?.id;
if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE
SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`, [id, dto.name, principal.userId]);
}
const versionNumber = await this.history.capture(
manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [
id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta de Yacimiento con Empresa operadora vinculada al Área'
: 'Alta simple de Inventario',
versionNumber,
request.requestId,
principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',
entityId: id,
afterData: created as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
simpleInventoryFlow: true,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un registro con ese código' });
}
throw error;
}
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const sql = kind === 'EMPRESA'
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind, 'EMPRESA'>]];
const rows = (await manager.query(sql, params)) as StructureTypeRow[];
if (!rows[0]) throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', message: `El nivel ${kind} no está configurado` });
return rows[0];
}
private async requireParent(
manager: EntityManager,
kind: InventoryStructureKind,
parentId: string | null,
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` });
return null;
}
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
FOR KEY SHARE OF asset
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent || parent.typeCode !== expectedType) {
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
}
return parent;
}
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
const rows = await manager.query(`
SELECT 1
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
LIMIT 1
FOR KEY SHARE OF relation
`, [areaId, companyId]);
if (!rows[0]) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
});
}
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
return null;
}
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
const rows = (await manager.query(`
SELECT id,code,name,level::text AS level
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
LIMIT 1
FOR KEY SHARE
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
const compatible = await manager.query(`
SELECT 1 FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`, [family.id, parent.inventoryFamilyId]);
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
}
return family;
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
: kind === 'AREA' ? 'AREA'
: kind === 'YACIMIENTO' ? 'YAC'
: kind === 'INSTALACION' ? 'INST'
: 'SUB';
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
private async loadView(manager: EntityManager, id: string) {
const rows = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',family.id,'code',family.code,'name',family.name,'level',family.level) END AS "inventoryFamily",
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
@@ -0,0 +1,179 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F7SimpleInventoryContext1790110200000 implements MigrationInterface {
name = 'F7SimpleInventoryContext1790110200000';
public async up(q: QueryRunner): Promise<void> {
await q.query(`
UPDATE asset_types
SET description='Yacimiento. Pertenece a un Área, tiene una única Empresa operadora vigente y puede recibir Hallazgos.',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='yacimiento'
`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere una Empresa operadora'; END IF;
SELECT type.operational_role INTO company_role
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa operadora del Yacimiento no es válida';
END IF;
IF NOT EXISTS(
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=NEW.parent_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa del Yacimiento debe estar vinculada como explotadora del Área';
END IF;
IF NEW.concession_type_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de concesión indicado no es válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre';
END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Yacimiento debe tener una Empresa operadora'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no está habilitado dentro del Tipo de instalación padre';
END IF;
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación debe pertenecer a un Yacimiento con Empresa operadora'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`
UPDATE asset_types
SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='yacimiento'
`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
}
}
@@ -32,7 +32,7 @@ test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root
assert.match(migration, /SET can_be_root=false/);
});
test('Authoritative manual creation keeps Area physical and stores Company plus concession on Yacimiento', () => {
test('Historical authoritative endpoint keeps the former Yacimiento Company plus concession contract for compatibility', () => {
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
@@ -66,27 +66,60 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('Authoritative Configuration presents exact physical relationships and technical families', () => {
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(configPage, /El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento/);
assert.match(configPage, /Tipo de concesión/);
assert.match(configPage, /Empresa relacionada/);
assert.match(configPage, /Yacimiento → Instalación/);
assert.match(configPage, /Instalación → Subinstalación/);
assert.match(configPage, /installationFamilies\.map/);
assert.match(configPage, /subinstallationFamilies\.map/);
assert.match(configPage, /Hallazgos asociados/);
assert.match(configPage, /Tipos y campos/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/);
assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/);
assert.match(configPage, /Tipos de Instalación/);
assert.match(configPage, /Tipos de Subinstalación/);
assert.match(configPage, /Puede estar dentro de/);
assert.match(configPage, /\{ value: 'TEXT', label: 'Texto' \}/);
assert.match(configPage, /\{ value: 'NUMBER', label: 'Número' \}/);
assert.match(configPage, /\{ value: 'DATE', label: 'Fecha' \}/);
assert.match(configPage, /\{ value: 'BOOLEAN', label: 'Sí \/ No' \}/);
assert.doesNotMatch(configPage, /Hallazgos asociados/);
});
test('Authoritative Web creation exposes Departamento as root and Yacimiento as operational owner', () => {
test('F7 Web creation keeps the fixed hierarchy and asks only the required simple context', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Empresa relacionada/);
assert.match(createPage, /Tipo de concesión/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /<strong>Yacimiento<\/strong>/);
assert.match(createPage, /AREA: 'Departamento'/);
assert.match(createPage, /YACIMIENTO: 'Área'/);
assert.match(createPage, /INSTALACION: 'Yacimiento'/);
assert.match(createPage, /SUBINSTALACION: 'Instalación'/);
assert.match(createPage, /listCompaniesForInventoryArea/);
assert.match(createPage, /Empresa operadora/);
assert.match(createPage, /ALTA RÁPIDA/);
assert.doesNotMatch(createPage, /Tipo de concesión/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
});
test('F7 canonical detail uses one short everyday profile and keeps advanced administration separate', () => {
const detailRoute = source('../web-v2/src/pages/InventoryDetailPage.tsx');
const detail = source('../web-v2/src/pages/SimpleInventoryDetailPage.tsx');
assert.match(detailRoute, /isSimpleInventoryAsset\(asset\) && !advanced/);
assert.match(detailRoute, /<SimpleInventoryDetailPage initialAsset=\{asset\}/);
assert.match(detail, /DEPARTAMENTO/);
assert.match(detail, /AREA/);
assert.match(detail, /YACIMIENTO/);
assert.match(detail, /INSTALACION/);
assert.match(detail, /SUBINSTALACION/);
assert.match(detail, />Resumen</);
assert.match(detail, />Actividad</);
assert.match(detail, />Ubicación</);
assert.match(detail, />Fotos</);
assert.match(detail, />Cambios</);
assert.match(detail, /Empresa operadora/);
assert.match(detail, /Tipo técnico/);
assert.match(detail, /AssetTechnicalDataPanel/);
assert.match(detail, /AssetDossierPanel/);
assert.match(detail, /Administración avanzada/);
assert.doesNotMatch(detail, /Tipo de concesión/);
assert.doesNotMatch(detail, /AssetProvenancePanel/);
});
+2 -2
View File
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 28/);
assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /versionCode = 29/);
assert.match(gradle, /versionName = "0\.19\.1"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
@@ -88,14 +88,21 @@ test('F6.1 WEB Inventory searches use physical scope and never the operator snap
}
});
test('WEB creation captures Company and concession only while creating a Yacimiento', () => {
test('F7 WEB creation selects one Area-linked Company for Yacimiento and retires concession from the simple flow', () => {
const page = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const structure = source('src/asset-master/inventory-structure.service.ts');
const api = source('../web-v2/src/lib/inventoryStructureApi.ts');
const simpleStructure = source('src/asset-master/simple-inventory-structure.service.ts');
const migration = source('src/database/migrations/1790110200000-f7-simple-inventory-context.ts');
assert.match(page, /requiresYacimientoContext = kind === 'YACIMIENTO'/);
assert.match(page, /Empresa relacionada/);
assert.match(page, /Tipo de concesión/);
assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/);
assert.match(page, /concessionTypeId:concessionTypeId \|\| null/);
assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/);
assert.match(page, /const requiresCompany = kind === 'YACIMIENTO'/);
assert.match(page, /listCompaniesForInventoryArea\(parentId\)/);
assert.match(page, /Empresa operadora/);
assert.match(page, /operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(page, /Tipo de concesión/);
assert.doesNotMatch(page, /concessionTypeId/);
assert.match(api, /inventory-structure\/simple/);
assert.match(simpleStructure, /requireAreaCompanyRelation/);
assert.match(simpleStructure, /INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA/);
assert.match(migration, /La Empresa del Yacimiento debe estar vinculada como explotadora del Área/);
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
});
@@ -0,0 +1,47 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { ActivityDossierService } from '../../src/asset-master/activity-dossier.service';
test('F7 Actividad no usa DISTINCT con ORDER BY COALESCE en visitas', () => {
const source = readFileSync('src/asset-master/activity-dossier.service.ts', 'utf8');
assert.doesNotMatch(source, /SELECT\s+DISTINCT\s+visit\.id/i);
assert.match(source, /ORDER BY COALESCE\(visit\.actual_started_at, visit\.planned_start_at, visit\.created_at\) DESC/);
assert.match(source, /EXISTS \(\s*SELECT 1\s*FROM inspection_visit_assets/s);
});
test('F7 una faceta auxiliar defectuosa no derriba todo el expediente', async () => {
const dataSource = {
query: async (sql: string) => {
if (sql.includes('FROM assets\n WHERE id = $1')) {
return [{
id: 'bcba740f-cc84-48b3-8d06-88a9c439e25c',
code: 'YAC-0005',
name: 'Agua Botada',
commonName: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
}];
}
if (sql.includes('inspection_finding_communications')) {
throw new Error('simulated optional facet failure');
}
return [];
},
};
const service = new ActivityDossierService(dataSource as never);
const dossier = await service.dossier('bcba740f-cc84-48b3-8d06-88a9c439e25c') as {
asset: { code: string; name: string };
counters: { inspections: number; findings: number };
warnings: string[];
timeline: unknown[];
};
assert.equal(dossier.asset.code, 'YAC-0005');
assert.equal(dossier.asset.name, 'Agua Botada');
assert.equal(dossier.counters.inspections, 0);
assert.equal(dossier.counters.findings, 0);
assert.deepEqual(dossier.warnings, ['communications']);
assert.deepEqual(dossier.timeline, []);
});
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const mergedDossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const dossierPanel = readFileSync('../web-v2/src/features/assets/AssetDossierPanel.tsx', 'utf8');
const simpleDetail = readFileSync('../web-v2/src/pages/SimpleInventoryDetailPage.tsx', 'utf8');
test('F7 mantiene Actividad disponible para Yacimiento', () => {
assert.match(simpleDetail, /kind === 'YACIMIENTO'/);
assert.match(simpleDetail, /setTab\('activity'\)/);
assert.match(simpleDetail, /AssetDossierPanel assetId=\{asset\.id\}/);
});
test('F7 no hace depender el dossier territorial del subsistema de fusión', () => {
assert.match(mergedDossier, /MERGEABLE_DOSSIER_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(mergedDossier, /if \(!MERGEABLE_DOSSIER_TYPES\.has\(requestedTypeCode\)\)/);
assert.match(mergedDossier, /private readonly activity: ActivityDossierService/);
assert.match(mergedDossier, /return await this\.activity\.dossier\(requestedAssetId\)/);
});
test('F7 la consulta auxiliar de merge nunca bloquea la carga de Actividad', () => {
assert.match(dossierPanel, /getInventoryMergeStatus\(assetId\)\.catch\(\(\) => null\)/);
assert.match(dossierPanel, /getAssetDossier\(assetId\)/);
});
+136
View File
@@ -0,0 +1,136 @@
# F7 · Modelo simple de Inventarios
Este documento fija el nuevo eje funcional de Inventarios para DH Inspección V2. Su objetivo es reducir la complejidad visible y orientar toda la experiencia al trabajo real de inspección.
## 1. Jerarquía
La estructura física es única:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
- Departamento y Área organizan el territorio.
- Yacimiento, Instalación y Subinstalación son niveles válidos para registrar Hallazgos.
- Una Instalación pertenece a un Yacimiento.
- Una Subinstalación pertenece a una Instalación.
## 2. Empresas
- Un Área puede estar explotada por varias Empresas al mismo tiempo.
- Cada Yacimiento tiene una sola Empresa operadora vigente.
- La Empresa del Yacimiento se elige entre las Empresas vinculadas al Área.
- Instalaciones y Subinstalaciones heredan el contexto de Empresa desde su Yacimiento.
- Cambiar la Empresa de un Yacimiento no mueve ni recrea su Inventario físico.
## 3. Datos simples
Todos los registros usan una base común reducida:
- Nombre.
- Código, opcional y autogenerable.
- Ubicación jerárquica.
- Estado.
- GPS cuando corresponda.
- Observaciones sólo cuando aporten valor.
Departamento, Área y Yacimiento no necesitan un constructor técnico complejo.
Instalaciones y Subinstalaciones pueden tener campos específicos configurados por el Superadmin técnico.
Los tipos de campo visibles para la puesta a punto deben mantenerse simples:
- Texto.
- Número.
- Fecha.
- Sí / No.
Teléfono, email o identificadores simples pueden resolverse como Texto. GPS es un dato básico del registro, no un atributo técnico configurable.
## 4. Administración de tipos
Sólo el Superadmin técnico modifica la configuración.
La pantalla administrativa debe permitir únicamente:
1. Crear/ocultar Tipos de Instalación.
2. Crear/ocultar Tipos de Subinstalación.
3. Definir qué Tipos de Subinstalación puede contener cada Tipo de Instalación.
4. Agregar, mostrar/ocultar y marcar como obligatorios los campos simples de cada tipo.
Los términos técnicos internos como `inventory_families`, reglas de compatibilidad o schemas no deben exponerse al usuario.
## 5. Cambio de tipo / función
La identidad física del elemento no cambia cuando cambia su función.
Ejemplo: `TK-57` sigue siendo `TK-57`, aunque hoy funcione como tanque de petróleo y mañana como tanque de agua.
El cambio debe registrarse cronológicamente con:
- tipo / función anterior;
- tipo / función nueva;
- fecha efectiva;
- observación opcional.
No se crea un Inventario nuevo para representar un cambio de función.
## 6. Cronología útil
Para Yacimiento, Instalación y Subinstalación la cronología debe concentrar sólo hechos operativos relevantes:
- Hallazgos.
- Acta asociada.
- Empresa operadora correspondiente a la fecha.
- Cambio de tipo / función.
- Cambio de ubicación cuando corresponda.
- Alta e inactivación.
La experiencia normal no debe obligar a navegar por procedencia, snapshots, registros técnicos o paneles administrativos separados.
## 7. Filosofía de la APK
La APK existe para **inspeccionar, generar Acta y generar Informe**.
El Inspector no administra Inventarios como tarea separada.
La mayoría de las altas reales ocurren en campo mientras se registra un Hallazgo.
Flujo:
1. La Inspección se planifica desde WEB y ya conoce el Yacimiento.
2. El Inspector abre la Inspección al llegar.
3. Pulsa `+ Crear hallazgo`.
4. Decide si el Hallazgo corresponde a Yacimiento, Instalación o Subinstalación.
5. Si la Instalación no existe, puede crearla rápidamente sin abandonar el flujo.
6. Si necesita una Subinstalación que no existe, puede crearla rápidamente dentro de la Instalación seleccionada.
7. El catálogo ayuda, pero `OTRO` siempre está disponible.
8. Con el tiempo, el Inventario se completa naturalmente y disminuye la necesidad de altas nuevas.
## 8. Planificación y recorrido
La planificación WEB define:
- Área.
- Empresa.
- Yacimiento.
- Inspector.
- Fecha/hora.
- Instalaciones/Subinstalaciones elegidas preventivamente.
- pendientes por vencer, mostrados por elemento a controlar.
- pendientes vencidos, mostrados por elemento a controlar.
La APK presenta ese conjunto como recorrido sugerido. No es una ruta rígida: el Inspector puede registrar Hallazgos nuevos en cualquier momento.
## 9. Actas e Informes
- Abrir una Inspección no crea automáticamente un Acta.
- El Inspector pulsa `+ Nueva Acta` cuando empieza a documentar.
- El número definitivo del Acta se genera al cerrarla.
- Una Inspección puede tener varias Actas.
- Al cerrar un Acta se pueden agregar uno o más acompañantes con Nombre + Email.
- El Acta se envía a Empresa, Inspector y acompañantes.
- El Informe se genera automáticamente en Word y se envía únicamente al Inspector que realizó el Acta.
- La firma o validación legal definitiva queda para una etapa posterior; la prioridad actual es la usabilidad.
## 10. Regla de diseño
Si una pantalla, campo o estado necesita explicación técnica para ser usado correctamente, debe simplificarse antes de incorporarse a la experiencia cotidiana.
+33 -11
View File
@@ -4,37 +4,59 @@ set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx"
APP="$ROOT/web-v2/src/app/App.tsx"
PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
CREATE_PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
ADMIN_PAGE="$ROOT/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx"
API="$ROOT/web-v2/src/lib/inventoryStructureApi.ts"
for file in "$LAYOUT" "$APP" "$PAGE"; do
for file in "$LAYOUT" "$APP" "$CREATE_PAGE" "$ADMIN_PAGE" "$API"; do
test -f "$file" || {
echo "F4 WEB contract: falta $file"
echo "F7 WEB contract: falta $file"
exit 1
}
done
if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then
echo "F4 WEB contract: Relevamientos no debe volver al menú principal"
echo "F7 WEB contract: Relevamientos no debe volver al menú principal"
exit 1
fi
if grep -Fq 'path="/relevamiento/*"' "$APP"; then
echo "F4 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
echo "F7 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
exit 1
fi
grep -Fq 'path="/inventarios/nuevo"' "$APP"
grep -Fq 'InventoryCreatePage' "$APP"
for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$PAGE"
for kind in DEPARTAMENTO AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$CREATE_PAGE"
done
if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then
echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural"
if grep -Fq "kind: 'EQUIPO'" "$CREATE_PAGE"; then
echo "F7 WEB contract: EQUIPO no debe restaurarse como nivel estructural"
exit 1
fi
grep -Fq 'getInventoryFamilyFindings' "$PAGE"
# F7: Yacimiento elige una Empresa ya vinculada al Área; concesión deja de ser una decisión del alta cotidiana.
grep -Fq 'listCompaniesForInventoryArea' "$CREATE_PAGE"
if grep -Fq 'concessionTypeId' "$CREATE_PAGE"; then
echo "F7 WEB contract: el alta simple no debe exponer Tipo de concesión"
exit 1
fi
echo 'F4 WEB contract: OK'
# El alta normal usa el endpoint simple; el endpoint histórico queda sólo por compatibilidad transitoria.
grep -Fq "'/inventory-structure/simple'" "$API"
# La configuración técnica visible se reduce a tipos, contención y campos sencillos.
grep -Fq 'Tipos y campos' "$ADMIN_PAGE"
grep -Fq 'Tipos de Instalación' "$ADMIN_PAGE"
grep -Fq 'Tipos de Subinstalación' "$ADMIN_PAGE"
grep -Fq '+ Agregar campo' "$ADMIN_PAGE"
# El catálogo de Hallazgos no debe mezclarse con el formulario de alta de Inventario.
if grep -Fq 'getInventoryFamilyFindings' "$CREATE_PAGE"; then
echo "F7 WEB contract: el alta simple no debe cargar catálogo de Hallazgos"
exit 1
fi
echo 'F7 WEB contract: OK'
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env bash
set -Eeuo pipefail
APP="/var/www/dhv2.korexlabs.com"
BACKUP_ROOT="/root/DH_V2_BACKUPS"
LOCK="/var/lock/dhv2-deploy.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "Otro deploy de DH V2 ya está en curso. No se realiza ninguna modificación."
exit 0
fi
DEPLOY_REF="${DHV2_DEPLOY_REF:-deploy}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP="$BACKUP_ROOT/GITEA_DEPLOY_${STAMP}"
STAGE="/root/dhv2-gitea-stage-${STAMP}"
LOG="/tmp/dhv2-gitea-deploy-${STAMP}.log"
API_TEST_IMAGE="dhv2-api:gitea-${STAMP}"
WEB_TEST_IMAGE="dhv2-web:gitea-${STAMP}"
PHASE="bootstrap"
PREV_SHA=""
TARGET_SHA=""
EXPECTED_API_VERSION=""
EXPECTED_WEB_VERSION=""
APP_TOUCHED=0
cd "$APP"
exec > >(tee -a "$LOG") 2>&1
cleanup() {
set +e
git worktree remove --force "$STAGE" >/dev/null 2>&1 || true
rm -rf "$STAGE"
docker image rm "$API_TEST_IMAGE" "$WEB_TEST_IMAGE" >/dev/null 2>&1 || true
}
publish_status() {
local rc="${1:-1}"
set +e
local outcome="failure"
[ "$rc" -eq 0 ] && outcome="success"
local current="unknown"
current="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
local status_file log_file status_blob log_blob tree commit
status_file="$(mktemp /tmp/dhv2-status.XXXXXX)"
log_file="$(mktemp /tmp/dhv2-log.XXXXXX)"
{
echo "status=$outcome"
echo "exit_code=$rc"
echo "phase=$PHASE"
echo "timestamp=$(date --iso-8601=seconds)"
echo "deploy_ref=$DEPLOY_REF"
echo "previous_sha=${PREV_SHA:-unknown}"
echo "target_sha=${TARGET_SHA:-unknown}"
echo "current_sha=$current"
echo "api_version=${EXPECTED_API_VERSION:-unknown}"
echo "web_version=${EXPECTED_WEB_VERSION:-unknown}"
echo "app_touched=$APP_TOUCHED"
echo "backup=${BACKUP:-unknown}"
} > "$status_file"
tail -n 500 "$LOG" > "$log_file" 2>/dev/null || true
status_blob="$(git hash-object -w "$status_file" 2>/dev/null || true)"
log_blob="$(git hash-object -w "$log_file" 2>/dev/null || true)"
if [ -n "$status_blob" ] && [ -n "$log_blob" ]; then
tree="$(printf '100644 blob %s\tdeploy.log\n100644 blob %s\tstatus.txt\n' "$log_blob" "$status_blob" | git mktree 2>/dev/null || true)"
if [ -n "$tree" ]; then
commit="$(printf 'deploy-status: %s · phase %s\n' "$outcome" "$PHASE" | git -c user.name='DH V2 Deploy Bot' -c user.email='deploy@dhv2.local' commit-tree "$tree" 2>/dev/null || true)"
[ -z "$commit" ] || git push --force origin "$commit:refs/heads/deploy-status" >/dev/null 2>&1 || true
fi
fi
rm -f "$status_file" "$log_file"
}
on_exit() {
local rc=$?
trap - EXIT ERR
cleanup
publish_status "$rc"
exit "$rc"
}
trap on_exit EXIT
rollback() {
local rc=$?
trap - ERR
PHASE="rollback"
echo
echo "============================================================"
echo " DH V2 · DEPLOY FALLÓ · ROLLBACK"
echo "============================================================"
cd "$APP"
if [ "$APP_TOUCHED" -eq 1 ] && [ -n "${PREV_SHA:-}" ]; then
echo "Restaurando aplicación al commit previo: $PREV_SHA"
git reset --hard "$PREV_SHA" || true
docker compose build api web </dev/null || true
docker compose up -d --no-deps --force-recreate api web </dev/null || true
else
echo "El candidato falló antes de modificar producción; no se reconstruye ni reinicia la aplicación activa."
fi
echo
echo "Estado actual:"
docker compose ps -a </dev/null || true
if [ "$APP_TOUCHED" -eq 1 ]; then
echo
echo "Últimos logs:"
docker compose logs --tail=160 api web </dev/null || true
fi
echo
if [ -d "$BACKUP" ]; then
echo "Backup PRE disponible en: $BACKUP"
echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta."
else
echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción."
fi
exit "$rc"
}
trap rollback ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY DESDE GITEA · $DEPLOY_REF"
echo "============================================================"
for cmd in git docker curl tar node; do
command -v "$cmd" >/dev/null || { echo "ERROR: falta $cmd"; false; }
done
[ -d .git ] || { echo "ERROR: $APP no es repositorio Git"; false; }
[ -f .env ] || { echo "ERROR: falta $APP/.env"; false; }
ORIGIN_URL="$(git remote get-url origin)"
EXPECTED_ORIGIN="https://git.korexlabs.com.ar/admin/dh-inspeccion-v2.git"
if [ "$ORIGIN_URL" != "$EXPECTED_ORIGIN" ]; then
echo "ERROR: origin no apunta al Gitea autorizado."
echo "Actual: $ORIGIN_URL"
echo "Esperado: $EXPECTED_ORIGIN"
false
fi
git config --global --get-all safe.directory 2>/dev/null | grep -Fxq "$APP" || git config --global --add safe.directory "$APP"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "ERROR: hay cambios locales versionados en producción."
git status --short
false
fi
PREV_SHA="$(git rev-parse HEAD)"
PHASE="fetch"
git fetch origin "$DEPLOY_REF"
TARGET_SHA="$(git rev-parse "origin/$DEPLOY_REF")"
echo "Actual: $PREV_SHA"
echo "Objetivo: $TARGET_SHA"
if [ "$TARGET_SHA" = "$PREV_SHA" ]; then
echo "Producción ya está en el commit autorizado."
PHASE="complete"
exit 0
fi
if ! git merge-base --is-ancestor "$PREV_SHA" "$TARGET_SHA"; then
echo "ERROR: origin/$DEPLOY_REF no es fast-forward desde producción."
false
fi
PHASE="candidate-preflight"
rm -rf "$STAGE"
git worktree add --detach "$STAGE" "$TARGET_SHA" >/dev/null
EXPECTED_API_VERSION="$(node -p "require('$STAGE/api-v3/package.json').version")"
EXPECTED_WEB_VERSION="$(node -p "require('$STAGE/web-v2/package.json').version")"
echo "API candidata: $EXPECTED_API_VERSION"
echo "WEB candidata: $EXPECTED_WEB_VERSION"
docker compose --env-file "$APP/.env" -f "$STAGE/docker-compose.yml" config >/dev/null
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find "$STAGE/scripts" -type f -name '*.sh' -print0)
echo
echo "========== TEST API CANDIDATA =========="
docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
docker run --rm \
-v "$STAGE/api-v3/test:/app/test:ro" \
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
-v "$STAGE/web-v2:/web-v2:ro" \
-v "$STAGE/android-app:/android-app:ro" \
"$API_TEST_IMAGE" npm test </dev/null
echo
echo "========== BUILD WEB CANDIDATA =========="
docker build -t "$WEB_TEST_IMAGE" "$STAGE/web-v2" </dev/null
PHASE="backup"
echo
echo "========== BACKUP PRE =========="
install -d -m 700 "$BACKUP"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-before.dump"
tar \
--exclude='./.git' \
--exclude='./.env' \
--exclude='*/node_modules' \
--exclude='*/dist' \
--exclude='*.zip' \
--exclude='*.tar.gz' \
--exclude='*.tgz' \
-czf "$BACKUP/source-before.tar.gz" .
install -m 600 .env "$BACKUP/.env"
git rev-parse HEAD > "$BACKUP/previous.sha"
printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha"
docker compose ps -a > "$BACKUP/docker-before.txt"
(
cd "$BACKUP"
sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="fast-forward"
echo
echo "========== FAST-FORWARD =========="
git log --oneline --no-decorate "$PREV_SHA..$TARGET_SHA"
APP_TOUCHED=1
git merge --ff-only "origin/$DEPLOY_REF"
PHASE="build"
echo
echo "========== BUILD PRODUCCIÓN =========="
docker compose build api migrate web </dev/null
PHASE="migrations"
echo
echo "========== MIGRACIONES =========="
docker compose --profile tools run --rm migrate </dev/null
docker compose --profile tools run --rm migrate npm run migration:show </dev/null | tee "$BACKUP/migrations.txt"
grep -Fq 'Pending migrations: no' "$BACKUP/migrations.txt"
PHASE="recreate"
echo
echo "========== RECREATE API + WEB =========="
docker compose up -d --no-deps --force-recreate api web </dev/null
PHASE="health"
echo
echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api
false
fi
cat "$BACKUP/health.json"
echo
grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json"
grep -Fq '"database":"ok"' "$BACKUP/health.json"
WEB_CODE="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:8182/)"
[ "$WEB_CODE" = "200" ] || { echo "ERROR: WEB HTTP $WEB_CODE"; false; }
PHASE="verify"
echo
echo "========== VERIFICACIÓN FINAL =========="
docker compose ps -a | tee "$BACKUP/docker-after.txt"
if docker compose ps --status running --services | grep -Fxq api && docker compose ps --status running --services | grep -Fxq web && docker compose ps --status running --services | grep -Fxq db; then
echo "Servicios críticos: OK"
else
echo "ERROR: falta un servicio crítico en ejecución."
false
fi
PHASE="post-backup"
docker compose exec -T db sh -lc 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' </dev/null > "$BACKUP/database-after.dump"
git rev-parse HEAD > "$BACKUP/deployed.sha"
printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt"
(
cd "$BACKUP"
sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt
sha256sum -c SHA256SUMS.txt
)
chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true
PHASE="complete"
trap - ERR
echo
echo "============================================================"
echo " DH V2 · DEPLOY OK"
echo "============================================================"
echo "Commit: $TARGET_SHA"
echo "API: $EXPECTED_API_VERSION"
echo "WEB: $EXPECTED_WEB_VERSION"
echo "Backup: $BACKUP"
echo "============================================================"
@@ -81,10 +81,12 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
const load = () => {
setLoading(true);
setError('');
setMergeStatus(null);
Promise.all([
getAssetDossier(assetId),
getAsset(assetId),
getInventoryMergeStatus(assetId),
// La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario.
getInventoryMergeStatus(assetId).catch(() => null),
])
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier);
+6 -2
View File
@@ -22,6 +22,7 @@ export interface InventoryTechnicalValues {
}
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOption { id:string; code:string; name:string; }
export interface InventoryAreaCompanyOption extends InventoryStructureOption { commonName?:string|null; typeName?:string; }
export interface InventoryStructureOptions {
independentMasters:InventoryStructureLevel[];
levels:InventoryStructureLevel[];
@@ -45,7 +46,7 @@ export interface CreatedInventoryStructure {
type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
operatorCompany?:null|{id:string;code:string;name:string};
concessionType?:null|{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels?:string[]};
}
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
@@ -53,6 +54,9 @@ export async function listInventoryStructureParents(kind:InventoryStructureKind,
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
}
export async function listCompaniesForInventoryArea(areaId:string){
return (await apiRequest<{data:InventoryAreaCompanyOption[]}>(`/asset-operational-relations/areas/${areaId}/companies`)).data;
}
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
@@ -63,4 +67,4 @@ export function updateInventoryFamily(familyId:string,input:{name?:string;parent
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure/simple',{method:'POST',body:JSON.stringify(input)});}
+17 -3
View File
@@ -78,6 +78,14 @@ function hasAttributeValue(definition: AssetAttributeDefinition, value: unknown)
return value !== undefined && value !== null && String(value).trim() !== '';
}
function normalizeStructuralType(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase();
}
export function AssetEditorPage() {
const { id } = useParams();
const editing = Boolean(id);
@@ -343,9 +351,15 @@ export function AssetEditorPage() {
? 'companies'
: 'territory';
const structuralTypeCode = selectedType?.code.trim().toLowerCase() ?? '';
const compactStructuralSummary = editing
&& (structuralTypeCode === 'instalacion' || structuralTypeCode === 'subinstalacion');
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
const structuralTypeName = normalizeStructuralType(selectedType?.name);
const compactStructuralSummary = editing && (
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
.includes(structuralTypeCode)
|| structuralTypeName === 'instalacion'
|| structuralTypeName === 'instalacion de superficie'
|| structuralTypeName === 'subinstalacion'
);
const technicalFilledCount = definitions.filter(
(definition) => hasAttributeValue(definition, attributeValues[definition.id]),
@@ -1,203 +1,305 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api';
import type { AssetType, FindingAdminCatalog } from '../lib/api';
import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
import {
createInventoryFamily,
createInventoryFamilyAttribute,
getInventoryFamilyAttributes,
listInventoryFamiliesAdmin,
updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi';
type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION';
type StructuralField = {
label: string;
detail: string;
required?: boolean;
relation?: boolean;
};
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [
{
kind: 'EMPRESA',
label: 'Empresa',
description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.',
fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }],
},
{
kind: 'DEPARTAMENTO',
label: 'Departamento',
description: 'Raíz territorial. No depende de ningún otro nivel.',
fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }],
},
{
kind: 'AREA',
label: 'Área',
description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.',
fields: [
{ label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true },
{ label: 'Nombre', detail: 'Nombre del Área.', required: true },
],
},
{
kind: 'YACIMIENTO',
label: 'Yacimiento',
description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.',
fields: [
{ label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true },
{ label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true },
{ label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true },
{ label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true },
],
},
{
kind: 'INSTALACION',
label: 'Instalación',
description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.',
fields: [
{ label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true },
{ label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true },
],
},
{
kind: 'SUBINSTALACION',
label: 'Subinstalación',
description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.',
fields: [
{ label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true },
{ label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true },
],
},
const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' },
{ value: 'NUMBER', label: 'Número' },
{ value: 'DATE', label: 'Fecha' },
{ value: 'BOOLEAN', label: 'Sí / No' },
];
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
function fieldTypeLabel(value: InventoryFamilyAttributeDataType) {
return FIELD_TYPES.find((item) => item.value === value)?.label
?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value);
}
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null;
function fieldCode(name: string) {
return name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100) || `campo_${Date.now()}`;
}
export function AuthoritativeInventoryConfigPage() {
const [types, setTypes] = useState<AssetType[]>([]);
const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage');
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('AREA');
const [level, setLevel] = useState<FamilyLevel>('INSTALLATION');
const [selectedFamilyId, setSelectedFamilyId] = useState('');
const [attributes, setAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [newTypeName, setNewTypeName] = useState('');
const [newTypeParents, setNewTypeParents] = useState<string[]>([]);
const [newFieldName, setNewFieldName] = useState('');
const [newFieldType, setNewFieldType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [newFieldRequired, setNewFieldRequired] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const refreshFamilies = async () => {
const loaded = await listInventoryFamiliesAdmin();
setFamilies(loaded);
return loaded;
};
useEffect(() => {
Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedTypes, loadedFamilies, loadedCatalog]) => {
setTypes(loadedTypes);
setFamilies(loadedFamilies);
setCatalog(loadedCatalog);
})
refreshFamilies()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!;
const selectedType = canonicalType(types, selectedKind);
const commonAttributes = useMemo(() => {
const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? [];
return selectedKind === 'INSTALACION'
? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion')
: attributes;
}, [selectedKind, selectedType]);
const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false);
const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false);
const activeFindings = catalog.items.filter((item) => item.isActive).length;
const installationFamilies = useMemo(
() => families.filter((family) => family.level === 'INSTALLATION'),
[families],
);
const visibleFamilies = useMemo(
() => families.filter((family) => family.level === level),
[families, level],
);
const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null;
useEffect(() => {
const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null;
setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? '');
}, [level, visibleFamilies]);
useEffect(() => {
if (!selectedFamilyId) {
setAttributes([]);
return;
}
getInventoryFamilyAttributes(selectedFamilyId)
.then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))))
.catch((requestError) => setError(errorMessage(requestError)));
}, [selectedFamilyId]);
const run = async (action: () => Promise<void>, message: string) => {
setSaving(true);
setError('');
setSuccess('');
try {
await action();
setSuccess(message);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const createType = async () => {
if (!newTypeName.trim()) return;
if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) {
setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.');
return;
}
await run(async () => {
const created = await createInventoryFamily({
level,
name: newTypeName.trim(),
parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined,
});
await refreshFamilies();
setSelectedFamilyId(created.id);
setNewTypeName('');
setNewTypeParents([]);
}, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
};
const toggleParent = (parentId: string) => {
setNewTypeParents((current) => current.includes(parentId)
? current.filter((id) => id !== parentId)
: [...current, parentId]);
};
const toggleSelectedParent = async (parentId: string) => {
if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return;
const next = selectedFamily.parentFamilyIds.includes(parentId)
? selectedFamily.parentFamilyIds.filter((id) => id !== parentId)
: [...selectedFamily.parentFamilyIds, parentId];
if (next.length === 0) {
setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.');
return;
}
await run(async () => {
await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next });
await refreshFamilies();
}, 'Tipos permitidos actualizados.');
};
const createField = async () => {
if (!selectedFamily || !newFieldName.trim()) return;
await run(async () => {
await createInventoryFamilyAttribute(selectedFamily.id, {
code: fieldCode(newFieldName),
name: newFieldName.trim(),
dataType: newFieldType,
isRequired: newFieldRequired,
sortOrder: attributes.length,
});
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
setNewFieldName('');
setNewFieldType('TEXT');
setNewFieldRequired(false);
}, 'Campo agregado.');
};
const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => {
if (!selectedFamily) return;
await run(async () => {
await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] });
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
}, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.');
};
const toggleFamilyActive = async () => {
if (!selectedFamily) return;
await run(async () => {
await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false });
await refreshFamilies();
}, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.');
};
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
<div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN · MODELO AUTORITATIVO</span>
<h1>Configuración de Inventarios</h1>
<p>Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.</p>
<span className="eyebrow">ADMINISTRACIÓN · INVENTARIOS</span>
<h1>Tipos y campos</h1>
<p>Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.</p>
</div>
<Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">ESTRUCTURA FÍSICA</span>
<h2>Jerarquía obligatoria</h2>
<p className="section-copy">Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.</p>
<span className="eyebrow">ESTRUCTURA FIJA</span>
<h2>Departamento Área Yacimiento Instalación Subinstalación</h2>
<p className="section-copy">Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.</p>
</div>
</div>
<div className="asset-browser-levels" aria-label="Jerarquía de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Área + Empresa + concesión</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>Yacimiento</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>Instalación</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}>
<Icon name="users" />
<p><strong>Empresa:</strong> ya no pertenece al Área. La relación canónica es <strong>Yacimiento Empresa relacionada</strong>.</p>
<div className="temporal-notice">
<Icon name="map" />
<p><strong>Campos básicos del sistema:</strong> nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.</p>
</div>
</article>
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">INFORMACIÓN ESTRUCTURAL</span>
<h2>{level.label}</h2>
<p className="section-copy">{level.description}</p>
</div>
<span className="tag">Modelo SQL</span>
</div>
<div className="quick-view-row" style={{ marginBottom: 18 }}>
{LEVELS.map((item) => <button type="button" key={item.kind} className={selectedKind === item.kind ? 'active' : ''} onClick={() => setSelectedKind(item.kind)}>{item.label}</button>)}
</div>
<div className="attribute-list">
{level.fields.map((field, index) => <div className="attribute-card" key={field.label}>
<span className="attribute-order">{index + 1}</span>
<span>
<strong>{field.label}</strong>
<small>{field.detail}</small>
</span>
<span className="attribute-flags">
{field.relation && <span className="tag">Relación</span>}
{field.required && <span className="tag">Obligatorio</span>}
</span>
</div>)}
{commonAttributes.map((attribute, index) => <div className="attribute-card" key={attribute.id}>
<span className="attribute-order">{level.fields.length + index + 1}</span>
<span>
<strong>{attribute.name}</strong>
<small>{attribute.code} · Campo común del nivel</small>
</span>
<span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}</span>
</div>)}
</div>
</article>
<div className="quick-view-row" style={{ marginBottom: 18 }}>
<button type="button" className={level === 'INSTALLATION' ? 'active' : ''} onClick={() => setLevel('INSTALLATION')}>Tipos de Instalación</button>
<button type="button" className={level === 'SUBINSTALLATION' ? 'active' : ''} onClick={() => setLevel('SUBINSTALLATION')}>Tipos de Subinstalación</button>
</div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Instalación</h2><p className="section-copy">{installationFamilies.length} clasificaciones exactas del SQL.</p></div></div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{installationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos asociados</small></span><span className="tag">{family.code}</span></div>)}
<div className="panel-heading">
<div>
<span className="eyebrow">TIPOS</span>
<h2>{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}</h2>
<p className="section-copy">Seleccioná un tipo para administrar sus campos.</p>
</div>
</div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{visibleFamilies.length === 0 && <p className="muted">Todavía no hay tipos configurados.</p>}
{visibleFamilies.map((family) => <button
type="button"
key={family.id}
onClick={() => setSelectedFamilyId(family.id)}
className="attribute-card"
style={{ width: '100%', textAlign: 'left', borderColor: selectedFamilyId === family.id ? 'var(--primary)' : undefined, opacity: family.isActive === false ? .55 : 1 }}
>
<span className="asset-symbol"><Icon name="layers" size={16} /></span>
<span><strong>{family.name}</strong><small>{family.isActive === false ? 'Oculto' : `${family.technicalAttributeCount ?? 0} campos`}</small></span>
<Icon name="chevron" size={16} />
</button>)}
</div>
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option"><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} /><span>{family.name}</span></label>)}
</div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>}
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">MODELO TÉCNICO</span><h2>Tipos de Subinstalación</h2><p className="section-copy">{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.</p></div></div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{subinstallationFamilies.map((family) => <div className="attribute-card" key={family.id}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos</small></span></div>)}
</div>
{!selectedFamily ? <div className="inline-empty">Seleccioná un tipo para ver sus campos.</div> : <>
<div className="panel-heading">
<div>
<span className="eyebrow">{selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'}</span>
<h2>{selectedFamily.name}</h2>
<p className="section-copy">Campos sencillos que se muestran al cargar este tipo.</p>
</div>
{canManage && <button type="button" className="button secondary" onClick={() => void toggleFamilyActive()}>{selectedFamily.isActive === false ? 'Activar tipo' : 'Ocultar tipo'}</button>}
</div>
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong>
<div className="inventory-parent-options">
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} className="inventory-parent-option">
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
<span>{family.name}</span>
</label>)}
</div>
</div>}
<div className="attribute-list">
{attributes.length === 0 && <p className="muted">Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.</p>}
{attributes.map((attribute) => <div className="attribute-card" key={attribute.id} style={{ opacity: attribute.isActive ? 1 : .5 }}>
<span className="attribute-order">{attribute.sortOrder + 1}</span>
<span>
<strong>{attribute.name}</strong>
<small>{fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'}</small>
</span>
{canManage && <span className="attribute-flags" style={{ display: 'flex', gap: 6 }}>
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isRequired')}>{attribute.isRequired ? 'Hacer opcional' : 'Hacer obligatorio'}</button>
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isActive')}>{attribute.isActive ? 'Ocultar' : 'Mostrar'}</button>
</span>}
</div>)}
</div>
{canManage && selectedFamily.isActive !== false && <div style={{ marginTop: 22, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Agregar campo</h3>
<div className="form-grid">
<label className="field"><span>Nombre</span><input value={newFieldName} onChange={(event) => setNewFieldName(event.target.value)} placeholder="Ej. Capacidad" /></label>
<label className="field"><span>Tipo</span><select value={newFieldType} onChange={(event) => setNewFieldType(event.target.value as InventoryFamilyAttributeDataType)}>{FIELD_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}><input type="checkbox" checked={newFieldRequired} onChange={(event) => setNewFieldRequired(event.target.checked)} />Obligatorio</label>
<button type="button" className="button primary" disabled={saving || !newFieldName.trim()} onClick={() => void createField()}><Icon name="plus" />Agregar campo</button>
<p className="muted" style={{ marginBottom: 0, marginTop: 12 }}>Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.</p>
</div>}
</>}
</article>
</div>
<article className="panel" style={{ marginTop: 18 }}>
<div className="panel-heading">
<div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo contextual</h2><p className="section-copy">{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.</p></div>
<Link className="button secondary" to="/admin/finding-catalog">Abrir catálogo <Icon name="chevron" /></Link>
</div>
</article>
</section>;
}
+207 -66
View File
@@ -6,13 +6,13 @@ import { Icon } from '../components/Icon';
import { getAsset } from '../lib/api';
import {
createInventoryStructure,
getInventoryFamilyFindings,
getInventoryStructureOptions,
listCompaniesForInventoryArea,
listInventoryStructureParents,
} from '../lib/inventoryStructureApi';
import type {
InventoryAreaCompanyOption,
InventoryFamily,
InventoryFamilyFindings,
InventoryStructureKind,
InventoryStructureOptions,
InventoryStructureParent,
@@ -26,13 +26,24 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' },
];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
departamento: 'AREA',
area: 'YACIMIENTO',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
};
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
const parentLabelByKind: Partial<Record<InventoryStructureKind, string>> = {
AREA: 'Departamento',
YACIMIENTO: 'Área',
INSTALACION: 'Yacimiento',
SUBINSTALACION: 'Instalación',
};
function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
function kindLabel(kind: InventoryStructureKind) {
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
}
export function InventoryCreatePage() {
const navigate = useNavigate();
@@ -44,102 +55,232 @@ export function InventoryCreatePage() {
const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState('');
const [areaCompanies, setAreaCompanies] = useState<InventoryAreaCompanyOption[]>([]);
const [operatorCompanyId, setOperatorCompanyId] = useState('');
const [concessionTypeId, setConcessionTypeId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
getInventoryStructureOptions().then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) { setKind(inferred); setParentId(parent.id); }
}
}).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
getInventoryStructureOptions()
.then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) {
setKind(inferred);
setParentId(parent.id);
}
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]);
const requiresParent = Boolean(parentLabelByKind[kind]);
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const requiresYacimientoContext = kind === 'YACIMIENTO';
const requiresCompany = kind === 'YACIMIENTO';
const parentLabel = parentLabelByKind[kind] ?? '';
useEffect(() => {
if (!requiresParent) { setParents([]); setParentId(''); return; }
if (!requiresParent) {
setParents([]);
setParentId('');
return;
}
const timer = window.setTimeout(() => {
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
}).catch((requestError) => setError(errorMessage(requestError)));
},150);
listInventoryStructureParents(kind, parentSearch)
.then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id === contextParentId)) setParentId(contextParentId);
})
.catch((requestError) => setError(errorMessage(requestError)));
}, 150);
return () => window.clearTimeout(timer);
}, [kind,parentSearch,contextParentId,requiresParent]);
}, [kind, parentSearch, contextParentId, requiresParent]);
const selectedParent = parents.find((item) => item.id===parentId) ?? null;
useEffect(() => {
if (kind !== 'YACIMIENTO' || !parentId) {
setAreaCompanies([]);
setOperatorCompanyId('');
return;
}
setOperatorCompanyId('');
listCompaniesForInventoryArea(parentId)
.then(setAreaCompanies)
.catch((requestError) => setError(errorMessage(requestError)));
}, [kind, parentId]);
const selectedParent = parents.find((item) => item.id === parentId) ?? null;
const families = useMemo(() => {
if (!options) return [] as InventoryFamily[];
if (kind==='INSTALACION') return options.installationFamilies;
if (kind==='SUBINSTALACION') {
const parentFamilyId=selectedParent?.inventoryFamily?.id;
return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
if (kind === 'INSTALACION') return options.installationFamilies;
if (kind === 'SUBINSTALACION') {
const parentFamilyId = selectedParent?.inventoryFamily?.id;
return parentFamilyId
? options.subinstallationFamilies.filter((item) => item.parentFamilyIds.includes(parentFamilyId))
: [];
}
return [];
},[options,kind,selectedParent]);
const selectedFamily=families.find((item)=>item.id===familyId) ?? null;
}, [options, kind, selectedParent]);
useEffect(() => {
if (!familyId) { setFamilyFindings(null); return; }
getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
},[familyId]);
useEffect(() => {
if (!requiresFamily) setFamilyId('');
if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
},[kind,requiresFamily,families,familyId]);
useEffect(() => {
if (!requiresYacimientoContext) { setOperatorCompanyId(''); setConcessionTypeId(''); }
},[requiresYacimientoContext]);
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
}, [kind, requiresFamily, families, familyId]);
const changeKind=(next:InventoryStructureKind) => {
setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null);
setOperatorCompanyId(''); setConcessionTypeId(''); setError('');
const changeKind = (next: InventoryStructureKind) => {
setKind(next);
setParentId('');
setParentSearch('');
setFamilyId('');
setAreaCompanies([]);
setOperatorCompanyId('');
setError('');
};
const save=async(event:FormEvent) => {
const save = async (event: FormEvent) => {
event.preventDefault();
if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
if (requiresYacimientoContext && !operatorCompanyId) { setError('Seleccioná la Empresa relacionada del Yacimiento.'); return; }
if (requiresYacimientoContext && !concessionTypeId) { setError('Seleccioná el Tipo de concesión del Yacimiento.'); return; }
if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; }
setSaving(true); setError('');
if (requiresParent && !parentId) {
setError(`Seleccioná ${parentLabel}.`);
return;
}
if (requiresCompany && !operatorCompanyId) {
setError('Seleccioná la Empresa que opera este Yacimiento.');
return;
}
if (requiresFamily && !familyId) {
setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`);
return;
}
setSaving(true);
setError('');
try {
const created=await createInventoryStructure({
kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null,
operatorCompanyId:operatorCompanyId || null,concessionTypeId:concessionTypeId || null,
code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null,
const created = await createInventoryStructure({
kind,
name: name.trim(),
parentId: parentId || null,
familyId: familyId || null,
operatorCompanyId: operatorCompanyId || null,
code: code.trim() || null,
});
navigate(`/inventarios/${created.id}`,{replace:true});
} catch(requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
navigate(`/inventarios/${created.id}`, { replace: true });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <LoadingBlock label="Preparando alta…" />;
const disableSave = saving
|| !name.trim()
|| (requiresParent && !parentId)
|| (requiresCompany && !operatorCompanyId)
|| (requiresFamily && !familyId);
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong></nav>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Modelo fijo: Departamento Área Yacimiento Instalación Subinstalación. La Empresa y el Tipo de concesión pertenecen al Yacimiento.</p></div></div>
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario">
<Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong>
</nav>
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">ALTA RÁPIDA</span>
<h1>Agregar al Inventario</h1>
<p>Elegí qué querés crear, dónde está y completá sólo los datos necesarios.</p>
</div>
</div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Área sólo depende de Departamento. Yacimiento define Área, Empresa relacionada y Tipo de concesión.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
{requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
{requiresYacimientoContext && <div className="form-section"><div><h2>3. Contexto del Yacimiento</h2><p className="section-copy">Estas relaciones pertenecen directamente al Yacimiento y no al Área.</p></div><div className="form-grid"><label className="field"><span>Empresa relacionada <em>obligatorio</em></span><select value={operatorCompanyId} onChange={(event)=>setOperatorCompanyId(event.target.value)} required><option value="">Seleccionar</option>{options?.companies.map((company)=><option key={company.id} value={company.id}>{company.name}</option>)}</select></label><label className="field"><span>Tipo de concesión <em>obligatorio</em></span><select value={concessionTypeId} onChange={(event)=>setConcessionTypeId(event.target.value)} required><option value="">Seleccionar</option>{options?.concessionTypes.map((type)=><option key={type.id} value={type.id}>{type.name}</option>)}</select></label></div></div>}
{requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
<div className="form-section"><div><h2>{requiresYacimientoContext ? '4' : requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">El nombre es el dato propio principal de este nivel. Los datos técnicos se completan después, cuando corresponda.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
<div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresYacimientoContext && (!operatorCompanyId || !concessionTypeId)) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
<div className="form-section">
<div>
<h2>¿Qué querés crear?</h2>
<p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación.</p>
</div>
<div className="quick-view-row" aria-label="Tipo de registro">
{KINDS.map((item) => <button
type="button"
key={item.kind}
className={kind === item.kind ? 'active' : ''}
onClick={() => changeKind(item.kind)}
>{item.label}</button>)}
</div>
</div>
{requiresParent && <div className="form-section">
<div>
<h2>¿Dónde está?</h2>
<p className="section-copy">Seleccioná el {parentLabel.toLowerCase()} al que pertenece.</p>
</div>
<label className="field">
<span>Buscar {parentLabel.toLowerCase()}</span>
<input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} />
</label>
<label className="field">
<span>{parentLabel} <em>obligatorio</em></span>
<select value={parentId} onChange={(event) => { setParentId(event.target.value); setFamilyId(''); }} required>
<option value="">Seleccionar</option>
{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name}</option>)}
</select>
</label>
</div>}
{requiresCompany && <div className="form-section">
<div>
<h2>Empresa operadora</h2>
<p className="section-copy">Un Área puede tener varias Empresas. Cada Yacimiento queda asociado a una sola de ellas.</p>
</div>
{!parentId ? <Alert>Primero seleccioná el Área.</Alert> : areaCompanies.length === 0 ? <Alert>El Área elegida todavía no tiene Empresas operadoras vinculadas.</Alert> : <label className="field">
<span>Empresa <em>obligatorio</em></span>
<select value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required>
<option value="">Seleccionar</option>
{areaCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
</select>
</label>}
</div>}
{requiresFamily && <div className="form-section">
<div>
<h2>{kind === 'INSTALACION' ? 'Tipo de instalación' : 'Tipo / función'}</h2>
<p className="section-copy">{kind === 'SUBINSTALACION' ? 'Sólo aparecen tipos permitidos dentro de la Instalación seleccionada.' : 'Elegí la clasificación simple que corresponda.'}</p>
</div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene un tipo configurado.</Alert> : <label className="field">
<span>{kind === 'INSTALACION' ? 'Tipo' : 'Tipo / función'} <em>obligatorio</em></span>
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
<option value="">Seleccionar</option>
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
</select>
</label>}
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && families.length === 0 && <Alert>No hay tipos de Subinstalación configurados para esta Instalación.</Alert>}
</div>}
<div className="form-section">
<div>
<h2>Datos básicos</h2>
<p className="section-copy">El nombre alcanza para identificar el registro. El código puede generarse automáticamente.</p>
</div>
<label className="field">
<span>Nombre <em>obligatorio</em></span>
<input autoFocus value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} />
</label>
<label className="field">
<span>Código <em>opcional</em></span>
<input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Se genera automáticamente si lo dejás vacío" />
</label>
</div>
<div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={disableSave}><Icon name="check" />{saving ? 'Guardando…' : `Crear ${kindLabel(kind)}`}</button>
</div>
</form>
</section>;
}
+5
View File
@@ -7,6 +7,7 @@ import { getAsset } from '../lib/api';
import type { AssetDetail } from '../lib/api';
import { AssetEditorPage } from './AssetEditorPage';
import { CompanyInventoryPage } from './CompanyInventoryPage';
import { SimpleInventoryDetailPage, isSimpleInventoryAsset } from './SimpleInventoryDetailPage';
import { TerritorialInventoryPage, isTerritorialInventoryAsset } from './TerritorialInventoryPage';
export function InventoryDetailPage() {
@@ -43,6 +44,10 @@ export function InventoryDetailPage() {
return <CompanyInventoryPage initialAsset={asset} />;
}
if (isSimpleInventoryAsset(asset) && !advanced) {
return <SimpleInventoryDetailPage initialAsset={asset} />;
}
if (isTerritorialInventoryAsset(asset) && !advanced) {
return <TerritorialInventoryPage initialAsset={asset} />;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,350 @@
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { hasAdministratorRole } from '../auth/adminAccess';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
import { AssetTechnicalDataPanel } from '../features/assets/AssetTechnicalDataPanel';
import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
} from '../features/assets/assetPresentation';
import {
getAssetLineage,
listAssetTreeChildren,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetDetail,
AssetInformationStatus,
AssetLineageItem,
AssetListItem,
AssetOperationalStatus,
} from '../lib/api';
import { getInventoryTechnicalValues } from '../lib/inventoryStructureApi';
import type { InventoryTechnicalValues } from '../lib/inventoryStructureApi';
import './SimpleInventoryDetailPage.css';
const AssetGeometryEditor = lazy(() =>
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
);
export type SimpleInventoryKind =
| 'DEPARTAMENTO'
| 'AREA'
| 'YACIMIENTO'
| 'INSTALACION'
| 'SUBINSTALACION';
type SimpleTab = 'summary' | 'activity' | 'findings' | 'location' | 'photos' | 'history';
function normalized(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[_\s]+/g, '-');
}
export function simpleInventoryKind(asset: AssetDetail): SimpleInventoryKind | null {
const code = normalized(asset.type.code);
const name = normalized(asset.type.name);
const values = new Set([code, name]);
if (values.has('departamento')) return 'DEPARTAMENTO';
if (values.has('area')) return 'AREA';
if (values.has('yacimiento')) return 'YACIMIENTO';
if (values.has('subinstalacion')) return 'SUBINSTALACION';
if (
values.has('instalacion')
|| values.has('instalacion-de-superficie')
|| values.has('instalacion-superficie')
) return 'INSTALACION';
return null;
}
export function isSimpleInventoryAsset(asset: AssetDetail): boolean {
return simpleInventoryKind(asset) !== null;
}
const labels: Record<SimpleInventoryKind, string> = {
DEPARTAMENTO: 'Departamento',
AREA: 'Área',
YACIMIENTO: 'Yacimiento',
INSTALACION: 'Instalación',
SUBINSTALACION: 'Subinstalación',
};
const childLabels: Partial<Record<SimpleInventoryKind, string>> = {
DEPARTAMENTO: 'Área',
AREA: 'Yacimiento',
YACIMIENTO: 'Instalación',
INSTALACION: 'Subinstalación',
};
function lineageLabel(item: AssetLineageItem): string {
return item.commonName ? `${item.name} · ${item.commonName}` : item.name;
}
export function SimpleInventoryDetailPage({ initialAsset }: { initialAsset: AssetDetail }) {
const [params, setParams] = useSearchParams();
const { user, hasPermission } = useAuth();
const kind = simpleInventoryKind(initialAsset);
const [asset, setAsset] = useState(initialAsset);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [childrenHasMore, setChildrenHasMore] = useState(false);
const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [loadingRelated, setLoadingRelated] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState(initialAsset.name);
const [commonName, setCommonName] = useState(initialAsset.commonName ?? '');
const [description, setDescription] = useState(initialAsset.description ?? '');
const [status, setStatus] = useState<AssetInformationStatus>(initialAsset.informationStatus);
const [operationalStatus, setOperationalStatus] = useState<AssetOperationalStatus>(initialAsset.operationalStatus);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const canEdit = hasPermission('assets.update');
const canCreate = hasPermission('assets.create');
const canChangeStatus = hasPermission('assets.change_status');
const canChangeOperationalStatus = hasPermission('assets.change_operational_status');
const canReadHistory = hasPermission('assets.read_history');
const canReadMedia = hasPermission('assets.read_media');
const canManageMedia = hasPermission('assets.manage_media');
const canEditGeometry = hasPermission('assets.update_geometry');
const canReadFindingCatalog = hasPermission('finding_catalog.read');
const canManageFindingCatalog = hasPermission('finding_catalog.manage');
const canReadDossier = hasPermission('inspections.read')
&& hasPermission('inspection_acts.read')
&& hasPermission('inspection_findings.read')
&& hasPermission('inspection_evidence.read')
&& hasPermission('inspection_communications.read');
const canAdvanced = hasAdministratorRole(user);
const inspectable = kind === 'YACIMIENTO' || kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const technicalLevel = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
const hasChildren = kind !== 'SUBINSTALACION';
const requestedTab = params.get('tab') as SimpleTab | null;
const availableTabs = useMemo(() => new Set<SimpleTab>([
'summary',
...(inspectable && canReadDossier ? ['activity' as const] : []),
...(technicalLevel && canReadFindingCatalog ? ['findings' as const] : []),
'location',
...(canReadMedia ? ['photos' as const] : []),
...(canReadHistory ? ['history' as const] : []),
]), [inspectable, technicalLevel, canReadDossier, canReadFindingCatalog, canReadMedia, canReadHistory]);
const tab: SimpleTab = requestedTab && availableTabs.has(requestedTab) ? requestedTab : 'summary';
const loadRelated = async () => {
setLoadingRelated(true);
try {
const [loadedLineage, childPage, loadedTechnical] = await Promise.all([
getAssetLineage(asset.id),
hasChildren
? listAssetTreeChildren({ parentId: asset.id, limit: 100 })
: Promise.resolve({ data: [] as AssetListItem[], meta: { count: 0, hasMore: false } }),
technicalLevel
? getInventoryTechnicalValues(asset.id).catch(() => null)
: Promise.resolve(null),
]);
setLineage(loadedLineage);
setChildren(childPage.data);
setChildrenHasMore(childPage.meta.hasMore);
setTechnical(loadedTechnical);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setLoadingRelated(false);
}
};
useEffect(() => { void loadRelated(); }, [asset.id]);
if (!kind) return <Alert>Este registro no pertenece al modelo simple de Inventarios.</Alert>;
const save = async (event: FormEvent) => {
event.preventDefault();
if (!name.trim()) return;
setSaving(true);
setError('');
setSuccess('');
try {
let saved = asset;
if (canEdit) {
saved = await updateAsset(asset.id, {
name: name.trim(),
commonName: commonName.trim() || null,
description: description.trim() || null,
});
}
if (canChangeStatus && saved.informationStatus !== status) {
saved = await updateAssetInformationStatus(asset.id, status);
}
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(asset.id, operationalStatus);
}
setAsset(saved);
setName(saved.name);
setCommonName(saved.commonName ?? '');
setDescription(saved.description ?? '');
setStatus(saved.informationStatus);
setOperationalStatus(saved.operationalStatus);
setEditing(false);
setHistoryRefreshKey((current) => current + 1);
setSuccess('Registro actualizado.');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const cancelEdit = () => {
setEditing(false);
setName(asset.name);
setCommonName(asset.commonName ?? '');
setDescription(asset.description ?? '');
setStatus(asset.informationStatus);
setOperationalStatus(asset.operationalStatus);
};
const setTab = (next: SimpleTab) => {
const nextParams = new URLSearchParams(params);
next === 'summary' ? nextParams.delete('tab') : nextParams.set('tab', next);
nextParams.delete('advanced');
setParams(nextParams);
};
const childLabel = childLabels[kind];
const parentLabel = kind === 'AREA'
? 'Departamento'
: kind === 'YACIMIENTO'
? 'Área'
: kind === 'INSTALACION'
? 'Yacimiento'
: kind === 'SUBINSTALACION'
? 'Instalación'
: null;
return <section className="narrow-section simple-inventory-page">
<nav className="breadcrumb simple-inventory-breadcrumb" aria-label="Ruta de Inventario">
<Link to="/inventarios">Inventarios</Link>
{lineage.filter((item) => item.id !== asset.id).map((item) => <span className="simple-inventory-breadcrumb-part" key={item.id}><span></span><Link to={`/inventarios/${item.id}`}>{lineageLabel(item)}</Link></span>)}
<span></span><strong>{asset.name}</strong>
</nav>
<header className="simple-inventory-hero">
<div>
<span className="eyebrow">{labels[kind].toUpperCase()}</span>
<h1>{asset.name}</h1>
<div className="simple-inventory-hero-meta"><strong>{asset.code}</strong>{asset.commonName && <span>{asset.commonName}</span>}</div>
</div>
<div className="simple-inventory-hero-actions">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span>
{tab === 'summary' && (canEdit || canChangeStatus || canChangeOperationalStatus) && <button type="button" className="button secondary" onClick={() => editing ? cancelEdit() : setEditing(true)}><Icon name="edit" />{editing ? 'Cancelar' : 'Editar'}</button>}
{tab === 'summary' && canCreate && childLabel && <Link className="button primary" to={`/inventarios/nuevo?parentId=${asset.id}`}><Icon name="plus" />Agregar {childLabel.toLowerCase()}</Link>}
</div>
</header>
<div className="simple-inventory-purpose">
<Icon name={inspectable ? 'clipboard' : 'layers'} />
<span>{inspectable
? 'Ficha operativa: identidad, ubicación y actividad de inspección. La información administrativa avanzada queda fuera de la vista cotidiana.'
: 'Este nivel organiza la estructura del Inventario. La vista cotidiana muestra sólo los datos necesarios para ubicar y navegar.'}</span>
</div>
<nav className="simple-inventory-tabs" aria-label="Secciones del Inventario">
<button type="button" className={tab === 'summary' ? 'active' : ''} onClick={() => setTab('summary')}>Resumen</button>
{inspectable && canReadDossier && <button type="button" className={tab === 'activity' ? 'active' : ''} onClick={() => setTab('activity')}>Actividad</button>}
{technicalLevel && canReadFindingCatalog && <button type="button" className={tab === 'findings' ? 'active' : ''} onClick={() => setTab('findings')}>Hallazgos</button>}
<button type="button" className={tab === 'location' ? 'active' : ''} onClick={() => setTab('location')}>Ubicación</button>
{canReadMedia && <button type="button" className={tab === 'photos' ? 'active' : ''} onClick={() => setTab('photos')}>Fotos</button>}
{canReadHistory && <button type="button" className={tab === 'history' ? 'active' : ''} onClick={() => setTab('history')}>Cambios</button>}
</nav>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{tab === 'summary' && <div className="simple-inventory-stack">
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">DATOS PRINCIPALES</span><h2>Identificación</h2></div></div>
{editing ? <form className="simple-inventory-form" onSubmit={save}>
<div className="form-grid two">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} /></label>
<label className="field"><span>Nombre habitual <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} /></label>
</div>
<details className="simple-inventory-details">
<summary>Más datos</summary>
<label className="field"><span>Descripción <em>opcional</em></span><textarea rows={3} value={description} onChange={(event) => setDescription(event.target.value)} maxLength={4000} /></label>
</details>
{(canChangeStatus || canChangeOperationalStatus) && <details className="simple-inventory-details">
<summary>Estado y opciones</summary>
<div className="form-grid two">
{canChangeStatus && <label className="field"><span>Estado del dato</span><select value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>}
{canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><select value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>}
</div>
</details>}
<div className="form-actions"><button type="button" className="button secondary" onClick={cancelEdit}>Cancelar</button><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar cambios'}</button></div>
</form> : <div className="simple-inventory-data-grid">
<div><small>Nombre</small><strong>{asset.name}</strong></div>
<div><small>Código DH</small><strong>{asset.code}</strong></div>
{asset.commonName && <div><small>Nombre habitual</small><strong>{asset.commonName}</strong></div>}
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>}
</article>
<article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">UBICACIÓN ACTUAL</span><h2>Dentro del Inventario</h2></div></div>
<div className="simple-inventory-context-grid">
{parentLabel && <div className="simple-inventory-context-card"><small>{parentLabel}</small>{asset.parent ? <Link to={`/inventarios/${asset.parent.id}`}><strong>{asset.parent.name}</strong><span>{asset.parent.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{kind === 'YACIMIENTO' && <div className="simple-inventory-context-card"><small>Empresa operadora</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="simple-inventory-context-card"><small>Empresa del Yacimiento</small>{asset.operatorCompany ? <Link to={`/inventarios/${asset.operatorCompany.id}`}><strong>{asset.operatorCompany.name}</strong><span>{asset.operatorCompany.code}</span></Link> : <strong>Sin asignar</strong>}</div>}
{technicalLevel && <div className="simple-inventory-context-card"><small>Tipo técnico</small><strong>{technical?.family.name ?? 'Sin clasificación técnica'}</strong>{technical?.family.code && <span>{technical.family.code}</span>}</div>}
</div>
</article>
{hasChildren && <article className="panel simple-inventory-card">
<div className="simple-inventory-card-heading"><div><span className="eyebrow">CONTENIDO</span><h2>{childLabel ? `${childLabel}s dentro de ${asset.name}` : `Contenido de ${asset.name}`}</h2></div><span className="count-pill">{children.length}{childrenHasMore ? '+' : ''}</span></div>
{loadingRelated ? <LoadingBlock label="Cargando contenido…" /> : children.length === 0 ? <div className="inline-empty">Todavía no hay registros dentro de {asset.name}.</div> : <div className="simple-inventory-child-list">{children.map((child) => <Link key={child.id} to={`/inventarios/${child.id}`} className="simple-inventory-child-row"><span className="asset-symbol"><Icon name="layers" /></span><span><strong>{child.name}</strong><small>{child.type.name} · {child.code}</small></span><span className={`status-badge ${assetStatusClass(child.informationStatus)}`}>{assetStatusLabel(child.informationStatus)}</span><Icon name="chevron" size={16} /></Link>)}</div>}
</article>}
{technicalLevel && <details className="panel simple-inventory-card simple-inventory-technical-details">
<summary><span><small>DATOS TÉCNICOS</small><strong>{technical?.family.name ?? 'Información técnica'}</strong></span><span>Ver / editar</span></summary>
<AssetTechnicalDataPanel assetId={asset.id} canEdit={canEdit} />
</details>}
{!editing && asset.description && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Más datos</summary>
<p>{asset.description}</p>
</details>}
{!editing && <details className="panel simple-inventory-card simple-inventory-extra-details">
<summary>Estado y opciones</summary>
<div className="simple-inventory-data-grid">
<div><small>Estado del dato</small><strong>{assetStatusLabel(asset.informationStatus)}</strong></div>
<div><small>Estado operativo</small><strong>{assetOperationalStatusLabel(asset.operationalStatus)}</strong></div>
</div>
{canAdvanced && <div className="simple-inventory-advanced"><span>Las herramientas históricas y administrativas quedan separadas de esta ficha.</span><Link to={`/inventarios/${asset.id}?advanced=1`}>Administración avanzada</Link></div>}
</details>}
</div>}
{tab === 'activity' && inspectable && canReadDossier && <AssetDossierPanel assetId={asset.id} />}
{tab === 'findings' && technicalLevel && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={asset.id} canManage={canManageFindingCatalog} />}
{tab === 'location' && <Suspense fallback={<LoadingBlock label="Cargando mapa…" />}><AssetGeometryEditor assetId={asset.id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{tab === 'photos' && canReadMedia && <AssetMediaPanel assetId={asset.id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={asset.id} refreshKey={historyRefreshKey} />}
</section>;
}
+52
View File
@@ -1296,3 +1296,55 @@ code { color: #5e6677; font-family: ui-monospace, monospace; font-size: 9px; }
/* D5.6.4 · combobox buscable global */
.searchable-select{position:relative;width:100%;min-width:0}.searchable-select-native{position:absolute!important;inset:0;width:1px!important;height:1px!important;opacity:0;pointer-events:none}.searchable-select-trigger{display:flex;width:100%;min-height:41px;align-items:center;justify-content:space-between;gap:10px;padding:9px 11px;border:1px solid #d7dce5;border-radius:8px;background:#fff;color:var(--ink);font-size:13px;text-align:left;cursor:pointer;outline:none}.searchable-select-trigger:hover{border-color:#bcc6d6}.searchable-select-trigger:focus-visible{border-color:#6b95ed;box-shadow:0 0 0 3px rgba(40,100,220,.1)}.searchable-select-trigger:disabled{cursor:not-allowed;color:#9199a8;background:#f1f3f6}.searchable-select-trigger>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.searchable-select-trigger .placeholder{color:#7e8796}.searchable-select-trigger .icon{flex:0 0 auto;transform:rotate(90deg)}.searchable-select-popup{position:fixed;z-index:10000;max-height:315px;padding:6px;border:1px solid #ccd4e0;border-radius:10px;background:#fff;box-shadow:0 14px 40px rgba(18,31,53,.18)}.searchable-select-search{display:flex;align-items:center;gap:7px;padding:5px 7px 7px;border-bottom:1px solid var(--line)}.searchable-select-search input{width:100%;min-width:0;height:34px;padding:6px 8px;border:0;outline:0;background:transparent;color:var(--ink);font-size:12px}.searchable-select-options{max-height:245px;overflow:auto;padding-top:4px}.searchable-select-options>button{display:flex;width:100%;align-items:center;justify-content:space-between;gap:8px;padding:8px 9px;border:0;border-radius:7px;background:transparent;color:var(--ink);font-size:12px;text-align:left;cursor:pointer}.searchable-select-options>button:hover,.searchable-select-options>button:focus-visible{background:#f2f5fa;outline:none}.searchable-select-options>button.selected{background:#eef4ff;color:#174ea6;font-weight:750}.searchable-select-options>button:disabled{cursor:not-allowed;color:#a0a7b3;background:transparent}.searchable-select-empty{padding:14px 10px;color:var(--muted);font-size:11px;text-align:center}.select-field>.searchable-select{width:100%}.survey-inline-select.searchable-select{min-width:130px;margin-top:6px;padding:0;border:0;background:transparent}.survey-inline-select.wide.searchable-select{min-width:175px;margin-top:0}.survey-inline-select .searchable-select-trigger{min-height:33px;padding:6px 8px;border-radius:7px;font-size:9px}.operational-context-selectors .searchable-select-trigger{min-height:36px;padding:7px 9px;font-size:10px}.parent-picker .searchable-select-trigger{border-radius:5px 5px 8px 8px}
.inspection-quick-create{max-width:980px;margin-left:auto;margin-right:auto}.inspection-quick-create .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.inspection-generated-code{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--soft)}.inspection-generated-code small{color:var(--muted);font-weight:700}.inspection-generated-code strong{font-size:15px;letter-spacing:.02em}@media(max-width:760px){.inspection-quick-create .form-grid{grid-template-columns:1fr}}
/* Parent choices need fixed-size controls even inside a generic form field. */
.inventory-parent-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 8px;
margin: 10px 0 14px;
}
.inventory-parent-option {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
min-height: 42px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--ink);
font-size: 12px;
font-weight: 600;
line-height: 1.4;
cursor: pointer;
}
.inventory-parent-option input[type="checkbox"] {
flex: 0 0 17px;
width: 17px;
height: 17px;
min-height: 17px;
padding: 0;
margin: 0;
accent-color: var(--blue);
cursor: inherit;
}
.inventory-parent-option > span {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
.inventory-parent-option:has(input:checked) {
border-color: #9db9ed;
background: #eef4ff;
}
.inventory-parent-option:has(input:focus-visible) {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.inventory-parent-option:has(input:disabled) {
cursor: default;
opacity: .65;
}