Compare commits

..
Author SHA1 Message Date
admin 8733c82f65 docs(F6.1): record the production dependency security gate 2026-09-09 18:25:12 -03:00
github-actions[bot] 693d841bec fix(security): patch runtime dependency advisories 2026-09-09 21:24:32 +00:00
admin 06cc3562f0 chore(F6.1): verify patched runtime locks structurally 2026-09-09 18:24:18 -03:00
admin 53badd02ee chore(F6.1): apply audited runtime dependency patches 2026-09-09 18:23:32 -03:00
admin 39ddcc17cd ci(security): audit production dependencies before presentation 2026-09-09 18:22:04 -03:00
admin 995f9e4044 docs(android): record the F6.1 runtime lint guarantees 2026-09-09 18:20:49 -03:00
admin d704fd01b4 test(F6.1): match the actual Android start button contract 2026-09-09 18:20:24 -03:00
github-actions[bot] 3097cc7305 fix(android): handle location permission races and optional camera 2026-09-09 21:17:53 +00:00
admin 3aa38fa5a7 chore(F6.1): apply targeted Android lint fixes 2026-09-09 18:17:43 -03:00
admin 9977c218de docs: surface F6.1 presentation release documentation 2026-09-09 18:10:39 -03:00
admin 3b6c2c1220 docs(F6.1): add presentation acceptance and demo guide 2026-09-09 18:10:27 -03:00
admin 88b469b6fa docs(android): define F6.1 release contract 2026-09-09 18:09:58 -03:00
admin a8b9501bd3 ci(android): harden F6.1 presentation release barrier 2026-09-09 18:09:44 -03:00
admin 4457fdb380 test(android): require real F6.1 release metadata tests 2026-09-09 18:09:22 -03:00
admin 0f2529b012 test(F6.1): lock presentation acceptance contracts 2026-09-09 18:09:11 -03:00
admin 90971de21d fix(release): align visible WEB metadata with F6.1 2026-09-09 18:08:51 -03:00
admin e549a44ae5 fix(F6.1): unificar contexto operativo Área–Operadora (#33)
Cierra inconsistencias residuales del contexto operativo F6.1. Inventario queda ligado al Área física; la Operadora vigente se resuelve por relaciones temporales. Incluye correcciones pre-merge de búsquedas WEB, alta contextual, merge de duplicados y barreras CI/runtime. Producción permanece sin desplegar.
2026-09-09 17:43:07 -03:00
admin 1dd424698a test(f6.1): require build-safe active operator fallback 2026-09-09 17:36:04 -03:00
admin d9cdb48a26 fix(f6.1): make active operator selection build-safe 2026-09-09 17:35:42 -03:00
admin bec92f0c6d test(f6.1): cover inventory merge physical candidate search 2026-09-09 17:32:16 -03:00
admin 3dab0938d6 fix(f6.1): merge inventory candidates by physical context 2026-09-09 17:31:54 -03:00
admin f93e8240ad test(f6.1): cover pre-merge operator consistency blockers 2026-09-09 17:27:48 -03:00
admin 257ee20148 fix(f6.1): derive creation operator from active area relation 2026-09-09 17:27:20 -03:00
admin 264ab70214 fix(f6.1): search preventive inventory by physical area 2026-09-09 17:26:05 -03:00
admin 3903ae22e6 fix(f6.1): reconcile field inventory by physical area 2026-09-09 17:24:45 -03:00
admin c454eb3255 test(f6.1): block legacy operator snapshot search contracts 2026-09-09 15:29:06 -03:00
admin a7cc9e0886 fix(f6.1): reject legacy operator snapshot tree filters 2026-09-09 15:28:22 -03:00
admin 7d475cdc6f fix(f6.1): reject legacy operator snapshot asset filters 2026-09-09 15:28:09 -03:00
admin 8b6608ecae test(f6.1): cover historical context and web operator separation 2026-09-09 15:23:39 -03:00
admin 5b237a4d4e fix(f6.1): keep operator editable only at inventory creation 2026-09-09 15:23:09 -03:00
admin 16ca67cb76 fix(f6.1): keep act lists and snapshots on visit context 2026-09-09 15:20:42 -03:00
admin ca1fddc834 fix(f6.1): separate physical context from area operator in web 2026-09-09 15:18:28 -03:00
admin b713be4d15 fix(f6.1): keep reports on frozen inspection context 2026-09-09 15:16:45 -03:00
admin 20ecbb2872 fix(f6.1): keep finding worklist on historical visit context 2026-09-09 15:15:07 -03:00
admin 62f612dd24 ci(f6.1): require real Nest startup and health check 2026-09-09 15:13:32 -03:00
admin fc978ad74e ci(f6.1): run Android on operational API contract changes 2026-09-09 15:12:55 -03:00
admin 4f1f26ad93 test(f6.1): lock operational context invariants 2026-09-09 15:12:43 -03:00
admin ec8a9be99e test(f6.1): enforce operator relation without inventory rewrite 2026-09-09 15:12:27 -03:00
admin 5738306a14 fix(f6.1): resolve verification operator from temporal area relation 2026-09-09 15:09:04 -03:00
admin 33c0c6b8f6 fix(f6.1): preserve inventory snapshots when operator changes 2026-09-09 15:07:52 -03:00
admin 44f6002869 fix(f6.1): resolve temporal operator from area relation history 2026-09-09 15:07:17 -03:00
admin 517f934d5e fix(f6.1): keep operator snapshot immutable on inventory updates 2026-09-09 15:06:54 -03:00
admin e4acb7bebf fix(f6.1): block operator changes through inventory context 2026-09-09 15:06:42 -03:00
admin f7145427c0 F6.1 · Auditoría de consistencia y manuales (#32)
Consolida invariantes F6.1, corrige terminología de contexto operativo, agrega contratos transversales y crea manuales de programador/usuario. Deploy permanece pausado.
2026-09-09 14:58:46 -03:00
admin 433220ee3a docs(code): documentar apertura móvil canónica 2026-09-09 14:52:57 -03:00
admin ba38dc7dda docs: enlazar manuales y contrato F6.1 2026-09-09 14:52:26 -03:00
admin 51ace362d7 docs: registrar auditoría de consistencia F6.1 2026-09-09 14:52:03 -03:00
admin 3a149691e2 docs: crear manual de usuario 2026-09-09 14:50:08 -03:00
admin 294abc4cf3 docs: crear manual del programador 2026-09-09 14:49:21 -03:00
admin 90443b3961 docs: evitar duplicación del modelo F6 2026-09-09 14:48:30 -03:00
admin 60c6d97f1a docs: evitar duplicación del modelo F6 2026-09-09 14:48:20 -03:00
admin ae7326f20a docs: consolidar modelo canónico de Inventarios F6.1 2026-09-09 14:47:52 -03:00
admin 5a9c4ed5ef test: fijar invariantes transversales F6.1 2026-09-09 14:47:19 -03:00
admin 0959e85487 fix: alinear terminología del contexto de inspección 2026-09-09 14:44:57 -03:00
admin a4fad611d4 docs(code): fijar invariante multi-Acta 2026-09-09 14:44:26 -03:00
admin d28186a567 docs(code): documentar jerarquía canónica de Inventario 2026-09-09 14:44:06 -03:00
admin 25a63cd3d4 F6.1 · Hallazgos en Yacimientos + abrir Inspección desde APK (#31)
Hallazgos directos en Yacimiento, Instalación y Subinstalación con OTROS siempre disponible. Apertura de inspección desde Android mediante Área → Operadora vigente, autoasignación y lifecycle canónico.
2026-09-09 14:17:08 -03:00
admin 3d8ace5529 F6.1 · actualizar contrato de APK de prueba 2026-09-09 14:11:01 -03:00
admin 47203ff0da F6.1 · contrato de catálogo contextual en Yacimientos 2026-09-09 14:10:50 -03:00
admin b701becab7 F6.1 · usar catálogo contextual por tipo en Yacimientos 2026-09-09 14:10:20 -03:00
admin 9983201de7 F6.1 · actualizar contrato de health metadata 2026-09-09 14:04:26 -03:00
admin 4fc05b73b1 F6.1 · artefacto APK 0.15.0 2026-09-09 14:00:34 -03:00
admin 40cfeff42e F6.1 · Android 0.15.0 2026-09-09 14:00:24 -03:00
admin e3f89f4d5a F6.1 · metadata API 2026-09-09 14:00:11 -03:00
admin 7284c252ff F6.1 · version API 0.29.0-1 2026-09-09 14:00:01 -03:00
admin 7baf306a23 F6.1 · contratos Hallazgos y apertura móvil 2026-09-09 13:59:49 -03:00
admin 27a51c2fe2 F6.1 · tratar Yacimiento como objetivo de Hallazgos 2026-09-09 13:59:33 -03:00
admin b326eb737d F6.1 · permitir Hallazgos sobre Yacimientos 2026-09-09 13:59:07 -03:00
admin 6a5db887c0 F6.1 · abrir inspecciones desde APK 2026-09-09 13:58:23 -03:00
admin baf63a861c F6.1 · usar home móvil con apertura de inspecciones 2026-09-09 13:57:57 -03:00
admin d61e4ad04d F6.1 · pantalla APK para abrir inspección en campo 2026-09-09 13:57:34 -03:00
admin 37885c0454 F6.1 · cliente Android para abrir inspecciones 2026-09-09 13:57:08 -03:00
admin b6707cef43 F6.1 · contrato para abrir inspección desde APK 2026-09-09 13:56:28 -03:00
admin 74fc08bf24 F6 hotfix · deploy espera versión candidata (#30)
Endurece el healthcheck del watcher para esperar status ok, versión exacta candidata y database ok antes de aceptar el recreate.
2026-09-09 11:34:37 -03:00
admin ad7ec2ca2b fix(deploy): wait for exact candidate health version 2026-09-09 11:28:53 -03:00
admin d938db7913 F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
2026-09-09 11:19:12 -03:00
58 changed files with 2356 additions and 290 deletions
+96 -21
View File
@@ -1,35 +1,36 @@
name: Android APK
# F6: genera una APK debug verificable contra la API productiva F6.
name: Android CI / RC
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
on:
push:
branches:
- 'main'
- 'release/f5-android-test'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
- 'release/f6-1*'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches:
- 'main'
paths:
- 'android-app/**'
- 'api-v3/src/auth/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build-debug-apk:
android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 35
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -51,18 +52,92 @@ jobs:
with:
gradle-version: '8.13'
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Validate mobile security and identity contract
run: |
set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Unit tests
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK
- name: Require real unit-test results
run: |
set -Eeuo pipefail
test_dir="android-app/app/build/test-results/testDebugUnitTest"
test -d "$test_dir"
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
test "$total" -gt 0
echo "Android unit tests discovered: $total"
- name: Assemble debug APK
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
- name: Package RC artifact and checksum
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.1"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F6-0.14.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
if-no-files-found: error
retention-days: 30
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14
+30 -2
View File
@@ -69,7 +69,7 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations on clean PostGIS
- name: Rehearse migrations and real API startup on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
@@ -216,7 +216,35 @@ jobs:
exit 1
}
rm -f "$rerun_log"
- name: VPS-equivalent isolated API preflight
# A build-only preflight cannot catch Nest dependency-injection or
# runtime configuration failures. Start the production API image against
# the migrated database and require the public health endpoint to answer.
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
export SMTP_SETTINGS_MASTER_KEY=''
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
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
api_ready=1
break
fi
sleep 2
done
if [ "$api_ready" -ne 1 ]; then
echo 'ERROR: production API image did not become healthy.' >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
grep -Fq '"status":"ok"' /tmp/dhv2-health.json || {
echo 'ERROR: /api/v3/health did not report status ok.' >&2
cat /tmp/dhv2-health.json >&2
exit 1
}
- name: Isolated builder test preflight
run: |
set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
+48
View File
@@ -0,0 +1,48 @@
name: Production dependency audit
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-production-audit-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
name: API · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
web:
name: WEB · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
+26 -5
View File
@@ -2,9 +2,30 @@
Repositorio del sistema DH Inspección V2.
## Estructura
## Componentes
- `api-v3/`: API v3.
- `web-v2/`: aplicación web.
- `scripts/`: utilidades operativas y de despliegue.
- `docs/`: documentación técnica.
- `api-v3/`: API NestJS/TypeORM y contratos de dominio.
- `web-v2/`: aplicación WEB React/Vite.
- `android-app/`: aplicación Android para trabajo de campo.
- `scripts/`: utilidades operativas, preflight y despliegue.
- `docs/`: documentación funcional y técnica.
## Documentación vigente
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
- [Contrato de release Android F6.1](android-app/RELEASE.md)
## Contratos que no deben romperse
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
+47
View File
@@ -0,0 +1,47 @@
# DH Inspección Android · release F6.1
## Candidata vigente
- Fase funcional: **F6.1**.
- `versionName`: **0.15.0**.
- `versionCode`: **22**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente.
El artefacto de CI contiene:
- APK debug;
- archivo `.sha256`;
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
## Criterio de distribución
Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica;
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre;
- registrar el SHA Git y SHA-256 de la APK distribuida.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 21
versionName = "0.14.0"
versionCode = 22
versionName = "0.15.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
@@ -0,0 +1,103 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
data class MobilePlanningAsset(
val id: String,
val code: String,
val name: String,
)
data class MobilePlanningAssetResponse(
val data: List<MobilePlanningAsset> = emptyList(),
)
data class OpenMobileInspectionRequest(
val operationalAreaId: String,
val operatorCompanyId: String,
)
private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas")
suspend fun areas(
@Header("Authorization") authorization: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("areaId") areaId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open")
suspend fun open(
@Header("Authorization") authorization: String,
@Body request: OpenMobileInspectionRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
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)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}")
}
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId)
}
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
api.open(
"Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -727,16 +727,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -389,13 +389,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -92,7 +92,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> DhApp(model)
else -> MobileHomeScreen(model)
}
}
}
@@ -431,13 +431,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -0,0 +1,231 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
) {
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,20 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(22, BuildConfig.VERSION_CODE)
assertEquals("0.15.0-debug", BuildConfig.VERSION_NAME)
}
@Test
fun fieldBuildTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.20.0-2",
"version": "0.29.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.20.0-2",
"version": "0.29.0-1",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.28.0-1",
"version": "0.29.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -42,5 +42,9 @@
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
}
}
}
@@ -170,20 +170,9 @@ 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 }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
// 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),
@@ -192,7 +181,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
: undefined,
}, manager);
return created;
@@ -223,8 +212,8 @@ export class AssetOperationalRelationsService {
});
}
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory.
// 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
SET valid_until = CURRENT_TIMESTAMP,
@@ -137,21 +137,41 @@ export class AssetTemporalService {
if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at);
row.asOf = asOf;
// Physical context is reconstructed from the Inventory history, while the
// operator is resolved independently from the temporal Area↔Empresa ledger.
// This keeps historical operator changes from rewriting the Inventory.
const [context] = (await this.dataSource.query(`
SELECT
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',company.name) END AS "operatorCompany"
CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',operator_company.id,'code',operator_company.code,'name',operator_company.name
) END AS "operatorCompany"
FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN assets company ON company.id=history.operator_company_id
LEFT JOIN LATERAL (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id=history.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
LIMIT 1
) operator_relation ON true
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id
WHERE history.asset_id=$1
AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC
LIMIT 1
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
`, [assetId, asOf])) as Array<{
parent: Record<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | null;
}>;
if (context && row.snapshot) {
row.snapshot = {
...row.snapshot,
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto {
@IsOptional()
@@ -10,8 +10,13 @@ export class ChangeAssetContextDto {
@IsUUID('4')
operationalAreaId?: string | null;
/**
* Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física
* del Inventario de la Operadora temporal del Área, por lo que este valor no
* puede modificarse desde un cambio de contexto del Inventario.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@@ -9,6 +9,8 @@ import {
MinLength,
} from 'class-validator';
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto {
@@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto {
@IsUUID('4')
operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
* filtrar por el snapshot histórico de Empresa del Inventario.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string;
@IsOptional()
@@ -1,6 +1,7 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmpty,
IsEnum,
IsInt,
IsOptional,
@@ -66,7 +67,12 @@ export class ListAssetsQueryDto {
@IsUUID('4')
operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada con consumidores históricos.
* F5/F6 no permite buscar ownership actual por el snapshot de Empresa del
* Inventario; la navegación Área↔Operadora usa area_company_relations.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string;
}
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsObject,
IsOptional,
IsString,
@@ -47,8 +48,12 @@ export class UpdateAssetDto {
@IsUUID('4')
operationalAreaId?: string | null;
/**
* Snapshot histórico/de compatibilidad. La Operadora vigente pertenece a la
* relación temporal Área↔Empresa y no puede editarse como propiedad física.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@@ -30,6 +30,8 @@ export enum InspectionDeadlineBasis {
GEDO_DATE = 'GEDO_DATE',
}
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
@Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -154,41 +154,26 @@ export class InspectionActsService {
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.code ILIKE ${search}
OR search_asset.name ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search})
)
OR EXISTS (
SELECT 1 FROM assets search_company
WHERE search_company.id=visit.operator_company_id
AND search_company.name ILIKE ${search}
)
OR EXISTS (
SELECT 1 FROM assets search_area
WHERE search_area.id=visit.operational_area_id
AND search_area.name ILIKE ${search}
)
)`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) {
conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
@@ -245,24 +230,8 @@ export class InspectionActsService {
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) {
conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
@@ -548,21 +517,16 @@ export class InspectionActsService {
LEFT JOIN inspection_reports report ON report.act_id = act.id
LEFT JOIN LATERAL (
SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas
FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
WHERE context_visit.id=act.visit_id
) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
@@ -813,6 +777,8 @@ export class InspectionActsService {
'code', visit.code,
'status', visit.status,
'scopeAssetId', visit.scope_asset_id,
'operationalAreaId', visit.operational_area_id,
'operatorCompanyId', visit.operator_company_id,
'actualStartedAt', visit.actual_started_at
),
'assets', COALESCE((
@@ -1,10 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable()
export class F3FindingCatalogResolverService {
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly contextualCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
@@ -36,17 +40,28 @@ export class F3FindingCatalogResolverService {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. Quedará registrado para revisión en oficina.',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a este Inventario. Quedará registrado para revisión en oficina.',
};
if (asset.typeCode.toLowerCase() === 'yacimiento') {
const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query);
return {
...contextual,
inventoryFamily: null,
catalogSource: 'ASSET_TYPE' as const,
configurationReason: contextual.configurationReason
?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.',
other,
};
}
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'El Yacimiento es contexto estructural y no posee clasificación técnica. Usá OTROS sólo si necesitás registrar una observación excepcional.'
: 'El elemento todavía no tiene una clasificación técnica activa.',
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
@@ -103,6 +118,7 @@ export class F3FindingCatalogResolverService {
name: asset.familyName,
level: asset.familyLevel,
},
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: true,
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories,
@@ -153,13 +153,16 @@ export class InspectionFindingWorklistService {
workflowFilters.push(`finding.status = 'OPEN'`);
}
// Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se
// leen del contexto congelado de la Inspección que originó el Hallazgo, no
// del snapshot de compatibilidad del Inventario.
const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
LEFT JOIN assets area ON area.id = visit.operational_area_id
`;
const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
@@ -408,30 +408,24 @@ export class InspectionReportsService {
}
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
return `EXISTS (
SELECT 1
FROM inspection_act_assets context_filter_link
INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id
WHERE context_filter_link.act_id = act.id
AND context_filter_link.included = true
AND context_filter_asset.${column} = ${parameter}::uuid
)`;
// Reports are historical documents. Their Area/Company filters must use the
// parent Inspection snapshot, never the mutable/current Inventory context.
return `visit.${column} = ${parameter}::uuid`;
}
private contextSelect(): string {
return `
SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id',company.id,'code',company.code,'name',company.name
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
LEFT JOIN assets company ON company.id=context_asset.operator_company_id
LEFT JOIN assets area ON area.id=context_asset.operational_area_id
WHERE context_link.act_id=act.id AND context_link.included=true
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas
FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
WHERE context_visit.id=act.visit_id
`;
}
@@ -66,9 +66,6 @@ interface LockedFindingRow {
assetId: string;
assetCode: string;
assetName: string;
companyId: string | null;
companyCode: string | null;
companyName: string | null;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
@@ -76,6 +73,12 @@ interface LockedFindingRow {
status: string;
}
interface PlannedOperatorRow {
companyId: string;
companyCode: string;
companyName: string;
}
@Injectable()
export class InspectionVerificationsService {
constructor(
@@ -142,8 +145,19 @@ export class InspectionVerificationsService {
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN LATERAL (
SELECT operator_company.id,operator_company.code,operator_company.name
FROM area_company_relations relation
INNER JOIN assets operator_company ON operator_company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND operator_company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
) company ON true
${activeVisitJoin}
`;
@@ -495,15 +509,11 @@ export class InspectionVerificationsService {
finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode",
asset.name AS "assetName",
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName",
area.id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName"
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
WHERE finding.id = ANY($1::uuid[])
FOR UPDATE OF finding
@@ -519,25 +529,51 @@ export class InspectionVerificationsService {
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
});
}
const missingContext = rows.find((row) => !row.companyId || !row.areaId);
if (missingContext) {
const missingArea = rows.find((row) => !row.areaId);
if (missingArea) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_REQUIRED',
message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`,
message: `${missingArea.code} necesita un Área física antes de planificar la visita`,
});
}
const companyId = rows[0]?.companyId;
const areaId = rows[0]?.areaId;
if (!companyId || !areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' });
if (!areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' });
}
if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) {
if (rows.some((row) => row.areaId !== areaId)) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_MIXED',
message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación',
message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación',
});
}
// A verification visit is a new operational act. Its operator is therefore
// the Company valid for the Area at the planned visit date, never the
// historical compatibility snapshot stored on the Inventory.
const [operator] = await manager.query(`
SELECT
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
AND company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
FOR SHARE OF relation
`, [areaId, plannedStartAt]) as PlannedOperatorRow[];
if (!operator) {
throw new ConflictException({
code: 'VERIFICATION_OPERATOR_NOT_ACTIVE',
message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación',
});
}
const companyId = operator.companyId;
const activeLinks = await manager.query(`
SELECT verification_link.finding_id AS "findingId", visit.code
FROM inspection_finding_verification_visits verification_link
@@ -552,7 +588,7 @@ export class InspectionVerificationsService {
});
}
const companyName = rows[0]?.companyName ?? 'Empresa';
const companyName = operator.companyName;
const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const findingCodes = rows.map((row) => row.code).join(', ');
@@ -637,6 +673,7 @@ export class InspectionVerificationsService {
areaId,
findingIds,
assetIds,
operatorSource: 'area_company_relations',
},
}, manager);
@@ -647,7 +684,7 @@ export class InspectionVerificationsService {
status: visit.status,
plannedStartAt: visit.plannedStartAt,
},
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
company: { id: companyId, code: operator.companyCode, name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
findingCount: findingIds.length,
assetCount: assetIds.length,
@@ -0,0 +1,9 @@
import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto {
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
operatorCompanyId!: string;
}
@@ -265,13 +265,14 @@ export class FieldFindingsService {
message: 'Registro de Inventario no encontrado',
});
}
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
const assetTypeCode = String(row.assetTypeCode).toLowerCase();
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
throw new BadRequestException({
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
});
}
if (!row.inventoryFamilyId) {
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
@@ -295,11 +295,13 @@ export class InspectionVisitLifecycleService {
}
}
// El contexto operativo de una Inspección es Área + Operadora vigente. El
// Yacimiento pertenece al alcance físico y nunca reemplaza al Área raíz.
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
throw new ConflictException({
code: 'INSPECTION_PLANNING_INCOMPLETE',
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
message: 'Definí Área, Operadora, Inspector responsable y fecha de inicio antes de planificar',
});
}
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
@@ -320,7 +322,7 @@ export class InspectionVisitLifecycleService {
if (!relation) {
throw new ConflictException({
code: 'INSPECTION_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área',
});
}
const [inspector] = await manager.query(`
@@ -13,11 +13,13 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
@@ -57,6 +59,43 @@ export class InspectionVisitsController {
return this.visits.listPlanningOperators(areaId);
}
@Get('mobile/planning-context/areas')
@RequirePermissions('inspections.execute')
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
assertMobileInspector(principal);
return this.visits.listPlanningAreas();
}
@Get('mobile/planning-context/areas/:areaId/operators')
@RequirePermissions('inspections.execute')
mobilePlanningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.visits.listPlanningOperators(areaId);
}
@Post('mobile/open')
@RequirePermissions('inspections.execute')
async openMobile(
@Body() dto: OpenMobileInspectionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
assertMobileInspector(principal);
// Android no tiene un lifecycle paralelo: crea la visita y delega las
// transiciones DRAFT → PLANNED → IN_PROGRESS a los servicios canónicos.
const created = await this.visits.create({
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: new Date().toISOString(),
leadInspectorUserId: principal.userId,
}, principal, request);
await this.lifecycle.plan(created.id, principal, request);
return this.lifecycle.start(created.id, principal, request);
}
@Get(':id')
@RequirePermissions('inspections.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.28.0-1';
export const API_PHASE = 'F6';
export const API_VERSION = '0.29.0-1';
export const API_PHASE = 'F6.1';
@@ -33,13 +33,15 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
});
test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
test('D5.3/F6.1 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
assert.match(service, /Ending an operator relation never moves, rewrites or blocks existing Inventory/);
assert.match(service, /operatorSnapshotPreserved:true/);
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.doesNotMatch(service, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
});
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6 release', () => {
assert.equal(API_PHASE, 'F6');
assert.equal(API_VERSION, '0.28.0-1');
test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1');
});
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6 Android test cut targets production API and has a distinct installable debug version', () => {
test('F6.1 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 = 21/);
assert.match(gradle, /versionName = "0\.14\.0"/);
assert.match(gradle, /versionCode = 22/);
assert.match(gradle, /versionName = "0\.15\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 allows direct findings only on Yacimiento, Instalacion and Subinstalacion', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
assert.match(findings, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
assert.match(findings, /FIELD_FINDING_TARGET_LEVEL_INVALID/);
assert.match(findings, /Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación/);
assert.match(findings, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
assert.match(findings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-another everywhere', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
const catalog = source('src/inspection-findings/finding-catalog.service.ts');
const androidFinding = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
assert.match(findings, /canAddAnother: true/);
assert.match(resolver, /typeCode\.toLowerCase\(\) === 'yacimiento'/);
assert.match(resolver, /contextualCatalog\.listApplicableForAsset\(assetId, query\)/);
assert.match(resolver, /catalogSource: 'ASSET_TYPE'/);
assert.match(catalog, /finding_catalog_item_asset_types mapping/);
assert.match(catalog, /finding_catalog_asset_overrides override/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
assert.match(androidFinding, /OTROS · No está en el catálogo/);
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
});
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
assert.match(client, /inspection-visits\/mobile\/open/);
assert.match(home, /Abrir inspección/);
assert.match(home, /Abrir inspección ahora/);
assert.match(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
});
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 preserves Inventory operator snapshots and changes operator only through Area relations', () => {
const relations = source('src/asset-master/asset-operational-relations.service.ts');
const migration = source('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts');
assert.match(migration, /Runtime ownership and search MUST NOT depend on it/);
assert.match(migration, /La Empresa se cambia en la relación temporal del Área, no en el Inventario/);
assert.match(relations, /operatorSnapshotPreserved:true/);
assert.doesNotMatch(relations, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
});
test('F6.1 blocks operator mutation through generic Inventory edit DTOs', () => {
const updateDto = source('src/asset-master/dto/update-asset.dto.ts');
const contextDto = source('src/asset-master/dto/change-asset-context.dto.ts');
for (const dto of [updateDto, contextDto]) {
assert.match(dto, /@IsEmpty/);
assert.match(dto, /La Operadora se administra en la relación temporal del Área, no en el Inventario/);
}
});
test('F6.1 rejects legacy Inventory searches that filter by operator snapshot', () => {
const listDto = source('src/asset-master/dto/list-assets-query.dto.ts');
const treeDto = source('src/asset-master/dto/list-asset-tree-query.dto.ts');
for (const dto of [listDto, treeDto]) {
assert.match(dto, /operatorCompanyId\?: string/);
assert.match(dto, /@IsEmpty/);
assert.match(dto, /Filtrá la Operadora mediante la relación temporal del Área/);
}
});
test('F6.1 resolves temporal Inventory operator from Area relation history', () => {
const temporal = source('src/asset-master/asset-temporal.service.ts');
assert.match(temporal, /FROM area_company_relations relation/);
assert.match(temporal, /relation\.valid_from <= \$2/);
assert.match(temporal, /relation\.valid_until IS NULL OR relation\.valid_until > \$2/);
assert.match(temporal, /operator_company\.id=operator_relation\.company_id/);
assert.doesNotMatch(temporal, /operator_company\.id=history\.operator_company_id/);
});
test('F6.1 verification planning uses Area relation truth instead of Inventory operator snapshot', () => {
const verification = source('src/inspection-verifications/inspection-verifications.service.ts');
assert.match(verification, /FROM area_company_relations relation/);
assert.match(verification, /relation\.valid_from <= \$2::timestamptz/);
assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/);
assert.match(verification, /operatorSource: 'area_company_relations'/);
assert.doesNotMatch(verification, /company\.id = asset\.operator_company_id/);
});
test('F6.1 historical operational lists read Company and Area from the parent Inspection', () => {
const acts = source('src/inspection-acts/inspection-acts.service.ts');
const findings = source('src/inspection-findings/inspection-finding-worklist.service.ts');
const reports = source('src/inspection-reports/inspection-reports.service.ts');
assert.match(acts, /visit\.operator_company_id/);
assert.match(acts, /visit\.operational_area_id/);
assert.doesNotMatch(acts, /context_asset\.operator_company_id/);
assert.match(findings, /company\.id = visit\.operator_company_id/);
assert.match(findings, /area\.id = visit\.operational_area_id/);
assert.doesNotMatch(findings, /company\.id = asset\.operator_company_id/);
assert.match(reports, /visit\.\$\{column\} = \$\{parameter\}::uuid/);
assert.match(reports, /company\.id=context_visit\.operator_company_id/);
assert.match(reports, /area\.id=context_visit\.operational_area_id/);
assert.doesNotMatch(reports, /context_asset\.operator_company_id/);
});
test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => {
const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
}
assert.doesNotMatch(inventoryMerge, /source\.operatorCompany/);
});
test('F6.1 WEB derives the creation snapshot from the active Area operator and removes it from Inventory edits', () => {
const editor = source('../web-v2/src/pages/AssetEditorPage.tsx');
const contextPanel = source('../web-v2/src/features/assets/AssetContextHistoryPanel.tsx');
assert.match(editor, /createAsset\(\{[\s\S]*operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(editor, /updateAsset\(id, \{[^}]*operatorCompanyId/);
assert.doesNotMatch(editor, /parent\.operatorCompany/);
assert.match(editor, /listCompaniesForArea\(operationalAreaId\)\.then\(\(loadedCompanies\)/);
assert.match(editor, /loadedCompanies\.length === 1 \? \(loadedCompanies\[0\]\?\.id \?\? ''\) : ''/);
assert.match(editor, /Snapshot histórico de alta/);
assert.match(contextPanel, /Cambiar contexto físico/);
assert.doesNotMatch(contextPanel, /operatorCompanyId:/);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
test('F6.1 presentation metadata keeps the visible WEB version aligned with package metadata', () => {
const pkg = JSON.parse(web('package.json')) as { version: string };
const version = web('src/config/version.ts');
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo ÁreaOperadora consolidado'/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
const app = web('src/app/App.tsx');
const layout = web('src/layout/AppLayout.tsx');
assert.doesNotMatch(app, /relevamientos/i);
assert.doesNotMatch(layout, /relevamientos/i);
assert.match(layout, /label: 'Inspecciones'/);
assert.match(layout, /label: 'Inventarios'/);
});
test('F6.1 presentation keeps the complete Inspector profile and documentary-copy explanation visible', () => {
const user = web('src/pages/UserDetailPage.tsx');
const delivery = api('src/inspection-reports/inspection-document-delivery.service.ts');
for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) {
assert.match(user, new RegExp(`name="${field}"`));
}
assert.match(user, /email es obligatorio para un Inspector/i);
assert.match(user, /la documentación se enviará también/i);
assert.match(delivery, /recipientKind:'INSPECTOR'/);
assert.match(delivery, /documentKind:'ACT_PDF'/);
assert.match(delivery, /documentKind:'REPORT_WORD'/);
});
test('F6.1 presentation keeps Android start, Otro and chronological merge flows wired to the canonical API', () => {
const mobileApi = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
const overview = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const viewModel = android('app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(mobileApi, /inspection-visits\/\{id\}\/start/);
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
assert.match(overview, /"Iniciar inspección"/);
assert.match(overview, /Otro \/ no catalogado/);
assert.match(viewModel, /repository\.startVisit\(id\)/);
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
});
test('F6.1 presentation keeps catalog merge append-only for emitted findings', () => {
const merge = api('src/inspection-findings/finding-catalog-merge.service.ts');
const resolver = api('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(merge, /los Hallazgos emitidos nunca se reescriben/);
assert.match(merge, /historicalFindingsRewritten: false/);
assert.match(merge, /historyPolicy: 'EMITTED_FINDINGS_PRESERVED'/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 conserva una jerarquía territorial única y Empresa fuera del árbol físico', () => {
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
assert.match(dto, /'EMPRESA',[\s\S]*'DEPARTAMENTO',[\s\S]*'AREA',[\s\S]*'YACIMIENTO',[\s\S]*'INSTALACION',[\s\S]*'SUBINSTALACION'/);
assert.match(structure, /EMPRESA:\s*null/);
assert.match(structure, /DEPARTAMENTO:\s*null/);
assert.match(structure, /AREA:\s*'departamento'/);
assert.match(structure, /YACIMIENTO:\s*'area'/);
assert.match(structure, /INSTALACION:\s*'yacimiento'/);
assert.match(structure, /SUBINSTALACION:\s*'instalacion'/);
assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
});
test('F6.1 usa Área + Operadora como contexto de planificación y no mezcla Yacimiento con Área', () => {
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
const visits = source('src/inspection-visits/inspection-visits.service.ts');
assert.match(lifecycle, /El contexto operativo de una Inspección es Área \+ Operadora vigente/);
assert.doesNotMatch(lifecycle, /Área\/Yacimiento/);
assert.match(visits, /FROM area_company_relations/);
assert.match(visits, /relation\.relation_role = 'OPERATOR'/);
});
test('F6.1 mantiene múltiples Actas por Inspección pero un solo borrador simultáneo', () => {
const act = source('src/database/entities/inspection-act.entity.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.match(act, /uq_inspection_acts_one_draft_per_visit/);
assert.match(act, /where: "status = 'DRAFT'"/);
assert.match(act, /Una Inspección puede acumular múltiples Actas/);
assert.match(lifecycle, /Todas las Actas deben estar SELLADAS antes de cerrar la Inspección/);
});
test('F6.1 permite Hallazgos directos sólo en Yacimiento, Instalación y Subinstalación y conserva OTROS', () => {
const field = source('src/inspection-visits/field-findings.service.ts');
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(field, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
assert.match(field, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
assert.match(field, /canAddAnother: true/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
test('F6.1 abre Inspecciones desde Android usando el lifecycle canónico', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
});
test('F6.1 separa oficialización GEDO de la activación del vencimiento no urgente', () => {
const report = source('src/inspection-reports/inspection-report-workflow.service.ts');
const act = source('src/database/entities/inspection-act.entity.ts');
assert.match(report, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
assert.match(report, /deadlineActivation: 'PENDING_NOTIFICATION_EVENT'/);
assert.match(act, /NOTIFICATION_DATE = 'GEDO_DATE'/);
});
+91
View File
@@ -0,0 +1,91 @@
# Auditoría de consistencia · F6.1
**Baseline revisado:** `25a63cd3d4b2c561803cc5105cce100983c8eeff`
**Alcance:** API, modelo de datos, Inventarios, Inspecciones, Actas, Hallazgos, Informes/GEDO, WEB, Android, permisos, versionado documental y CI.
## Resultado
El modelo funcional consolidado de F6.1 es coherente. No se detectó una contradicción estructural que requiera rediseñar tablas o migrar datos antes de continuar.
La auditoría sí encontró deuda de terminología/documentación y una inconsistencia de metadata generada. Las correcciones funcionales/documentales se integran en esta rama; la metadata de lockfiles se documenta como observación no funcional y debe regenerarse con npm en un corte controlado, no editarse parcialmente a mano.
## Contratos verificados
### Inventario
- Jerarquía física única: Departamento → Área → Yacimiento → Instalación → Subinstalación.
- Empresa es un maestro independiente.
- Operadora vigente se resuelve por relación temporal Área ⇄ Empresa.
- Cambiar Operadora no reparenta Inventario.
- Departamento, Área y Yacimiento no llevan familia técnica.
- Instalación y Subinstalación requieren familia técnica.
- Subinstalación debe ser compatible con la familia de la Instalación padre.
- PostgreSQL protege jerarquía y compatibilidad, además de las validaciones de API.
### Inspecciones
- Lifecycle canónico DRAFT → PLANNED → IN_PROGRESS → CLOSED.
- Planificación requiere Área, Operadora vigente, Inspector responsable, fecha/hora y checklist.
- La APK abre una Inspección reutilizando el mismo create/plan/start; no existe un lifecycle móvil paralelo.
- No hay referencias activas a `plannedEndAt` / `planned_end_at` en el código actual.
### Actas
- Una Inspección puede contener múltiples Actas.
- El constraint histórico de una sola Acta por visita fue eliminado por F1.1.
- Existe un índice parcial que permite sólo un Acta DRAFT simultánea por Inspección.
- El cierre de la Inspección exige que todas las Actas no canceladas estén SELLADAS.
- El Acta finalizada/bloqueada es inmutable.
### Hallazgos
- Destinos directos: Yacimiento, Instalación y Subinstalación.
- Departamento y Área no son destinos directos.
- OTROS / Agregar otro permanece disponible.
- Yacimiento no requiere familia técnica.
- Instalación/Subinstalación sí requieren clasificación.
- Inventario nacido en campo necesita GPS + foto antes de admitir Hallazgos.
### Informes / GEDO
- El Informe pertenece al Acta.
- La oficialización GEDO/IF es inmutable.
- Oficializar en GEDO no activa por sí solo el vencimiento No urgente.
- El valor persistido histórico `GEDO_DATE` se conserva como ABI de base; en código existe el alias semántico `NOTIFICATION_DATE` para evitar interpretar GEDO como notificación automática.
## Correcciones integradas en la auditoría
1. Se corrigió la terminología residual `Área/Yacimiento` en el lifecycle de Inspecciones. El contexto operativo queda definido inequívocamente como **Área + Operadora vigente**.
2. Se documentó en código la separación entre Empresa y la jerarquía física del Inventario.
3. Se documentó en la entidad Acta el contrato multi-Acta + único DRAFT simultáneo.
4. Se agregó un test transversal F6.1 que protege jerarquía, contexto operativo, multi-Acta, Hallazgos, apertura móvil y separación GEDO/vencimiento.
5. Se consolidó `docs/F6_INVENTORY_MODEL.md` como única referencia viva y los duplicados históricos apuntan a ella.
6. Se crearon `MANUAL_PROGRAMADOR.md` y `MANUAL_USUARIO.md`.
## Compatibilidades históricas que NO deben “limpiarse” sin migración
- Migraciones antiguas conservan nombres y reglas que fueron válidas en fases anteriores. No se modifican; migraciones posteriores son las que expresan la evolución.
- `InspectionDeadlineBasis.GEDO_DATE` comparte valor persistido con `NOTIFICATION_DATE` por compatibilidad de esquema.
- `assets.operator_company_id` puede conservar una fotografía de compatibilidad incluso cuando la relación temporal ya finalizó. La fuente de verdad vigente/histórica sigue siendo `area_company_relations`.
- F5.1 es una migración destructiva de inicio limpio y no tiene `down()` reconstructivo; su rollback es el backup PRE.
## Observación no funcional: metadata de package-lock
Los `package.json` actuales declaran las versiones de producto vigentes, pero la metadata `name/version` de la raíz de ambos `package-lock.json` todavía conserva `0.20.0-2` de un corte anterior.
Esto no cambia el grafo de dependencias ni el runtime y `npm ci` continúa siendo la barrera de instalación. Se deja explícitamente registrado para no ocultarlo.
**Tratamiento correcto:** regenerar cada lockfile con npm (`npm install --package-lock-only`) cuando se haga el próximo corte controlado de metadata/dependencias y verificar el diff completo. No editar manualmente sólo las primeras líneas del lockfile.
## Criterio de aceptación de esta auditoría
Antes de mergear esta rama deben pasar nuevamente:
- API typecheck + tests + build.
- WEB typecheck + contrato + build.
- migraciones sobre PostGIS limpio.
- preflight aislado equivalente al VPS.
- build de imágenes productivas.
- Android assemble + unit tests cuando el workflow se dispare por el PR.
La rama de documentación/consistencia no debe promoverse a `deploy` durante esta revisión. El despliegue queda deliberadamente pausado.
+131 -1
View File
@@ -1 +1,131 @@
# F6 · Modelo definitivo de Inventarios
# F6.1 · Modelo canónico de Inventarios
Este documento es la referencia funcional y técnica del modelo de Inventarios de DH Inspección V2. Ante una contradicción con documentación histórica, prevalecen este documento, las migraciones vigentes y los contratos automatizados de F6.1.
## 1. Jerarquía física
La estructura territorial y física es única:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
Reglas:
- `Departamento` es raíz.
- `Área` requiere un Departamento padre.
- `Yacimiento` requiere un Área padre.
- `Instalación` requiere un Yacimiento padre.
- `Subinstalación` requiere una Instalación padre.
- La base de datos rechaza combinaciones de padre/hijo que no respeten esta secuencia.
- El campo `parent_id` expresa exclusivamente esta pertenencia física.
## 2. Empresa no pertenece al árbol físico
`Empresa` es un maestro independiente. No es padre de Departamento, Área, Yacimiento, Instalación ni Subinstalación.
La Operadora vigente de un Área se expresa mediante `area_company_relations`, con `relation_role = 'OPERATOR'` y vigencia temporal. Esta relación es la fuente de verdad para saber qué Empresa opera un Área en una fecha determinada.
`assets.operator_company_id` puede existir como fotografía de compatibilidad del contexto actual, pero no debe utilizarse para reconstruir la historia ni para reparentar Inventario.
**Cambiar de Operadora nunca mueve la jerarquía física.** Se finaliza la relación temporal anterior y se crea la nueva relación Empresa ⇄ Área.
## 3. Contexto transversal de Área
Los elementos bajo un Área conservan `operational_area_id` como contexto transversal para consultas, planificación y trazabilidad. El Yacimiento forma parte del alcance físico, pero no sustituye al Área como contexto operativo de una Inspección.
Por lo tanto, la planificación de una Inspección trabaja con:
**Área + Operadora vigente + Inspector responsable + fecha/hora de inicio**.
## 4. Clasificación técnica
Los niveles territoriales no usan clasificación técnica:
- Departamento: sin familia técnica.
- Área: sin familia técnica.
- Yacimiento: sin familia técnica.
Los niveles físicos sí la requieren:
- Instalación: una familia de nivel `INSTALLATION`.
- Subinstalación: una familia de nivel `SUBINSTALLATION` compatible con la familia de la Instalación padre.
La compatibilidad se almacena en `inventory_family_parent_rules` y se valida también en PostgreSQL. Una misma familia de Subinstalación puede ser compatible con varias familias de Instalación.
Los campos técnicos específicos de cada familia se definen en `inventory_family_attribute_definitions` y sus valores por Inventario en `asset_inventory_attribute_values`.
## 5. Hallazgos
Un Hallazgo puede asignarse directamente a:
- Yacimiento.
- Instalación.
- Subinstalación.
No se asignan Hallazgos directos a Departamento ni Área.
`OTROS / Agregar otro` debe estar siempre disponible. Permite describir un Hallazgo que no exista todavía en el catálogo aplicable y dejarlo registrado para revisión posterior.
Para Yacimiento, la aplicabilidad del catálogo puede resolverse por tipo de Inventario y por excepciones del objeto concreto. Yacimiento no necesita una familia técnica para recibir Hallazgos.
Instalación y Subinstalación deben estar clasificadas antes de recibir un Hallazgo.
## 6. Inventario nacido en campo
La APK puede incorporar Inventario encontrado durante una Inspección. Un registro creado en campo conserva su trazabilidad de origen y, antes de permitir Hallazgos sobre él, debe tener como mínimo:
- GPS capturado para la creación.
- al menos una fotografía de campo.
- clasificación técnica cuando se trate de Instalación o Subinstalación.
La excepción transitoria que permite crear un registro `FIELD_SURVEY + DRAFT` sin familia existe únicamente para completar la transacción de alta; no habilita Hallazgos sin clasificación.
## 7. Carga manual recomendada
El orden seguro para construir una estructura desde cero es:
1. Crear las Empresas necesarias como maestros independientes.
2. Crear Departamento.
3. Crear Área dentro del Departamento.
4. Registrar la Operadora vigente del Área, si ya se conoce.
5. Crear Yacimiento dentro del Área.
6. Crear Instalación dentro del Yacimiento y seleccionar su familia técnica.
7. Crear Subinstalación dentro de la Instalación y seleccionar una familia compatible.
La importación de archivos no debe definir la arquitectura del modelo. Los datos externos se adaptan a este contrato; el contrato no se deforma para adaptarse a cada planilla.
## 8. Historia y consulta temporal
DH conserva dos conceptos separados:
- historia física/contextual del Inventario;
- historia temporal de la relación Área ⇄ Empresa.
Los cambios relevantes generan versiones y eventos de auditoría. Las consultas históricas deben utilizar la vigencia correspondiente a la fecha consultada, no sólo la fotografía actual de las columnas de `assets`.
## 9. Barreras de integridad
La consistencia no depende sólo de la interfaz. Se protege en varias capas:
- DTO y servicios de API validan niveles y padres.
- PostgreSQL valida la jerarquía canónica.
- PostgreSQL valida nivel y compatibilidad de familias técnicas.
- la planificación valida una relación Operadora ⇄ Área vigente.
- los Hallazgos validan nivel permitido, clasificación técnica y pertenencia a la Inspección/Acta.
- CI ejecuta la cadena completa de migraciones sobre PostGIS limpio y los contratos transversales.
## 10. Corte limpio F5.1
`F51CleanManualInventory1790087400000` fue un corte intencional de inicio limpio. Eliminó datos operativos/importados de desarrollo y dejó los maestros técnicos necesarios para reconstruir manualmente una estructura confiable.
Es una migración deliberadamente no reversible mediante `migration:revert`. Su recuperación depende del backup PRE del despliegue. No debe copiarse este patrón para migraciones normales sin una decisión explícita y una estrategia de restauración probada.
## 11. Regla para cambios futuros
Cualquier modificación del modelo debe mantener simultáneamente:
1. la jerarquía física canónica;
2. Empresa fuera del árbol;
3. relación temporal Área ⇄ Empresa como fuente de verdad operativa;
4. compatibilidad técnica de familias;
5. trazabilidad histórica;
6. protección en base de datos y tests, no sólo en la UI.
+3 -5
View File
@@ -1,7 +1,5 @@
# F6 · Modelo definitivo de Inventarios
# F6 · Modelo de Inventarios
La jerarquía física canónica es fija:
Este archivo se conserva por compatibilidad con referencias históricas.
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
+3 -5
View File
@@ -1,7 +1,5 @@
# F6 · Modelo definitivo de Inventarios
# F6 · Modelo de Inventarios
La jerarquía física canónica es fija:
Este archivo se conserva por compatibilidad con referencias históricas.
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
+370
View File
@@ -0,0 +1,370 @@
# Manual del Programador · DH Inspección V2
**Baseline documental:** F6.1
**API:** 0.29.0-1
**WEB:** 0.23.0-1
**Android:** 0.15.0
Este manual describe la arquitectura, las reglas que no deben romperse y el procedimiento seguro para modificar DH Inspección V2. No reemplaza los tests ni las restricciones de base de datos: los tres forman juntos el contrato del sistema.
---
## 1. Estructura del repositorio
- `api-v3/`: API NestJS + TypeORM + PostgreSQL/PostGIS.
- `web-v2/`: aplicación WEB React/Vite.
- `android-app/`: aplicación Android Kotlin/Compose.
- `scripts/`: despliegue, preflight y utilidades operativas.
- `docs/`: documentación funcional y técnica viva.
- `.github/workflows/`: CI de API/WEB/Docker y build/test de Android.
El backend expone `/api/v3`. Los cambios de dominio deben resolverse primero en API/base de datos; WEB y Android consumen ese contrato y no deben inventar reglas paralelas.
---
## 2. Invariantes de dominio
### 2.1 Inventario físico
La jerarquía canónica es:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
`parent_id` expresa exclusivamente esa jerarquía. No agregue Empresa dentro de este árbol.
### 2.2 Empresa y Operadora
`Empresa` es un maestro independiente.
La relación operativa vigente se modela en `area_company_relations`:
- Área ↔ Empresa.
- `relation_role = 'OPERATOR'` para la Operadora.
- `valid_from` / `valid_until` para vigencia.
Esta tabla es la fuente temporal de verdad. `assets.operator_company_id` es una fotografía de compatibilidad; nunca debe usarse como sustituto del historial.
Cambiar Operadora no mueve Yacimientos, Instalaciones ni Subinstalaciones.
### 2.3 Clasificación técnica
- Departamento: sin familia.
- Área: sin familia.
- Yacimiento: sin familia.
- Instalación: requiere familia `INSTALLATION`.
- Subinstalación: requiere familia `SUBINSTALLATION` compatible con la Instalación padre.
La compatibilidad está protegida por PostgreSQL, no sólo por la UI.
### 2.4 Hallazgos
Un Hallazgo directo sólo puede asignarse a:
- Yacimiento.
- Instalación.
- Subinstalación.
Departamento y Área no reciben Hallazgos directos.
`OTROS / Agregar otro` debe permanecer disponible siempre. En Yacimiento no se exige familia técnica. En Instalación/Subinstalación sí.
Si un elemento fue creado en campo, antes de admitir un Hallazgo debe tener GPS de creación y al menos una fotografía; Instalación/Subinstalación además deben quedar clasificadas.
### 2.5 Inspecciones
Lifecycle operativo:
`DRAFT → PLANNED → IN_PROGRESS → CLOSED`
La planificación requiere:
- Área.
- Operadora vigente del Área.
- Inspector responsable habilitado.
- fecha/hora prevista de inicio.
- checklist generado.
La APK puede abrir una Inspección, pero no posee un lifecycle alternativo: el endpoint móvil crea la visita y utiliza los mismos servicios `plan()` y `start()` que el resto del sistema.
### 2.6 Actas
Una Inspección puede tener **múltiples Actas**.
Reglas críticas:
- sólo puede existir un `DRAFT` simultáneo por Inspección;
- un Acta en `DRAFT` es editable;
- al finalizar se bloquea y queda inmutable;
- el sellado conserva integridad y firmas;
- no existe reapertura normal de un Acta bloqueada a borrador;
- para cerrar la Inspección todas las Actas no canceladas deben estar `SEALED`;
- el Informe pertenece al Acta, no a la Inspección completa.
La unicidad del borrador está protegida por el índice parcial `uq_inspection_acts_one_draft_per_visit`.
### 2.7 Informe, GEDO y vencimientos
Flujo conceptual:
**Acta → INF → oficialización GEDO/IF**
GEDO oficializa el Informe, pero **no equivale por sí solo a la notificación administrativa que inicia el plazo No urgente**.
Existe una compatibilidad histórica importante: el valor físico `GEDO_DATE` se conserva en el enum/base, mientras el significado funcional vigente es `NOTIFICATION_DATE`. No cambie el valor persistido en una refactorización sin una migración explícita y pruebas de compatibilidad.
---
## 3. Modelo de Inventarios
La referencia completa es [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
Puntos técnicos centrales:
- `assets.parent_id`: pertenencia física.
- `assets.operational_area_id`: contexto transversal de Área.
- `area_company_relations`: historia de Operadora.
- `inventory_families`: clasificaciones técnicas.
- `inventory_family_parent_rules`: compatibilidad Subinstalación ↔ Instalación.
- `inventory_family_attribute_definitions`: campos técnicos por familia.
- `asset_inventory_attribute_values`: valores técnicos por objeto.
- `asset_context_history` y versiones: trazabilidad contextual/histórica.
No se debe rediseñar el esquema para coincidir con una planilla importada. Los archivos externos se normalizan al modelo canónico.
---
## 4. Migraciones
### Regla principal
**Nunca modifique una migración ya aplicada.**
Si cambia esquema, constraints, seeds o reglas persistentes:
1. cree una migración nueva;
2. haga `up()` determinista;
3. agregue verificación al final cuando la regla sea crítica;
4. implemente `down()` cuando la operación sea razonablemente reversible;
5. agregue/actualice tests de contrato;
6. pruebe la cadena completa sobre PostGIS limpio.
### F5.1 es una excepción histórica
`F51CleanManualInventory1790087400000` fue un corte destructivo intencional para reiniciar los datos de dominio. Su `down()` no intenta reconstruir información eliminada: el rollback depende del backup PRE.
No repita este patrón para una migración normal sin una decisión explícita, backup validado y procedimiento de restauración.
### Guards importantes de base de datos
PostgreSQL protege, entre otras cosas:
- jerarquía Departamento → Área → Yacimiento → Instalación → Subinstalación;
- familia correcta según el nivel físico;
- compatibilidad de familia de Subinstalación con su Instalación padre;
- un solo Acta `DRAFT` por Inspección.
Las validaciones de API mejoran la experiencia, pero no reemplazan estos guards.
---
## 5. API
### Principios
- Reutilice servicios canónicos; no duplique lifecycle en controllers móviles.
- Las mutaciones relevantes deben producir auditoría.
- Los errores de dominio deben tener `code` estable y mensaje legible.
- Los estados históricos/inmutables no deben reabrirse por conveniencia de UI.
- Para relaciones temporales, valide la vigencia en la fecha correspondiente, no sólo `valid_until IS NULL` cuando el caso exige reconstrucción histórica.
### Áreas a revisar antes de tocar un flujo
- Inventario: `src/asset-master/`.
- Inspecciones: `src/inspection-visits/`.
- Actas: `src/inspection-acts/`, `src/inspection-closing/`, `src/act-administration/`.
- Hallazgos: `src/inspection-findings/` y gate móvil en `src/inspection-visits/field-findings.service.ts`.
- Informes/GEDO: `src/inspection-reports/`.
- Verificaciones: `src/inspection-verifications/`.
- Entidades/migraciones: `src/database/`.
---
## 6. WEB
La WEB es el frente principal de administración, planificación y seguimiento. Las rutas protegidas se declaran en `web-v2/src/app/App.tsx` y usan permisos para controlar acceso.
Módulos principales:
- Dashboard.
- Mapa.
- Inventarios y dossier.
- Revisión de Inventario nacido en campo.
- Inspecciones y preparación de campo.
- Actas.
- Hallazgos y planificación de verificaciones.
- Seguimiento de Actas.
- Informes.
- Historial y consulta temporal.
- Administración de usuarios, roles, auditoría, tipos y catálogo.
No esconda una regla sólo en la WEB: toda restricción de integridad debe existir también en API/DB.
---
## 7. Android
La APK está orientada a ejecución en campo.
Reglas que deben mantenerse:
- sólo un usuario con capacidad de ejecución puede iniciar/operar una Inspección;
- `Abrir inspección` selecciona Área y Operadora vigente;
- la nueva visita queda autoasignada al inspector actual;
- se genera checklist antes de iniciar;
- el backend recorre DRAFT → PLANNED → IN_PROGRESS con el lifecycle canónico;
- Inventario nuevo en campo captura evidencia mínima antes de Hallazgos;
- Hallazgos respetan los mismos niveles y catálogo que la API.
Al cambiar contratos móviles, actualice tanto Kotlin como los tests de contrato montados por el preflight de API.
---
## 8. Permisos y seguridad
El sistema utiliza permisos finos; las pantallas no deben asumir permisos por nombre visual del rol. La fuente efectiva es la matriz rol-permiso de base de datos.
Buenas prácticas:
- proteja controllers con permisos explícitos;
- no confíe en que una ruta esté oculta en la WEB;
- preserve la política especial del inspector móvil;
- mantenga auditadas las mutaciones administrativas;
- no exponga secretos ni credenciales en repositorio, logs o documentación.
---
## 9. Tests y CI
La barrera principal está en `.github/workflows/ci.yml`.
### API
Ejecuta:
```bash
npm ci
npm run typecheck
npm test
npm run build
```
### WEB
Ejecuta:
```bash
npm ci
npm run typecheck
bash ../scripts/check-f3-1-web-contract.sh
npm run build
```
### Docker / contratos
Valida:
- sintaxis de scripts shell;
- paridad del preflight de deploy;
- `docker compose config`;
- cadena de migraciones sobre PostGIS limpio;
- estado limpio F5.1 y preservación de maestros técnicos;
- segunda ejecución sin migraciones pendientes;
- preflight API aislado equivalente al VPS;
- build de imágenes productivas.
### Android
El workflow específico de Android debe completar:
- `assembleDebug`;
- unit tests;
- publicación del artefacto APK.
### Contrato transversal F6.1
`api-v3/test/unit/f6-1-project-invariants.test.ts` fija las reglas que más fácilmente podrían degradarse por refactors: jerarquía, contexto Área+Operadora, multi-Acta, Hallazgos, apertura móvil y separación GEDO/vencimiento.
---
## 10. Versionado
Cuando una entrega cambia versión, revise como mínimo:
- `api-v3/package.json`;
- `api-v3/src/version.ts`;
- `web-v2/package.json`;
- `android-app/app/build.gradle.kts` si cambia APK;
- tests que fijen metadata de versión;
- metadata raíz de `package-lock.json` regenerándola con npm cuando corresponda.
Los lockfiles son archivos generados: no los edite manualmente en forma parcial. Use npm para reconciliarlos.
---
## 11. Flujo Git y despliegue
Secuencia recomendada:
1. crear rama desde `main` estable;
2. implementar código + migración + tests + documentación;
3. abrir PR;
4. exigir API + WEB + Docker/migraciones + Android verdes cuando aplique;
5. mergear a `main`;
6. exigir nuevamente CI verde sobre el SHA de `main`;
7. recién entonces promover el SHA exacto a `deploy`;
8. esperar el watcher/deploy;
9. comprobar SHA objetivo, health, versiones y backup generado.
Nunca despliegue un SHA distinto al que pasó la barrera. Nunca use `deploy` para “probar” una rama incompleta.
Si una migración es de una sola vía, el rollback operativo es el backup PRE validado, no un `migration:revert` improvisado.
---
## 12. Checklist antes de aprobar un cambio
- ¿Respeta la jerarquía canónica?
- ¿Empresa sigue fuera del árbol físico?
- ¿Una modificación de Operadora conserva historia temporal?
- ¿La regla importante está protegida también en API/DB?
- ¿Hallazgos siguen limitados a Yacimiento/Instalación/Subinstalación?
- ¿OTROS sigue disponible?
- ¿Una Inspección puede tener varias Actas y sólo un DRAFT simultáneo?
- ¿Actas finalizadas siguen inmutables?
- ¿Informe sigue perteneciendo al Acta?
- ¿GEDO sigue separado del inicio automático del plazo No urgente?
- ¿WEB y Android consumen el mismo contrato?
- ¿Se agregó un test para la nueva regla o regresión?
- ¿CI completa está verde?
- ¿Documentación viva quedó actualizada?
---
## 13. Terminología canónica
Use consistentemente:
- **Inventario**, no “Activo” en la interfaz de usuario cuando se refiere al módulo funcional.
- **Departamento**.
- **Área**.
- **Yacimiento**.
- **Instalación**.
- **Subinstalación**.
- **Empresa / Operadora** como maestro/rol operativo, no como padre físico.
- **Inspección** para la visita operativa.
- **Acta** para el documento de campo; una Inspección puede contener varias.
- **Hallazgo** para la observación fiscalizable asociada a Inventario.
- **Informe / INF** por Acta.
- **GEDO / IF** para la oficialización documental, separada de la notificación administrativa cuando corresponda.
Si una etiqueta histórica contradice estos términos, corrija la etiqueta; no cambie el modelo para conservar una denominación obsoleta.
+353
View File
@@ -0,0 +1,353 @@
# Manual de Usuario · DH Inspección V2
**Versión funcional de referencia:** F6.1
Este manual explica el uso cotidiano del sistema. Las opciones visibles dependen de los permisos asignados a cada usuario, por lo que un Inspector, Supervisor, Director o Administrador puede ver acciones distintas.
---
## 1. Conceptos principales
DH Inspección V2 organiza el trabajo alrededor de cuatro elementos:
1. **Inventario:** dónde se inspecciona.
2. **Inspección:** la visita de trabajo.
3. **Acta:** documento generado durante la Inspección; una Inspección puede tener varias Actas.
4. **Hallazgo:** observación asociada a un elemento concreto del Inventario.
Además, cada Acta puede continuar con su **Informe (INF)**, oficialización documental y seguimiento posterior.
---
## 2. Ingreso al sistema
1. Abra la WEB de DH Inspección V2.
2. Ingrese usuario y contraseña.
3. El menú mostrará solamente los módulos habilitados por sus permisos.
Si una opción no aparece o el sistema informa que no tiene autorización, consulte al Administrador antes de intentar resolverlo creando datos alternativos.
---
## 3. Cómo se organiza el Inventario
La estructura física correcta es siempre:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
La **Empresa** no forma parte de esa cadena. Se administra por separado y se vincula al Área cuando actúa como Operadora.
Ejemplo:
- Departamento: Malargüe
- Área: Área A
- Yacimiento: Yacimiento Norte
- Instalación: Planta de Tratamiento
- Subinstalación: Separador 01
Una Empresa puede cambiar con el tiempo sin modificar esta estructura física.
---
## 4. Carga manual de Inventario
Para construir una estructura confiable desde cero, use este orden:
1. Cree las **Empresas** necesarias.
2. Cree el **Departamento**.
3. Cree el **Área** dentro del Departamento.
4. Registre la **Operadora vigente** del Área si corresponde.
5. Cree el **Yacimiento** dentro del Área.
6. Cree la **Instalación** dentro del Yacimiento.
7. Seleccione la clasificación técnica de la Instalación.
8. Cree la **Subinstalación** dentro de la Instalación.
9. Seleccione una clasificación técnica compatible para la Subinstalación.
El sistema no permite saltear niveles. Por ejemplo, una Instalación no puede depender directamente de un Área.
### Empresa y Operadora
Cuando una Empresa opera un Área, registre la relación operativa correspondiente. Si cambia la Operadora:
1. finalice la relación vigente anterior;
2. registre la nueva Operadora;
3. no mueva Yacimientos, Instalaciones ni Subinstalaciones.
El historial queda conservado para consultas temporales.
---
## 5. Consultar Inventario
Desde **Inventarios** puede navegar la estructura territorial y abrir el dossier de cada registro.
Utilice el breadcrumb para volver hacia niveles superiores. El dossier concentra, según la información disponible:
- datos actuales;
- ubicación y clasificación;
- fotografías;
- historial de cambios;
- Hallazgos;
- verificaciones;
- Actas e Informes relacionados.
Cuando necesite saber cómo estaba un elemento en una fecha anterior, use la **Consulta temporal** en lugar de interpretar sólo sus datos actuales.
---
## 6. Crear una Inspección desde la WEB
En **Inspecciones → Nueva Inspección**:
1. seleccione el **Área**;
2. seleccione una **Operadora vigente** para esa Área;
3. seleccione el **Inspector responsable**;
4. indique la fecha y hora prevista de inicio;
5. confirme la creación.
Al crearse la Inspección, el sistema genera su checklist de antecedentes. Antes de planificar, la información obligatoria debe estar completa.
Una Inspección planificada se inicia operativamente desde la APK por un Inspector habilitado y asignado.
---
## 7. Abrir una Inspección directamente desde la APK
La APK también permite comenzar una Inspección que no haya sido preparada previamente en la WEB.
Desde la pantalla principal:
1. pulse **Abrir inspección**;
2. elija el **Área**;
3. elija la **Operadora vigente**;
4. pulse **Abrir inspección ahora**.
El sistema:
- crea la Inspección;
- la asigna al Inspector que inició sesión;
- genera su checklist;
- la planifica;
- la deja **En curso**.
No elija una Empresa por aproximación: la APK muestra la relación operativa válida del Área.
---
## 8. Trabajar con Inventario durante una Inspección
Dentro de una Inspección en curso puede seleccionar Inventario existente del Área y agregarlo al trabajo de campo/Acta correspondiente.
Los niveles relevantes para Hallazgos son:
- Yacimiento;
- Instalación;
- Subinstalación.
### Inventario encontrado en campo
Si encuentra una Instalación o Subinstalación que todavía no existe, la APK puede darla de alta dentro de la jerarquía correspondiente.
Antes de poder registrar Hallazgos sobre un elemento nuevo de campo, debe quedar como mínimo con:
- GPS de creación;
- al menos una fotografía;
- clasificación técnica si es Instalación o Subinstalación.
Esto evita que un Hallazgo quede asociado a un objeto sin identificación suficiente.
---
## 9. Actas: una Inspección puede tener varias
Una misma Inspección puede generar **varias Actas**.
La regla práctica es:
- puede haber varias Actas históricas dentro de la Inspección;
- sólo puede existir **un Acta en borrador al mismo tiempo**.
Mientras un Acta está en borrador puede completar su contenido y agregar Hallazgos. Una vez finalizada/bloqueada deja de ser editable como un borrador normal.
Para continuar con una nueva Acta dentro de la misma Inspección, finalice correctamente la actual antes de abrir la siguiente.
---
## 10. Registrar Hallazgos
Los Hallazgos se asignan directamente a:
- **Yacimiento**;
- **Instalación**;
- **Subinstalación**.
No se crean Hallazgos directos sobre Departamento ni Área.
### Hallazgo de catálogo
Si el sistema muestra un Hallazgo aplicable al elemento seleccionado:
1. elija el Hallazgo del catálogo;
2. complete la descripción y datos requeridos;
3. guarde.
### OTROS / Agregar otro
La opción **OTROS** debe estar siempre disponible.
Úsela cuando el Hallazgo observado no exista en la lista disponible. Describa claramente qué se encontró para que luego pueda revisarse y normalizarse en oficina.
Después de guardar un Hallazgo puede utilizar **Agregar otro** para continuar cargando Hallazgos sobre el mismo Inventario.
### En Yacimiento
Un Yacimiento puede recibir Hallazgos aunque no tenga clasificación técnica. Cuando exista catálogo configurado para ese tipo, el sistema puede ofrecerlo; en todos los casos conserva la opción OTROS.
---
## 11. Finalizar y sellar un Acta
Antes de finalizar un Acta revise especialmente:
- Inventario incluido;
- Hallazgos;
- observaciones;
- firmas y datos requeridos por el flujo.
Al finalizar, el sistema bloquea el contenido y genera controles de integridad. Esto significa que **el Acta deja de ser editable como borrador**.
No finalice un Acta para “probar” cómo queda. Primero revise todo su contenido.
Cuando el flujo de firmas y sellado termina, el Acta queda `SELLADA` y preparada para continuar con su documentación posterior.
---
## 12. Cerrar una Inspección
Para cerrar una Inspección en curso deben cumplirse las condiciones operativas, entre ellas:
- existir al menos un Acta;
- todas las Actas no canceladas deben estar selladas;
- si la Inspección contiene verificaciones, sus resultados deben estar registrados.
Si el sistema impide el cierre, revise el mensaje mostrado: normalmente identifica el Acta o la verificación pendiente.
---
## 13. Informes por Acta
El Informe se genera y administra **por Acta**.
Por eso, si una Inspección tuvo tres Actas, el seguimiento documental no debe tratarla como si existiera un único Informe global.
Desde **Informes** puede consultar el estado del INF y continuar el flujo habilitado para su rol.
---
## 14. GEDO / IF y plazo No urgente
Registrar el identificador y PDF oficial de GEDO/IF **oficializa el Informe**, pero por sí solo no significa que haya comenzado automáticamente el plazo administrativo de una Acta No urgente.
El vencimiento debe activarse a partir del evento administrativo de notificación que corresponda al procedimiento vigente.
Si ve un Informe oficializado sin fecha de vencimiento activa, no lo corrija cargando una fecha ficticia de GEDO como notificación.
---
## 15. Seguimiento de Hallazgos
Después del trabajo de campo, un Hallazgo mantiene su historia. El seguimiento puede incluir:
- respuesta de la Empresa;
- documentos y evidencias;
- fecha de control;
- planificación de una verificación;
- resultado de verificación;
- nueva fecha si continúa pendiente;
- cierre administrativo.
No reescriba el Hallazgo original para reflejar novedades posteriores. Use los eventos de seguimiento para conservar la trazabilidad.
---
## 16. Verificaciones
Cuando un Hallazgo requiere control posterior:
1. defina la fecha de verificación cuando corresponda;
2. planifique la visita de verificación;
3. ejecute la verificación;
4. registre el resultado y evidencia;
5. si no está resuelto, reprograme según el flujo;
6. si está resuelto y la visita quedó cerrada, continúe con el cierre administrativo del Hallazgo.
El historial de verificaciones no debe borrarse para reemplazarlo por el último resultado.
---
## 17. Historial y Consulta temporal
Use **Historial** para revisar cambios y eventos registrados.
Use **Consulta temporal** cuando la pregunta sea, por ejemplo:
- “¿qué Empresa operaba esta Área en esa fecha?”;
- “¿dónde estaba ubicado este elemento?”;
- “¿qué estado tenía el Inventario en ese momento?”.
La vista actual y la vista histórica cumplen funciones distintas.
---
## 18. Revisión de Inventario nacido en campo
Los registros creados por Inspectores en la APK pueden requerir revisión posterior en oficina.
Al revisar:
- confirme nombre/código;
- confirme la ubicación jerárquica;
- verifique clasificación técnica;
- revise GPS/fotografías;
- normalice información pendiente sin perder el origen de campo.
No cree un duplicado sólo porque el nombre no coincide exactamente; primero confirme si se trata del mismo objeto.
---
## 19. Mensajes frecuentes
### “La Operadora seleccionada no tiene una relación operativa vigente con el Área”
Revise la relación Empresa ⇄ Área y su vigencia. No cambie la jerarquía física para resolverlo.
### “La Instalación/Subinstalación debe tener una clasificación técnica”
Complete la familia técnica del elemento antes de registrar el Hallazgo.
### “El Inventario creado en campo debe tener GPS y al menos una foto”
Complete la captura de campo requerida y vuelva a intentar.
### “Todas las Actas deben estar SELLADAS antes de cerrar la Inspección”
Abra la Inspección y revise cuáles Actas todavía están en borrador, bloqueadas o pendientes de sellado.
### “La inspección no tiene un Acta borrador abierta”
Cree/abra un Acta borrador para esa Inspección antes de cargar nuevos Hallazgos.
---
## 20. Buenas prácticas
- Cargue la estructura de Inventario en orden jerárquico.
- No use Empresa como padre físico.
- No duplique Inventario ante la primera diferencia de nombre.
- Use OTROS cuando el Hallazgo no exista en catálogo.
- Revise un Acta antes de finalizarla: luego queda inmutable.
- Use seguimiento y verificaciones para agregar historia; no reescriba hechos anteriores.
- Use Consulta temporal para preguntas históricas.
- Respete los mensajes de integridad del sistema en lugar de forzar datos ficticios.
Ante una duda funcional que pueda modificar jerarquía, Actas, Hallazgos o vencimientos, consulte antes de cargar datos masivamente.
+128
View File
@@ -0,0 +1,128 @@
# DH Inspección V2 · corte de presentación F6.1
Este documento define el alcance verificable del corte F6.1 y el recorrido recomendado para una presentación funcional. No reemplaza al Manual de Usuario ni al Manual del Programador.
## Versiones del corte
- API: `0.29.0-1`.
- WEB: `0.23.0-1`.
- Android: `0.15.0` (`versionCode 22`).
- Modelo funcional: **F6.1 · contexto operativo ÁreaOperadora consolidado**.
La versión visible en el footer WEB debe coincidir con `web-v2/package.json`; este contrato queda protegido por tests.
## Matriz de aceptación funcional
### Inventario
- Jerarquía física vigente: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa permanece fuera del árbol físico.
- La Operadora se resuelve por relación temporal vigente con el Área.
- Instalación y Subinstalación admiten familia **Otro / no catalogado** cuando corresponda.
- Un alta de campo conserva inspección, actor, fecha, contexto, GPS y evidencia exigida por el flujo.
> El issue histórico F3.1 describía un árbol que comenzaba en Área. Esa definición fue supersedida por F5.1/F6, que incorporó Departamento como raíz territorial autorizada.
### Merge cronológico de Inventario
- Sólo se fusionan Instalaciones/Subinstalaciones compatibles.
- El registro duplicado no se elimina: queda fusionado/inactivo y conserva identidad histórica.
- Las referencias históricas emitidas no se reescriben.
- El dossier canónico agrega cronológicamente alias y eventos, mostrando el Inventario original cuando corresponde.
- WEB muestra el destino canónico.
- Android, al conciliar un alta nacida en campo, selecciona inmediatamente el canónico y confirma qué código se conserva.
### Hallazgos y catálogo
- `OTROS` permanece disponible en el resolver de Hallazgos.
- La fusión de catálogo mueve aplicabilidad futura al canónico sin reescribir Hallazgos ya emitidos.
- Las instancias históricas conservan el modelo append-only y la trazabilidad de auditoría.
### Inspecciones y Android
- Una inspección planificada puede iniciarse desde Android por un Inspector autorizado y asignado.
- El inicio registra `actualStartedAt`, actor y auditoría del servidor.
- El contexto operativo es Área + Operadora vigente.
- Una Inspección puede tener múltiples Actas, con un único borrador simultáneo.
- El cierre exige Actas selladas y verificaciones requeridas completas.
### Perfil del Inspector
- Nombre y apellido obligatorios.
- Datos administrativos disponibles: DNI, email, teléfono, cargo/función y legajo/matrícula.
- El email es obligatorio para rol Inspector.
### Entrega documental
- El Acta PDF se prepara para Empresa, Oficina e Inspector principal.
- El INF Word se prepara también para el Inspector principal cuando existe.
- El outbox conserva destinatario, estado, intentos, error y fecha de envío; los pendientes son reintentables.
- La entrega depende de SMTP y destinatarios configurados: si faltan, el registro queda en un estado de espera auditable en lugar de perderse.
### Relevamientos
- `Relevamientos` no existe como módulo/ruta/entrada de menú activa.
- La captura en campo vive dentro de Inspecciones + Inventario de campo.
## Seguridad de dependencias del corte
La candidata mantiene una barrera separada para dependencias productivas de API y WEB mediante `npm audit --omit=dev --audit-level=high`.
Para F6.1 se fijaron las resoluciones parcheadas que eliminan los advisories detectados durante el cierre:
- API: `multer 2.3.0` y `qs 6.16.0`.
- WEB: `maplibre-gl 6.4.1`.
Las dependencias de runtime con vulnerabilidades altas o críticas bloquean el corte aunque typecheck, tests y builds estén verdes.
## Barrera técnica obligatoria
Un SHA sólo es candidato de presentación cuando pasan:
1. API typecheck, tests y build.
2. WEB typecheck y build.
3. auditoría de dependencias productivas de API y WEB, sin advisories altos/críticos.
4. migraciones sobre PostGIS limpio.
5. arranque real de API.
6. preflight aislado equivalente al entorno Docker.
7. build de imágenes productivas.
8. Android lint.
9. Android unit tests reales.
10. Android `assembleDebug`.
11. Android `assembleRelease`.
12. APK debug con SHA-256 y metadata del SHA exacto.
## Recorrido sugerido de demo
1. **Login WEB** y Dashboard.
2. **Inventarios**: navegar Departamento → Área → Yacimiento → Instalación → Subinstalación y abrir un dossier.
3. Mostrar la **cronología/dossier**, documentos, fotos y Hallazgos.
4. Mostrar, si existe un caso preparado, una **fusión cronológica** y el aviso de registro canónico.
5. **Usuarios**: abrir un Inspector y mostrar perfil, email documental y roles.
6. **Inspecciones**: crear/abrir una planificación con Área + Operadora, Inspector y checklist.
7. **Android**: ingresar con Inspector, abrir o seleccionar la Inspección e iniciarla.
8. En Android, abrir **Inventario de campo**, seleccionar un registro o crear uno nuevo con GPS y foto. Para una familia no catalogada, usar **Otro / no catalogado**.
9. Crear un **Acta**, registrar un **Hallazgo** (incluyendo `OTROS` si se quiere demostrar el fallback), adjuntar evidencia y sellar el Acta.
10. Cerrar la Inspección cuando todas las Actas estén selladas.
11. En WEB, mostrar **Actas / Informes / Entrega documental** y el estado auditable de los envíos.
## Preparación del entorno de presentación
Antes de una demo con envío real de correo, verificar en Administración:
- email institucional de Oficina;
- email de la Empresa involucrada;
- email del Inspector principal;
- SMTP configurado y operativo.
Para una demo sin correo saliente, el resto del flujo puede demostrarse y la bandeja de entrega debe reflejar el estado de espera correspondiente; no se debe presentar un envío como exitoso si el transporte no está configurado.
## Evidencia del corte
El SHA presentado debe conservarse junto con:
- resultado verde de GitHub Actions;
- auditoría productiva verde;
- metadata de versiones;
- APK debug de la candidata y su SHA-256;
- si se distribuye APK productiva, firma histórica verificada y SHA-256 del artefacto firmado.
+8 -2
View File
@@ -247,16 +247,22 @@ 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 -F '"status":"ok"' "$BACKUP/health.json" >/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 no pasó healthcheck."
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
+6 -6
View File
@@ -1,14 +1,14 @@
{
"name": "dhv2-web",
"version": "0.20.0-2",
"version": "0.23.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
"version": "0.20.0-2",
"version": "0.23.0-1",
"dependencies": {
"maplibre-gl": "^6.0.0",
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^8.0.0"
@@ -1633,9 +1633,9 @@
}
},
"node_modules/maplibre-gl": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz",
"integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz",
"integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
+2 -2
View File
@@ -12,7 +12,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"maplibre-gl": "^6.0.0",
"maplibre-gl": "6.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^8.0.0"
@@ -24,4 +24,4 @@
"typescript": "^5.9.0",
"vite": "^7.0.0"
}
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.21.0-1';
export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado';
export const APP_VERSION = '0.23.0-1';
export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado';
@@ -7,7 +7,6 @@ import {
changeAssetContext,
listAssetContextHistory,
listAssetParentOptions,
listCompaniesForArea,
listOperationalAreas,
} from '../../lib/api';
import type {
@@ -29,7 +28,6 @@ function contextLabel(item: AssetContextHistoryItem) {
const parts = [
item.parent ? `Padre: ${item.parent.name}` : 'Sin padre',
item.operationalArea ? `Área: ${item.operationalArea.name}` : null,
item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null,
].filter(Boolean);
return parts.join(' · ');
}
@@ -55,9 +53,7 @@ export function AssetContextHistoryPanel({
const [parentSearch, setParentSearch] = useState('');
const [parents, setParents] = useState<AssetListItem[]>([]);
const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? '');
const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? '');
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow());
const [reason, setReason] = useState('');
@@ -94,29 +90,14 @@ export function AssetContextHistoryPanel({
setAreas(items);
if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) {
setOperationalAreaId('');
setOperatorCompanyId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, parentId]);
useEffect(() => {
if (!editing || !genericContext || !operationalAreaId) {
setCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId)
.then((items) => {
setCompanies(items);
if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId('');
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, operationalAreaId]);
const beginEdit = () => {
setParentId(asset.parent?.id ?? '');
setOperationalAreaId(asset.operationalArea?.id ?? '');
setOperatorCompanyId(asset.operatorCompany?.id ?? '');
setEffectiveAt(localDateTimeNow());
setReason('');
setError('');
@@ -133,13 +114,12 @@ export function AssetContextHistoryPanel({
const saved = await changeAssetContext(asset.id, {
parentId: parentId || null,
operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null,
operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null,
effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined,
reason,
});
onChanged(saved);
setEditing(false);
setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.');
setSuccess('Contexto físico actualizado. La asignación anterior quedó preservada en el historial.');
loadHistory();
} catch (requestError) {
setError(errorMessage(requestError));
@@ -150,30 +130,29 @@ export function AssetContextHistoryPanel({
return <article className="panel asset-history-panel">
<div className="panel-heading">
<div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía, Área y Operadora</h2></div>
{canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto</button>}
<div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía y Área</h2></div>
{canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto físico</button>}
</div>
<p className="section-copy">Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.</p>
<p className="section-copy">Los cambios físicos no reemplazan la historia. La Operadora se administra por separado en las relaciones temporales del Área.</p>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto vigente.</strong> {contextLabel(current)}</p></div>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto físico vigente.</strong> {contextLabel(current)}</p></div>}
{editing && <form className="form-section" onSubmit={save}>
<div><h3>Cambiar contexto vigente</h3><p className="section-copy">Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.</p></div>
<div><h3>Cambiar contexto físico vigente</h3><p className="section-copy">Indicá la nueva ubicación dentro del Inventario y el motivo. La relación física anterior se cierra automáticamente.</p></div>
<div className="form-grid">
<div className="field parent-picker"><span>Registro padre</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}><option value="">{type.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect></div>
<label className="field"><span>Vigente desde</span><input type="datetime-local" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} required /><small>Puede registrarse una vigencia pasada si corresponde a un cambio ya ocurrido.</small></label>
</div>
{genericContext && <div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Operadora</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required disabled={!operationalAreaId}><option value="">{operationalAreaId ? 'Seleccionar Operadora…' : 'Primero seleccioná un Área'}</option>{companies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => setOperationalAreaId(event.target.value)} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect><small>La Operadora vigente se resuelve desde la relación temporal del Área.</small></label>
</div>}
<label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: transferencia operativa, corrección documental, reubicación física…" /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio'}</button></div>
<label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: corrección documental, reubicación física, ajuste de jerarquía…" /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio físico'}</button></div>
</form>}
<div className="form-section"><div><h3>Historial de contexto</h3><p className="section-copy">Se muestra la secuencia completa de relaciones conocidas del elemento.</p></div>
<div className="form-section"><div><h3>Historial de contexto físico</h3><p className="section-copy">Se muestra la secuencia completa de ubicaciones conocidas del elemento.</p></div>
{loading ? <LoadingBlock label="Cargando contexto…" /> : history.length === 0 ? <div className="inline-empty">Todavía no hay contexto histórico registrado.</div> : <div className="asset-timeline">{history.map((item) => <div className="timeline-entry" key={item.id}><span className={`timeline-dot ${item.isCurrent ? 'current' : ''}`} /><span><strong>{item.isCurrent ? 'Vigente' : 'Histórico'} · v{item.assetVersionNumber}</strong><small>{formatDate(item.validFrom)} {item.validUntil ? formatDate(item.validUntil) : 'actualidad'}{item.creator ? ` · ${item.creator.username}` : ''}</small><span>{contextLabel(item)}</span><small>{item.changeReason}</small></span></div>)}</div>}
</div>
</article>;
+1 -2
View File
@@ -70,7 +70,7 @@ export async function searchInventoryMergeCandidates(
source: AssetDetail,
search = '',
): Promise<AssetListItem[]> {
if (!source.parent?.id || !source.operationalArea?.id || !source.operatorCompany?.id) return [];
if (!source.parent?.id || !source.operationalArea?.id) return [];
const page = await listAssets({
page: 1,
pageSize: 80,
@@ -78,7 +78,6 @@ export async function searchInventoryMergeCandidates(
typeId: source.type.id,
parentId: source.parent.id,
operationalAreaId: source.operationalArea.id,
operatorCompanyId: source.operatorCompany.id,
});
return page.data.filter((candidate) =>
candidate.id !== source.id && candidate.informationStatus !== 'INACTIVE',
+25 -11
View File
@@ -144,7 +144,6 @@ export function AssetEditorPage() {
const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
if (parent.operatorCompany) setOperatorCompanyId(parent.operatorCompany.id);
}
}).catch((requestError) => setError(errorMessage(requestError)));
}, [editing, contextParentId, types]);
@@ -158,21 +157,36 @@ export function AssetEditorPage() {
useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]);
if (!parentId) { setOperationalAreaId(''); setOperatorCompanyId(''); }
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) { setOperationalAreaId(''); setOperatorCompanyId(''); }
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
}).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId]);
}, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
useEffect(() => {
if (!canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') { setOperationalCompanies([]); return; }
listCompaniesForArea(operationalAreaId).then(setOperationalCompanies).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, operationalAreaId, selectedType?.operationalRole]);
if (editing || !canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') {
setOperationalCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId).then((loadedCompanies) => {
setOperationalCompanies(loadedCompanies);
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
? current
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
}).catch((requestError) => {
setOperationalCompanies([]);
setOperatorCompanyId('');
setError(errorMessage(requestError));
});
}, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const save = async (event: FormEvent) => {
@@ -182,7 +196,7 @@ export function AssetEditorPage() {
const attributes = normalizeAttributeValues(definitions, attributeValues);
let saved: AssetDetail;
if (editing && id) {
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, operatorCompanyId: canDirectContextEdit ? operatorCompanyId || null : undefined, description: description.trim() || null, attributes });
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, description: description.trim() || null, attributes });
else if (asset) saved = asset; else return;
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
@@ -229,9 +243,9 @@ export function AssetEditorPage() {
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. El contexto ÁreaOperadora se completa a partir de esa ubicación cuando es posible. Los registros ya consolidados cambian de contexto desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect 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>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área. Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect 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>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>Área y operadora</h2><p className="section-copy">Sólo necesitás revisar estos campos. La jerarquía física sigue siendo independiente.</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label><label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !canDirectContextEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label></div>{operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto confirmado.</strong> El registro pertenece a esta Área y tiene una única Operadora activa.</p></div>}</div>}
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2><p className="section-copy">{editing ? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.' : 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); if (!editing) setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect>{editing && <small>La Operadora se resuelve por la relación ÁreaEmpresa vigente.</small>}</label>{!editing && <label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>}</div>{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p></div>}{editing && asset?.operatorCompany && <div className="temporal-notice"><Icon name="layers" /><p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p></div>}</div>}
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
+1 -2
View File
@@ -86,7 +86,6 @@ export function FieldDiscoveriesPage() {
pageSize: 12,
search: matchSearch.trim(),
operationalAreaId: matchFor.asset.operationalAreaId ?? undefined,
operatorCompanyId: matchFor.asset.operatorCompanyId ?? undefined,
});
setCandidates(response.data.filter((candidate) => candidate.id !== matchFor.asset.id && candidate.informationStatus !== 'INACTIVE'));
} catch (requestError) { setError(errorMessage(requestError)); }
@@ -147,7 +146,7 @@ export function FieldDiscoveriesPage() {
</div>}
{matchFor && <div className="panel field-discovery-match-panel">
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Empresa y Área. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
{candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>}
</div>}
@@ -175,7 +175,6 @@ export function InspectionVisitEditorF4Page() {
pageSize: 100,
search: assetSearch.trim(),
operationalAreaId: form.operationalAreaId,
operatorCompanyId: form.operatorCompanyId,
}).then((response) => setAssets(response.data)).catch(() => undefined);
}, 220);
return () => window.clearTimeout(timer);