Compare commits

..
Author SHA1 Message Date
admin 9f582be25e chore(android): publish F6 APK artifact identity 2026-09-09 11:12:21 -03:00
admin 712cb167d3 fix(f6): separate technical and finding permissions 2026-09-09 11:09:29 -03:00
admin 3ea6ea772e feat(f6): show exact classification findings in asset dossier 2026-09-09 11:06:14 -03:00
admin 4ae339cc5c test(f6): advance Android installable cut contract 2026-09-09 11:04:38 -03:00
admin 560734c0b8 test(f6): advance health metadata contract 2026-09-09 11:04:21 -03:00
admin a3f642d13f chore(android): bump F6 debug version 2026-09-09 10:58:20 -03:00
admin 17fb0d2d15 chore(f6): bump WEB package version 2026-09-09 10:58:01 -03:00
admin 0e64007996 chore(f6): bump API package version 2026-09-09 10:57:47 -03:00
admin 709f43c1e9 chore(f6): publish API phase metadata 2026-09-09 10:57:30 -03:00
admin ab5ce42d47 test(f6): lock definitive inventory and mobile contracts 2026-09-09 10:48:50 -03:00
admin 3f71323555 test(f6): retire one-to-one family expectation 2026-09-09 10:47:53 -03:00
admin f3b72ecfc3 feat(android): browse field inventory as strict hierarchy 2026-09-09 10:43:53 -03:00
admin 5c5e5de26b fix(f6): gate mobile findings by area ancestry and temporal operator 2026-09-09 10:42:03 -03:00
admin 792b115449 fix(f6): resolve field findings by technical classification 2026-09-09 10:41:17 -03:00
admin 46cb5e0eb9 fix(f6): allow atomic field classification handoff 2026-09-09 10:40:57 -03:00
admin 8708c9c93f feat(f6): agregar datos técnicos por clasificación a la ficha 2026-09-09 09:34:55 -03:00
admin 2186810be3 feat(f6): agregar API de valores técnicos por objeto 2026-09-09 09:34:25 -03:00
admin 052384bfe0 feat(f6): registrar servicio de valores técnicos 2026-09-09 09:33:56 -03:00
admin a758c9528a feat(f6): exponer valores técnicos por objeto 2026-09-09 09:33:39 -03:00
admin 5023685043 feat(f6): persistir y validar valores técnicos del Inventario 2026-09-09 09:33:28 -03:00
admin d05ea8405a feat(f6): agregar DTO de valores técnicos por objeto 2026-09-09 09:33:02 -03:00
admin dc0e1d9762 feat(f6): filtrar subinstalaciones por compatibilidad múltiple 2026-09-09 09:27:14 -03:00
admin 3f662b86ea feat(f6): mostrar compatibilidades múltiples en catálogo 2026-09-09 09:26:32 -03:00
admin 636be088e8 feat(f6): consolidar configuración técnica por clasificación 2026-09-09 09:25:57 -03:00
admin 96738bfdcb feat(f6): consumir compatibilidades y campos técnicos de familias 2026-09-09 09:24:06 -03:00
admin 0b074923be feat(f6): aplicar compatibilidad múltiple al alta de Inventarios 2026-09-09 09:23:32 -03:00
admin e181dea4cb feat(f6): exponer campos técnicos por clasificación 2026-09-09 09:22:31 -03:00
admin 76163a5ebb feat(f6): administrar compatibilidades y campos técnicos por familia 2026-09-09 09:22:13 -03:00
admin 74516ede1e feat(f6): agregar DTOs de campos técnicos por clasificación 2026-09-09 09:20:59 -03:00
admin 9f09aca30f feat(f6): aceptar compatibilidades múltiples por clasificación 2026-09-09 09:18:33 -03:00
admin 169aab5cd3 feat(f6): solidificar compatibilidades y campos técnicos por clasificación 2026-09-09 09:18:16 -03:00
64 changed files with 304 additions and 2765 deletions
+20 -95
View File
@@ -1,36 +1,35 @@
name: Android CI / RC
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
name: Android APK
# F6: genera una APK debug verificable contra la API productiva F6.
on:
push:
branches:
- 'main'
- 'release/f6-1*'
- 'release/f5-android-test'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches:
- 'main'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- 'api-v3/src/auth/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
android:
name: Android · lint, tests, debug APK, release compile
build-debug-apk:
runs-on: ubuntu-latest
timeout-minutes: 35
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -52,92 +51,18 @@ jobs:
with:
gradle-version: '8.13'
- 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: 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: 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
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
- name: Unit tests
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
run: gradle --no-daemon :app:testDebugUnitTest
- 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
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
name: DH-Inspeccion-F6-0.14.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
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
retention-days: 30
+15 -51
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 and real API startup on clean PostGIS
- name: Rehearse migrations on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
@@ -116,9 +116,9 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate
# F5.1 performs the historical clean start. F6.1 then seeds only the
# presentation territory approved by DH: 7 Departamentos, 64 Áreas,
# 230 Yacimientos and 12 real Empresas.
# F5.1 intentionally ends with zero operational/domain instances. The
# technical family and finding masters remain, but territory preload,
# imports, applicability links and old audits are deliberately gone.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
@@ -128,7 +128,6 @@ jobs:
audits integer;
applicability integer;
territory_sources integer;
presentation_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
@@ -150,18 +149,18 @@ jobs:
END IF;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 313 THEN
RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
IF domain_assets <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets;
END IF;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 0 THEN
RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO territory_sources
@@ -171,20 +170,13 @@ jobs:
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) INTO presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF presentation_sources <> 1 THEN
RAISE EXCEPTION 'F6.1 must contain exactly one presentation territory source, got %', presentation_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
@@ -192,7 +184,7 @@ jobs:
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
@@ -213,46 +205,18 @@ jobs:
END $$;
SQL
# Both destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed chain
# is idempotent and has no pending migration on a second run.
# F5.1 is intentionally one-way: production rollback is the PRE database
# backup, not migration:revert. Prove instead that the completed chain is
# idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: F6.1 migration chain is not idempotent." >&2
echo "ERROR: F5.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
rm -f "$rerun_log"
# 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
- name: VPS-equivalent isolated API preflight
run: |
set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
-48
View File
@@ -1,48 +0,0 @@
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
+5 -26
View File
@@ -2,30 +2,9 @@
Repositorio del sistema DH Inspección V2.
## Componentes
## Estructura
- `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.
- `api-v3/`: API v3.
- `web-v2/`: aplicación web.
- `scripts/`: utilidades operativas y de despliegue.
- `docs/`: documentación técnica.
-48
View File
@@ -1,48 +0,0 @@
# DH Inspección Android · release F6.1
## Candidata vigente
- Fase funcional: **F6.1**.
- `versionName`: **0.15.1**.
- `versionCode`: **23**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema.
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.
+3 -3
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 23
versionName = "0.15.1"
versionCode = 21
versionName = "0.14.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -77,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
}
@@ -3,14 +3,11 @@
<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" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher"
android:roundIcon="@drawable/ic_mendoza_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion"
@@ -1,103 +0,0 @@
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,17 +547,13 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
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)
}
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -727,20 +727,16 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
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()))
}
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) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -389,17 +389,13 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
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)
}
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -92,7 +92,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> MobileHomeScreen(model)
else -> DhApp(model)
}
}
}
@@ -431,17 +431,13 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
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)
}
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) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -1,231 +0,0 @@
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,
)
}
}
}
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Launcher de presentación F6.1 inspirado en el escudo institucional publicado
por Gobierno de Mendoza en mendoza.gov.ar. Se mantiene como vector local para
evitar el ícono genérico del sistema y no depender de assets remotos.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- fondo institucional -->
<path
android:fillColor="#0B5FA5"
android:pathData="M54,4 A50,50 0,1 0,54 104 A50,50 0,1 0,54 4" />
<!-- escudo exterior -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,13 C72,13 84,22 87,38 L83,65 C80,81 68,93 54,99 C40,93 28,81 25,65 L21,38 C24,22 36,13 54,13 Z" />
<!-- campo superior celeste -->
<path
android:fillColor="#22A9E0"
android:pathData="M27,38 C30,27 39,21 54,21 C69,21 78,27 81,38 L78,59 L30,59 Z" />
<!-- campo inferior dorado -->
<path
android:fillColor="#C6A66A"
android:pathData="M30,63 L78,63 L76,68 C73,80 65,89 54,94 C43,89 35,80 32,68 Z" />
<!-- gorro frigio simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M47,31 C52,26 61,25 67,28 C64,31 63,35 64,39 C58,37 52,37 46,40 C44,36 44,33 47,31 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,39 L57,39 L56,48 L52,48 Z" />
<!-- racimo de uva simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M53,69 C56,69 58,71 58,74 C58,77 56,79 53,79 C50,79 48,77 48,74 C48,71 50,69 53,69 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M45,73 C48,73 50,75 50,78 C50,81 48,83 45,83 C42,83 40,81 40,78 C40,75 42,73 45,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M61,73 C64,73 66,75 66,78 C66,81 64,83 61,83 C58,83 56,81 56,78 C56,75 58,73 61,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,80 C56,80 58,82 58,85 C58,88 56,90 53,90 C50,90 48,88 48,85 C48,82 50,80 53,80 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M52,67 C54,63 58,61 62,61 C60,65 57,68 53,70 Z" />
</vector>
@@ -1,20 +0,0 @@
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(23, BuildConfig.VERSION_CODE)
assertEquals("0.15.1-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.29.0-1",
"version": "0.20.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.29.0-1",
"version": "0.20.0-2",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
+2 -6
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-1",
"version": "0.28.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -42,9 +42,5 @@
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
}
}
}
@@ -170,9 +170,20 @@ export class AssetOperationalRelationsService {
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// F5/F6: changing the Area operator is a temporal relation event only.
// Existing Inventory keeps its creation/historical operator snapshot and
// physical hierarchy unchanged. Runtime ownership must resolve this row.
// 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]);
}
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -181,7 +192,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
: undefined,
}, manager);
return created;
@@ -212,8 +223,8 @@ export class AssetOperationalRelationsService {
});
}
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory.
await manager.query(`
UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP,
@@ -137,41 +137,21 @@ 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 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"
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',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 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
LEFT JOIN assets company ON company.id=history.operator_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, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto {
@IsOptional()
@@ -10,13 +10,8 @@ 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()
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@@ -9,8 +9,6 @@ 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, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto {
@@ -24,12 +24,8 @@ 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()
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@@ -1,7 +1,6 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmpty,
IsEnum,
IsInt,
IsOptional,
@@ -67,12 +66,7 @@ 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()
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
@IsUUID('4')
operatorCompanyId?: string;
}
@@ -1,6 +1,5 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsObject,
IsOptional,
IsString,
@@ -48,12 +47,8 @@ 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()
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@@ -30,8 +30,6 @@ 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 })
@@ -1,35 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5/F6 permits physical Inventory to belong to an Área without persisting a
* Company snapshot. Company snapshots are optional creation/history metadata;
* current ownership is resolved from area_company_relations.
*
* The F4 paired CHECK survived the trigger migration and contradicted that
* model. Keep only the invariant that a Company snapshot cannot exist without
* an Área.
*/
export class F61AreaOwnedOperationalContextCheck1790094500000 implements MigrationInterface {
name='F61AreaOwnedOperationalContextCheck1790094500000';
public async up(queryRunner:QueryRunner):Promise<void>{
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
await queryRunner.query(`
ALTER TABLE assets
ADD CONSTRAINT chk_assets_operational_assignment_pair
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
`);
}
public async down(queryRunner:QueryRunner):Promise<void>{
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
await queryRunner.query(`
ALTER TABLE assets
ADD CONSTRAINT chk_assets_operational_assignment_pair
CHECK (
(operational_area_id IS NULL AND operator_company_id IS NULL)
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
)
`);
}
}
@@ -1,163 +0,0 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { F61_PRESENTATION_TERRITORY_SOURCE as SOURCE } from '../../reference-data/f6-1-presentation-territory-source';
type IdRow={id:string};
const NO_OPERATOR='Sin Empresa Operadora';
const DOC='DH-F6.1-PRESENTATION-TERRITORY-20260909';
const key=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase()
.replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
const code=(prefix:string,value:string,length=12)=>
`${prefix}-${createHash('sha1').update(value).digest('hex').slice(0,length).toUpperCase()}`;
const uniqueBy=<T>(rows:T[],identity:(row:T)=>string)=>{
const seen=new Set<string>(); return rows.filter((row)=>{const id=identity(row);if(seen.has(id))return false;seen.add(id);return true;});
};
export class F61PresentationTerritoryReset1790094600000 implements MigrationInterface {
name='F61PresentationTerritoryReset1790094600000';
public async up(q:QueryRunner):Promise<void>{
const rows=SOURCE.rows;
if(SOURCE.file!=='Tablas de yacimiento y areas(1).xlsx'||SOURCE.sheet!=='cr26e_tabla1'
||SOURCE.sha256!=='afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb'||rows.length!==230)
throw new Error('F6.1 presentation source contract mismatch');
const areas=uniqueBy(rows,(r)=>key(r.area));
const pairs=uniqueBy(rows,(r)=>`${key(r.area)}|${key(r.yacimiento)}`);
const departments=uniqueBy(rows,(r)=>key(r.departamento));
const companies=[...new Set(rows.map((r)=>r.empresaOperadora.trim())
.filter((name)=>name&&key(name)!==key(NO_OPERATOR)))].sort((a,b)=>a.localeCompare(b,'es'));
if(areas.length!==64||pairs.length!==230||departments.length!==7||companies.length!==12
||areas.filter((r)=>key(r.empresaOperadora)!==key(NO_OPERATOR)).length!==47)
throw new Error('F6.1 presentation source cardinality mismatch');
const concessions=new Set(rows.map((r)=>r.tipoConcesion.trim()));
if(concessions.size!==2||!concessions.has('Exploración')||!concessions.has('Explotación'))
throw new Error('F6.1 presentation concession contract mismatch');
for(const area of areas){
const same=rows.filter((r)=>key(r.area)===key(area.area));
for(const field of ['departamento','tipoConcesion','empresaOperadora'] as const)
if(new Set(same.map((r)=>key(r[field]))).size!==1) throw new Error(`Conflicting ${field}: ${area.area}`);
}
const companyType=await this.type(q,
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`);
const departmentType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true AND can_be_root=true LIMIT 1`);
const areaType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
const yacimientoType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
const [rules]=await q.query(`
SELECT COUNT(*)::integer AS total FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='area' AND lower(parent.code)='departamento')
OR (lower(child.code)='yacimiento' AND lower(parent.code)='area')`);
if(Number(rules?.total)!==2) throw new Error('F6.1 canonical hierarchy rules are incomplete');
// Destructive clean start requested for the presentation. Users, RBAC,
// technical families, Finding masters and system configuration are preserved.
// Recovery is the automatic PRE database backup made by deploy-github.sh.
await q.query('TRUNCATE TABLE assets CASCADE');
await q.query('TRUNCATE TABLE administrative_departments CASCADE');
await q.query('TRUNCATE TABLE source_documents CASCADE');
await q.query('TRUNCATE TABLE audit_events');
await q.query(`DO $$ DECLARE t text; BEGIN FOR t IN SELECT tablename FROM pg_tables
WHERE schemaname=current_schema() AND tablename LIKE 'asset_import_%'
LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE',t); END LOOP; END $$;`);
const [doc]=(await q.query(`INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id`,
[DOC,SOURCE.file,`sha256:${SOURCE.sha256}`,`F6.1 · ${SOURCE.sheet} · ${rows.length} filas`])) as IdRow[];
if(!doc?.id) throw new Error('Could not create F6.1 presentation source');
const companyIds=new Map<string,string>();
for(const name of companies){
const id=await this.asset(q,companyType,null,null,code('PRES-ORG',key(name)),name,
'Empresa/Operadora del corte de presentación.',`F6.1:PRESENTATION:COMPANY:${code('SRC',key(name),10)}`,'Empresa');
companyIds.set(key(name),id);
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name)
VALUES($1::uuid,$2::organization_kind,$3)`,[id,name.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',name]);
await this.link(q,id,doc.id,'Empresa/Operadora');
}
const departmentIds=new Map<string,string>();
for(const row of departments.sort((a,b)=>a.departamento.localeCompare(b.departamento,'es'))){
const id=await this.asset(q,departmentType,null,null,code('PRES-DEP',key(row.departamento)),row.departamento,
'Departamento administrativo raíz F6.1.',`F6.1:PRESENTATION:DEPARTMENT:${code('SRC',key(row.departamento),10)}`,'Departamento');
departmentIds.set(key(row.departamento),id); await this.link(q,id,doc.id,'Departamento');
}
const areaIds=new Map<string,string>();
for(const row of areas.sort((a,b)=>a.area.localeCompare(b.area,'es'))){
const parent=departmentIds.get(key(row.departamento)); if(!parent)throw new Error(`Missing Departamento ${row.departamento}`);
const id=await this.asset(q,areaType,parent,null,code('PRES-AREA',key(row.area)),row.area,
`Área del Departamento ${row.departamento}.`,`F6.1:PRESENTATION:AREA:${code('SRC',key(row.area),10)}`,'Área');
areaIds.set(key(row.area),id); await this.link(q,id,doc.id,`Área · ${row.departamento}`);
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
id,row.tipoConcesion==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
`${row.tipoConcesion} · ${row.area}`,doc.id,`F6.1 · ${SOURCE.file}`]);
if(key(row.empresaOperadora)!==key(NO_OPERATOR)){
const company=companyIds.get(key(row.empresaOperadora)); if(!company)throw new Error(`Missing Empresa ${row.empresaOperadora}`);
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,
[id,company,doc.id,`F6.1 · operadora vigente según ${SOURCE.file}`]);
}
}
for(const row of pairs){
const area=areaIds.get(key(row.area)); if(!area)throw new Error(`Missing Área ${row.area}`);
const id=await this.asset(q,yacimientoType,area,area,code('PRES-YAC',`${key(row.area)}|${key(row.yacimiento)}`),
row.yacimiento,`Yacimiento del Área ${row.area}.`,
`F6.1:PRESENTATION:YAC:${code('SRC',`${key(row.area)}|${key(row.yacimiento)}`,12)}`,`${SOURCE.sheet} · fila ${row.sourceRow}`);
await this.link(q,id,doc.id,`Yacimiento · ${row.area} · fila ${row.sourceRow}`);
}
const [c]=await q.query(`SELECT
(SELECT COUNT(*) FROM assets)::integer total_assets,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer yacimientos,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE')::integer legal_rights,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLORATION_PERMIT')::integer exploration_rights,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLOITATION_CONCESSION')::integer exploitation_rights,
(SELECT COUNT(*) FROM area_company_relations WHERE relation_role='OPERATOR' AND valid_until IS NULL)::integer operators,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area'
AND NOT EXISTS(SELECT 1 FROM area_company_relations r WHERE r.area_id=a.id AND r.relation_role='OPERATOR' AND r.valid_until IS NULL))::integer areas_without_operator,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN assets p ON p.id=a.parent_id
LEFT JOIN asset_types pt ON pt.id=p.asset_type_id WHERE lower(t.code)='area' AND (p.id IS NULL OR lower(pt.code)<>'departamento'))::integer invalid_area_parents,
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id LEFT JOIN assets a ON a.id=y.parent_id
LEFT JOIN asset_types at ON at.id=a.asset_type_id WHERE lower(t.code)='yacimiento'
AND (a.id IS NULL OR lower(at.code)<>'area' OR y.operational_area_id IS DISTINCT FROM a.id))::integer invalid_yacimiento_contexts,
(SELECT COUNT(*) FROM asset_source_documents WHERE document_id=$1::uuid)::integer source_links,
(SELECT COUNT(*) FROM source_documents WHERE document_number=$2)::integer source_documents`,[doc.id,DOC]);
const expected:Record<string,number>={total_assets:313,companies:12,departments:7,areas:64,yacimientos:230,
legal_rights:64,exploration_rights:18,exploitation_rights:46,operators:47,areas_without_operator:17,
invalid_area_parents:0,invalid_yacimiento_contexts:0,source_links:313,source_documents:1};
for(const [field,value] of Object.entries(expected))
if(Number(c?.[field]??-1)!==value)throw new Error(`F6.1 seed verification failed: ${field}=${c?.[field]} expected=${value}`);
}
public async down():Promise<void>{
throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.');
}
private async type(q:QueryRunner,sql:string):Promise<string>{
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing F6.1 master type: ${sql}`); return rows[0].id;
}
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,assetCode:string,name:string,
description:string,sourceReference:string,sourceNotes:string):Promise<string>{
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
code,name,description,information_status,operational_status,data_origin,source_name,source_reference,source_notes,is_inventory_instance)
VALUES($1::uuid,$2::uuid,$3::uuid,NULL,NULL,$4,$5,$6,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$7,$8,$9,false) RETURNING id`,
[typeId,parentId,areaId,assetCode,name,description,SOURCE.file,sourceReference,sourceNotes])) as IdRow[];
if(!row?.id)throw new Error(`Could not seed ${name}`); return row.id;
}
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
}
}
@@ -154,26 +154,41 @@ 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 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}
AND (
search_asset.code ILIKE ${search}
OR search_asset.name ILIKE ${search}
OR search_company.name ILIKE ${search}
OR 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(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
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.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
@@ -230,8 +245,24 @@ 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(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
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.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
@@ -517,16 +548,21 @@ export class InspectionActsService {
LEFT JOIN inspection_reports report ON report.act_id = act.id
LEFT JOIN LATERAL (
SELECT
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
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
) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
@@ -777,8 +813,6 @@ 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,14 +1,10 @@
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,
private readonly contextualCatalog: FindingCatalogService,
) {}
constructor(private readonly dataSource: DataSource) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
@@ -40,28 +36,17 @@ export class F3FindingCatalogResolverService {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a este Inventario. Quedará registrado para revisión en oficina.',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. 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: 'El elemento todavía no tiene una clasificación técnica activa.',
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.',
categories: [],
items: [],
other,
@@ -118,7 +103,6 @@ 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,16 +153,13 @@ 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 = visit.operator_company_id
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
`;
const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
@@ -408,24 +408,30 @@ export class InspectionReportsService {
}
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
// 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`;
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
)`;
}
private contextSelect(): string {
return `
SELECT
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
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
`;
}
@@ -66,6 +66,9 @@ 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;
@@ -73,12 +76,6 @@ interface LockedFindingRow {
status: string;
}
interface PlannedOperatorRow {
companyId: string;
companyCode: string;
companyName: string;
}
@Injectable()
export class InspectionVerificationsService {
constructor(
@@ -145,19 +142,8 @@ 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}
`;
@@ -509,11 +495,15 @@ 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
@@ -529,51 +519,25 @@ export class InspectionVerificationsService {
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
});
}
const missingArea = rows.find((row) => !row.areaId);
if (missingArea) {
const missingContext = rows.find((row) => !row.companyId || !row.areaId);
if (missingContext) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_REQUIRED',
message: `${missingArea.code} necesita un Área física antes de planificar la visita`,
message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`,
});
}
const companyId = rows[0]?.companyId;
const areaId = rows[0]?.areaId;
if (!areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' });
if (!companyId || !areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' });
}
if (rows.some((row) => row.areaId !== areaId)) {
if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_MIXED',
message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación',
message: 'Seleccioná hallazgos de una misma empresa y á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
@@ -588,7 +552,7 @@ export class InspectionVerificationsService {
});
}
const companyName = operator.companyName;
const companyName = rows[0]?.companyName ?? 'Empresa';
const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const findingCodes = rows.map((row) => row.code).join(', ');
@@ -673,7 +637,6 @@ export class InspectionVerificationsService {
areaId,
findingIds,
assetIds,
operatorSource: 'area_company_relations',
},
}, manager);
@@ -684,7 +647,7 @@ export class InspectionVerificationsService {
status: visit.status,
plannedStartAt: visit.plannedStartAt,
},
company: { id: companyId, code: operator.companyCode, name: companyName },
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
findingCount: findingIds.length,
assetCount: assetIds.length,
@@ -1,9 +0,0 @@
import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto {
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
operatorCompanyId!: string;
}
@@ -265,14 +265,13 @@ export class FieldFindingsService {
message: 'Registro de Inventario no encontrado',
});
}
const assetTypeCode = String(row.assetTypeCode).toLowerCase();
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
throw new BadRequestException({
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
});
}
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
if (!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,13 +295,11 @@ 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, Operadora, Inspector responsable y fecha de inicio antes de planificar',
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
});
}
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
@@ -322,7 +320,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',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
});
}
const [inspector] = await manager.query(`
@@ -13,13 +13,11 @@ 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';
@@ -59,43 +57,6 @@ 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) {
@@ -1,30 +0,0 @@
import { gunzipSync } from 'node:zlib';
export interface F61PresentationTerritoryRow {
sourceRow: number;
yacimiento: string;
area: string;
departamento: string;
tipoConcesion: string;
empresaOperadora: string;
}
/**
* F6.1 · fuente única del corte de presentación.
* Archivo: Tablas de yacimiento y areas(1).xlsx
* Hoja: cr26e_tabla1
* SHA-256: afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb
*
* Las 230 filas se guardan comprimidas para versionar exactamente la fuente
* aprobada sin depender del binario Excel durante CI o deploy.
*/
const SNAPSHOT_GZIP_BASE64 = 'H4sIADTzoWoC/+2d23IiuRmAX0Xli1RSNeOiwZzmrmFY21OAWfBStZvNhdzIWI6QvN0tZ00qj5CLPMI+wtbepXLnF4vU+NA6AbPLINXgOyNh+PpH0n/Qr19//edRxniaoDH7x9GH6rujB5jgBUY0Z0cfjrrw8Vc4g2DGU3j07gimCIrWKV4mMLlBKcxE4wzdwTSHi6d/mUAKujAlTPbl+I51GU1QhhkVnb2f7wjLxTc8/kZFN1rcpSiDF3fio2YslZ89Qnn6+BtBLAMTPhOfmuIEUvGqR1E6fwCT4/j46F/vFOqaRj3iNBfMCBCYgQ5MZwXnE3wfgnvxPkihyT7G9+Jp7zHcG/qJhi5oU0iTMnC5SePt89vHX6h80i5/YHuDrmvQ380FFBIDImTohgYdzzkEHfE9s9LIVhs18AEkMJ0//g/9LubxRffMCtbUwYj4cAIpTjASUiJgBB84LTG6+neK+91lD/z52+/Oh5fxMAa9P41APD7tDS/PxavJ8fi4f/wOvHYPe+PT78H5cNqbXA7EuybFk74Dp7H46/JiHJ/2QDz8CAbnHyeX4148AJPeeHre7T2/sXsxGMWP/3n8dwwuJ52i8S+6oFr64oTSlIE+5nc8gW/y+dC2ymeI5il7k86HqOIWj1hF0jcRfYgifSkSD4uznzguCafU5F17RlUXsPaLBgVdM4ysNMVLSKRKjK/gLQsVXDdWegR8y9FCfGaoxLql0meFQShQCc5ZFip2wzBoUZYzEJNrmAYra92OucRigZUGONEM2rCwW+uxCwUwQDMcrNzbGx5gyIRxHih8tWIs4FzI20CWjQVBuiJ4eYfF+RzDa4iI6xHSdY8wwRT0Vs3gtV1njqzMmsJ5anqlZnTnuGcwz4WQ/16W8MSUcdWyCI5Yoi+AQcm4Zl8BB1h4lYrDFhb2iXs4g5cZxKjOr71hBfHZ/Gtn5yWkc7Z2KtZdoQhwIWQftG9fbTjZlWkZILmuNfucEhm+ev9sFQZN3/o83RPgExhOswxs8ry0xJRads88RZRhYo9qGg6rcA2KbyPimbrwChJSXsFFt1z+zP79hLFqkQt3wAimMBzQqjUQ0GEEwRkq29e+QWuG4l6IOTVCRFhpYlQri7JvVl3zjTFNxM8fL2CKBUk4oHVjmJIFTxTA54bPJfrjBkStYd2AcQhSdklZlvq+9hBVrWmdvE+jaYhn6op4iBJqmVGa7g0W1t/b4Gmbohnh5AbSspl1kKI5qbhFo9rQhyke3bjpQ9BHyQ1fwkOXjG5NjaB4WpYevFyMCIZ0hZbC2z/0VfjEYSoWPuPBC6duF44wcHpZAgmjBz+xDBMZXQtPn5ZHzmuL/wSfpqk5xC8p1gGsBBlCQm7Zg6+jFKM8VDHbkx8+MjFDwCcOlWlj6dq/q1c3tj5ojhMZKSFAOvogzlN4Lz5iZqB/I1QJFwLZ+XT/fvSNPeErskq3zOEZsLoeUA9h+8KsbcBULW1PkCeOvRdIM6FjAhiNuo6cMiIWCDCBMu1zIDRhAIwN6y89YEKAqR4WL/ftGnDUHdsBm/ZfecxuzR0fL4CtdRIMAdBQOTdQOMw3HFFtHuvtexmBjYqJJzSe+HdwJjdp1EiZrU/DvOR3nM5hzr7M/mPD0DE3+Ibj2Sq4B8Xs1nYRHN1fu/3bqG4jJ9AVvTK98U1gjdrnCGzyJrDGyVYC05XYoUqrvpW0NI1woLIy8zRuGZD5SH12peojtwy/eiE1dzSgDkVerW3lVYTQ3oRWCM22FSYWdEb1vIIDFlLTsin2A1RDjYcsnsgZq+4ymWP/JqZCTNUNYnpbyl9EZY+PSQNhgAjBy3U2QpF6WJzngvvysZu6pfwRZdeYFGlkoAMfWFi09Q20G5Z+H8gNcwUesDQwuTYtuhTiDNLAOHVD6ZTDmZL5EgBj29wu6zKiboL4x2xVLFkPcmQGhhnZg7xnKKWQztAyLNqqY+OBrY/8+UB1nE8ZQcIWYc36lhHNGYLp+TmIS5SvLTvdB3UiGSGT4ftpGad4tVOU7Y5KtYxdG0avCUc0wbqVZnTs9MfcGC1v6Tqnw8UCNEbkni9eKaW2hHQJ6c73vdacW2i1DDND6EOU4/I+sR+ytuUk3IDNWY4yz2jtigVtta/pn03XIgPpkVzeIOydzHCtIP2Jizd3fQ+1ds1w+hg4lYcXkW8y44yicLGE7d9lUlehxLvk6na+lRNIecK8jzqbU/JC4ZWs6STTd8z98LXcfIp69UNni4nCTD1144UsqhjqAXPQ5UQYxt7RIssptefia73sTuiKzDujw7MQzuW3Ql1A73w1ZzRe22V96rCeEP8KSzZZKs5MWEaRUnJGbdztsXlX8k9UqZtl28QiphyyOmVX0tEWHwXiB0h27vKNCM/uUJ4y17CyJ6GdwQVMjP1777D2k31nHFJOAsJsrcFUlZh3VIs+67I0YVcBjdGo4oQMbYQa5dE+QfldghdmhcYbYTRLoRZF905d3Y46qKEb1bZaDOyPsndYXUt9//JCDOWzCUAUdBHoIIqucSL8wE/dUXDPUDenoaX6XyCwDbfAe93hSt5FNl/YMm+aj3EMBBs3Z2fxSB0U5GO0fscCEwh62z2QhG8QE8Eylzs7AYu/am5FLe4gmKK0HGoKhNVyjLqjJIq8Nuwlhz8yi6MRGYzQI3V7x6q5HNZM/LJzrZ75ftFcx4P4469s6ZFL12DS8/tGlpvCWTmouXeuhjnmi21CddS/NO3JfzUKj8kA9RmjMxZqqfzIqDZmMVBCQ247LlT4qFyoEBh1zeKTdViOCCmXLgwNOnJWJ9YdydDIqyb5mGUw4KlolCaTChN04K0c26vif+Gyn5hpUBQmzChAERp33VQk0xe+QJkbTmbw41H841G45E03eeFclhPnQmNvudnV4FRo4G03eNhruFE/q0SuOL6hcZseWSbMwHSB5+FemhQZRaf6mBZZzGXP9hnd0rVTy3+71LLIKAhlgBlDPBTyk43k6hAPhbtu8e1lQlV5j05p249HaFQwKnbMu+LHl6n4il4xOvZfpyY6sW/MxUQ4XkINUg13lek6sB5o/YPObO94cNw7tsu05cxCeGYJALJtD50QmYaArlIly90jZ71iE6bwSj7ie5xpRZKLPqT1+Zju9ciSQnnJ0plM4DFKmtg79QLfMi6z/IJzy6hbNJDv12K3SttOi3L8vjt1arZ00Oc6vMS2MJTe8dT7+AvdX6zLqGgkr/kTjmqcIvGwpTk3ghlT74103SbxZXL1I6O0UakoWAEkA/i5mpHmH9pIM+FpikFXCXH5p7TkRsYELrTMQ/+cFudtNVCVgoH+OQ1lZhAFRNuobKTVzW//zM7z1U8jNyRWswzwA6e60hWNj/+lLm37hY/MREYhoJV3kskDhkwaX+UBq3e972V5ypNcLGrgnOYoXaAZhrtWyOuSRBuWjTeCSlr25bUHJ8GoguM+aWjp2dMYddTkE99G9GtSlaOHX2YubSnYpvUKw8dfy8cgwqFtbT4XGw5s2wWrRUmDITYKktjJgsOOtqv4HQ6w47zAiC2Z9GMSual4kSR4JoOixHgC9/t2qi22fJja5ofZ8AQp/iL8m1W2UWBDK/SpQhe1S/cWxms6VF48Y1fIN1tjTXVX32xNW0JMJpCIoiWo9HNhYe2ecbFgWEMGe1kNWs6rHEBxuQ7DP3EUJroZcdTZIA2S3KiwIWM2soAbozNcjpOO0TWfYwbKS9Fug2Q3DFH8Mzgl7EoYimO0wswc1wlH2zsZoaEbp6lXfOEC65qt/OWBIp+4kHVfOTTwuvVqWx1aABcdu/ZCndrEKNkxLmRG+UN5YVMbA7h0XNeBT5Vh4qV+iMzs8ODdGyU9nqikmhiie8WAtHX5QG7b9McMgTjF5TV4CglBBa1SoWE/EYn22jtAZAzvHquXvPrFdV7xekFgrtTF9Mtpvw3k+UZlnAcDaj/mttrxDwbS7oPZ9oL9ctY3cKoLq19WeyhSeBOnMJzRaU/8GImvxuGMTnO7bM7pSv+MMLsNhrNty6mISXlPzydgtVJxAWrRUL+UkZMymNldNcqNOO/z8otZs20tiDkuFGQWCuOJhbEL72E4hHUnYVATR9c4q22v4l7JHFKcEchCQW06UAkYoBkOBrNlPXg7Et+m3CfvF7K9RR06r4RGAZBXhoCWdKP2hzyFW8D0WSgWcNUo9VFAGr+20rjbFLzTh1NwIRMYUHqPE/RUOMoErTmORfaJ8HiXalLeFC8TmNyIL8nsvMKvW/nI+wneVKMTS4FTeaBzqXjAwWHX7ZZysf2C74OWeMNySuUTp7kSyQmO2jhijVZRq76sKhEyeMuyJE9Qskoxk+YNS1NIkJFUHd6T6OrvB6mjSxv2L68/Nyb8x0OUVaNyR59TIgf1++dIlWZAdkSzPK1iEW6f3z7+srpThD/sLxm8Wo0cG/TyY/X7JCxd+1GMRpWP7hCcn58bV9iprfuPWleNuh/PBY01Ur3ZB6quBTuMIC0eWG7ygWhzzUZiCVpRZOppELXDB27DfaGscdmi0eMDuOkw41b2L01Wnx+qcqja1NwUo9twT75WjYIipbTkbsqXthOwAaoNo8KIOjhCHTFGjZEpFGIFA47SPKRB87f/A/kcwxXWpwAA';
export const F61_PRESENTATION_TERRITORY_SOURCE = {
file: 'Tablas de yacimiento y areas(1).xlsx',
sheet: 'cr26e_tabla1',
sha256: 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb',
rows: JSON.parse(
gunzipSync(Buffer.from(SNAPSHOT_GZIP_BASE64, 'base64')).toString('utf8'),
) as F61PresentationTerritoryRow[],
} as const;
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-1';
export const API_PHASE = 'F6.1';
export const API_VERSION = '0.28.0-1';
export const API_PHASE = 'F6';
@@ -33,15 +33,13 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
});
test('D5.3/F6.1 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
test('D5.3/F5 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 never moves, rewrites or blocks existing Inventory/);
assert.match(service, /operatorSnapshotPreserved:true/);
assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
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.1 release', () => {
assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1');
test('health metadata reports the current F6 release', () => {
assert.equal(API_PHASE, 'F6');
assert.equal(API_VERSION, '0.28.0-1');
});
+5 -5
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
test('F6 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 = 23/);
assert.match(gradle, /versionName = "0\.15\.1"/);
assert.match(gradle, /versionCode = 21/);
assert.match(gradle, /versionName = "0\.14\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6 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',
@@ -25,4 +25,4 @@ test('F5/F6.1 field inventory exposes Other families as reviewable choices to An
assert.match(service, /F5:SYSTEM:OTHER:%/);
assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/);
});
});
@@ -1,67 +0,0 @@
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\)/);
});
@@ -1,102 +0,0 @@
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:/);
});
@@ -1,13 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
test('F6.1 DB check allows Área-only physical context and rejects Company without Área', () => {
const migration=readFileSync(
resolve(process.cwd(),'src/database/migrations/1790094500000-f6-1-area-owned-operational-context-check.ts'),
'utf8',
);
assert.match(migration,/CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.doesNotMatch(migration,/operational_area_id IS NULL OR operator_company_id IS NULL/);
});
@@ -1,66 +0,0 @@
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'/);
});
@@ -1,86 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { F61_PRESENTATION_TERRITORY_SOURCE } from '../../src/reference-data/f6-1-presentation-territory-source';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
test('F6.1 presentation territory snapshot matches the approved spreadsheet cardinalities', () => {
const source = F61_PRESENTATION_TERRITORY_SOURCE;
const rows = source.rows;
assert.equal(source.file, 'Tablas de yacimiento y areas(1).xlsx');
assert.equal(source.sheet, 'cr26e_tabla1');
assert.equal(source.sha256, 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb');
assert.equal(rows.length, 230);
const areas = new Map<string, typeof rows[number][]>();
for (const row of rows) {
const id = key(row.area);
const current = areas.get(id) ?? [];
current.push(row);
areas.set(id, current);
}
const pairs = new Set(rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
const departments = new Set(rows.map((row) => key(row.departamento)));
const companies = new Set(
rows
.map((row) => row.empresaOperadora)
.filter((name) => key(name) !== key('Sin Empresa Operadora'))
.map(key),
);
const areasWithoutOperator = [...areas.values()]
.filter((sameArea) => key(sameArea[0]!.empresaOperadora) === key('Sin Empresa Operadora'));
const concessions = new Set(rows.map((row) => row.tipoConcesion));
assert.equal(areas.size, 64);
assert.equal(pairs.size, 230);
assert.equal(departments.size, 7);
assert.equal(companies.size, 12);
assert.equal(areasWithoutOperator.length, 17);
assert.deepEqual([...concessions].sort((a, b) => a.localeCompare(b, 'es')), ['Exploración', 'Explotación']);
for (const [area, sameArea] of areas) {
assert.equal(new Set(sameArea.map((row) => key(row.departamento))).size, 1, `${area}: Departamento conflictivo`);
assert.equal(new Set(sameArea.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión conflictiva`);
assert.equal(new Set(sameArea.map((row) => key(row.empresaOperadora))).size, 1, `${area}: Operadora conflictiva`);
}
});
test('F6.1 presentation reset preserves system masters and enforces the physical hierarchy', () => {
const migration = readFileSync(
resolve(process.cwd(), 'src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts'),
'utf8',
);
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
assert.match(migration, /TRUNCATE TABLE audit_events/);
assert.doesNotMatch(migration, /TRUNCATE TABLE users/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE roles/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE permissions/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/i);
assert.match(migration, /area_company_relations/);
assert.match(migration, /area_legal_rights/);
assert.match(migration, /key\(row\.empresaOperadora\)!==key\(NO_OPERATOR\)/);
for (const contract of [
/total_assets:313/,
/companies:12/,
/departments:7/,
/areas:64/,
/yacimientos:230/,
/legal_rights:64/,
/operators:47/,
/areas_without_operator:17/,
]) assert.match(migration, contract);
});
@@ -1,71 +0,0 @@
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
@@ -1,91 +0,0 @@
# 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.
+1 -131
View File
@@ -1,131 +1 @@
# 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.
# F6 · Modelo definitivo de Inventarios
+5 -3
View File
@@ -1,5 +1,7 @@
# F6 · Modelo de Inventarios
# F6 · Modelo definitivo de Inventarios
Este archivo se conserva por compatibilidad con referencias históricas.
La jerarquía física canónica es fija:
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
+5 -3
View File
@@ -1,5 +1,7 @@
# F6 · Modelo de Inventarios
# F6 · Modelo definitivo de Inventarios
Este archivo se conserva por compatibilidad con referencias históricas.
La jerarquía física canónica es fija:
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
-370
View File
@@ -1,370 +0,0 @@
# 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
@@ -1,353 +0,0 @@
# 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
@@ -1,128 +0,0 @@
# 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.
+2 -8
View File
@@ -247,22 +247,16 @@ echo "========== HEALTH =========="
HEALTH_OK=0
for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
if grep -F '"status":"ok"' "$BACKUP/health.json" >/dev/null; then
HEALTH_OK=1
break
fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi
sleep 2
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
echo "ERROR: API no pasó healthcheck."
docker compose logs --tail=180 api
false
fi
+6 -6
View File
@@ -1,14 +1,14 @@
{
"name": "dhv2-web",
"version": "0.23.0-1",
"version": "0.20.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-web",
"version": "0.23.0-1",
"version": "0.20.0-2",
"dependencies": {
"maplibre-gl": "6.4.1",
"maplibre-gl": "^6.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^8.0.0"
@@ -1633,9 +1633,9 @@
}
},
"node_modules/maplibre-gl": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz",
"integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz",
"integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==",
"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.4.1",
"maplibre-gl": "^6.0.0",
"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.23.0-1';
export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado';
export const APP_VERSION = '0.21.0-1';
export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado';
@@ -7,6 +7,7 @@ import {
changeAssetContext,
listAssetContextHistory,
listAssetParentOptions,
listCompaniesForArea,
listOperationalAreas,
} from '../../lib/api';
import type {
@@ -28,6 +29,7 @@ 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(' · ');
}
@@ -53,7 +55,9 @@ 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('');
@@ -90,14 +94,29 @@ 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('');
@@ -114,12 +133,13 @@ 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 físico actualizado. La asignación anterior quedó preservada en el historial.');
setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.');
loadHistory();
} catch (requestError) {
setError(errorMessage(requestError));
@@ -130,29 +150,30 @@ export function AssetContextHistoryPanel({
return <article className="panel asset-history-panel">
<div className="panel-heading">
<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><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>
<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>
<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>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto físico vigente.</strong> {contextLabel(current)}</p></div>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto vigente.</strong> {contextLabel(current)}</p></div>}
{editing && <form className="form-section" onSubmit={save}>
<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><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 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)} 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>
<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>
</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>
<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>
</form>}
<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>
<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>
{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>;
+2 -1
View File
@@ -70,7 +70,7 @@ export async function searchInventoryMergeCandidates(
source: AssetDetail,
search = '',
): Promise<AssetListItem[]> {
if (!source.parent?.id || !source.operationalArea?.id) return [];
if (!source.parent?.id || !source.operationalArea?.id || !source.operatorCompany?.id) return [];
const page = await listAssets({
page: 1,
pageSize: 80,
@@ -78,6 +78,7 @@ 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',
+11 -25
View File
@@ -144,6 +144,7 @@ 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]);
@@ -157,36 +158,21 @@ export function AssetEditorPage() {
useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]);
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
if (!parentId) { setOperationalAreaId(''); setOperatorCompanyId(''); }
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) { setOperationalAreaId(''); setOperatorCompanyId(''); }
}).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
}, [canReadRelations, selectedType?.operationalRole, parentId]);
useEffect(() => {
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]);
if (!canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') { setOperationalCompanies([]); return; }
listCompaniesForArea(operationalAreaId).then(setOperationalCompanies).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const save = async (event: FormEvent) => {
@@ -196,7 +182,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, 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, operatorCompanyId: canDirectContextEdit ? operatorCompanyId || 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);
@@ -243,9 +229,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. 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>
<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>
{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>}
{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>}
<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>}
+2 -1
View File
@@ -86,6 +86,7 @@ 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)); }
@@ -146,7 +147,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 Á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>
<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>
<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,6 +175,7 @@ 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);