Compare commits

...
Author SHA1 Message Date
admin 5c69892815 test: keep isolated F6.2 contract independent from docs 2026-09-10 20:26:31 -03:00
admin c1eeb5f078 test: protect CSRF fix, simplified shell and PDF projection 2026-09-10 20:20:47 -03:00
admin 324a73d35c docs: define provisional PDF contract for Actas and INF 2026-09-10 20:20:33 -03:00
admin 97f34a471f docs: align document model with multi-Act inspections and provisional PDFs 2026-09-10 20:20:16 -03:00
admin a5d954b4c7 feat: show provisional Informe PDF projection 2026-09-10 20:19:59 -03:00
admin a80ef4477c feat: show provisional Acta PDF projection 2026-09-10 20:19:28 -03:00
admin b21810f166 feat: expose provisional PDF data contract for Actas and Informes 2026-09-10 20:18:44 -03:00
admin 64486f40f6 ux: stop persisting hidden operational filters 2026-09-10 20:16:59 -03:00
admin a407bf46c5 ux: remove duplicated global operational context bar 2026-09-10 20:16:40 -03:00
admin bfb7eed608 fix: use authenticated CSRF-aware request for inspection planning 2026-09-10 20:16:21 -03:00
admin c74fd80620 Merge pull request #39 from enlineawork/feat/structured-inventory-navigation
feat: navegación estructurada de Inventarios
2026-09-10 20:00:28 -03:00
admin 9bd1adbdb5 fix: keep inventory item type presentation generic 2026-09-10 19:54:47 -03:00
admin cc8d650aa4 feat: present inventory levels as dedicated sections 2026-09-10 19:54:19 -03:00
admin 9d0d1f33ce feat: add structured inventory navigation 2026-09-10 19:53:52 -03:00
admin e5f1615665 F6.1 hotfix · Operadora inmediata y cancelación con motivo (#38)
Corrige la UX de planificación para precargar fecha/hora, resolver y autoseleccionar la Operadora vigente del Área, y expone cancelación auditada con motivo obligatorio en DRAFT/PLANNED/IN_PROGRESS, manteniendo el bloqueo por Acta sellada.
2026-09-10 18:26:23 -03:00
admin 7be5d89204 F6.1 hotfix · jerarquía de Inspecciones y scope Yacimiento (#37)
Corrige creación de Inspección, separa Departamento→Área→Yacimiento, elimina el UPDATE oculto de checklist en lectura, integra Preparación para campo como etapa de Inspección, limita operaciones Android al Yacimiento, preserva scope en edición, auto-incluye Inventarios accionables, valida Operadora a plannedStartAt y recompila Android 0.15.2/vc24 con el ícono exacto de presentación.
2026-09-10 16:06:48 -03:00
admin ce2a85a26a F6.1 · reset y precarga territorial de presentación (#36)
Precarga únicamente la fuente aprobada Tablas de yacimiento y areas(1).xlsx con jerarquía Departamento → Área → Yacimiento, concesiones y Operadoras. Corrige además el CHECK legacy F4 para permitir contexto físico de Área sin snapshot obligatorio de Empresa. El reset preserva usuarios/RBAC y catálogos/configuración técnica, y valida cardinalidades exactas antes de completar.
2026-09-09 21:16:29 -03:00
admin 20ffa9e4b3 F6.1 · APK 0.15.1 con launcher Mendoza Gobierno (#35)
Prepara la APK de presentación con launcher propio basado en el escudo institucional de Gobierno de Mendoza, versionName 0.15.1 / versionCode 23, contratos de metadata actualizados y sin cambios funcionales de backend o WEB.
2026-09-09 20:22:53 -03:00
admin 509bc0cde1 F6.1 · cierre de presentación y barrera de release (#34)
Cierra F6.1 como candidata verificable de presentación: metadata WEB alineada, contratos transversales de aceptación, Android CI/RC endurecida con lint/tests/builds/checksum, correcciones de carreras de permisos GPS y cámara opcional, auditoría de dependencias productivas y parches de runtime, más documentación de release y recorrido de demo.
2026-09-09 18:35:28 -03:00
61 changed files with 2846 additions and 416 deletions
+95 -24
View File
@@ -1,39 +1,36 @@
name: Android APK name: Android CI / RC
# F6.1: genera una APK debug verificable contra la API productiva F6.1. # F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
on: on:
push: push:
branches: branches:
- 'main' - 'main'
- 'release/f5-android-test' - 'release/f6-1*'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
- 'feature/f6-1*'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
pull_request: pull_request:
branches:
- 'main'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/auth/**' - 'api-v3/src/**'
- 'api-v3/src/asset-master/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- 'api-v3/src/inspection-findings/**'
- 'api-v3/src/inspection-verifications/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build-debug-apk: android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 35
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -55,18 +52,92 @@ jobs:
with: with:
gradle-version: '8.13' gradle-version: '8.13'
- name: Assemble debug - name: Validate mobile security and identity contract
working-directory: android-app run: |
run: gradle --no-daemon :app:assembleDebug set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Unit tests - name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK - name: Require real unit-test results
run: |
set -Eeuo pipefail
test_dir="android-app/app/build/test-results/testDebugUnitTest"
test -d "$test_dir"
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
test "$total" -gt 0
echo "Android unit tests discovered: $total"
- name: Assemble debug APK
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
- name: Package RC artifact and checksum
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.1"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: DH-Inspeccion-F6.1-0.15.0-debug name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk path: android-app/dist/*
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14
+21 -13
View File
@@ -116,9 +116,9 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate docker compose --env-file .env.example --profile tools run --rm migrate
# F5.1 intentionally ends with zero operational/domain instances. The # F5.1 performs the historical clean start. F6.1 then seeds only the
# technical family and finding masters remain, but territory preload, # presentation territory approved by DH: 7 Departamentos, 64 Áreas,
# imports, applicability links and old audits are deliberately gone. # 230 Yacimientos and 12 real Empresas.
docker compose --env-file .env.example exec -T db \ docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$ DO $$
@@ -128,6 +128,7 @@ jobs:
audits integer; audits integer;
applicability integer; applicability integer;
territory_sources integer; territory_sources integer;
presentation_sources integer;
source_installations integer; source_installations integer;
source_subinstallations integer; source_subinstallations integer;
source_findings integer; source_findings integer;
@@ -149,18 +150,18 @@ jobs:
END IF; END IF;
SELECT COUNT(*) INTO domain_assets FROM assets; SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 0 THEN IF domain_assets <> 313 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets; RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
END IF; END IF;
SELECT COUNT(*) INTO audits FROM audit_events; SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN IF audits <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits; RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
END IF; END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families; SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 0 THEN IF applicability <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability; RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
END IF; END IF;
SELECT COUNT(*) INTO territory_sources SELECT COUNT(*) INTO territory_sources
@@ -170,13 +171,20 @@ jobs:
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources; RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF; 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'), SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION') COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations INTO source_installations,source_subinstallations
FROM inventory_families FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%'; WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations; RAISE EXCEPTION 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
END IF; END IF;
SELECT COUNT(*) INTO source_findings SELECT COUNT(*) INTO source_findings
@@ -184,7 +192,7 @@ jobs:
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true; WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings; RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
END IF; END IF;
SELECT COUNT(*) INTO department_types SELECT COUNT(*) INTO department_types
@@ -205,13 +213,13 @@ jobs:
END $$; END $$;
SQL SQL
# F5.1 is intentionally one-way: production rollback is the PRE database # Both destructive cuts are intentionally one-way: production rollback is
# backup, not migration:revert. Prove instead that the completed chain is # the PRE database backup, not migration:revert. Prove the completed chain
# idempotent and has no pending migration on a second run. # is idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)" rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log" 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" || { grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: F5.1 migration chain is not idempotent." >&2 echo "ERROR: F6.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2 cat "$rerun_log" >&2
exit 1 exit 1
} }
@@ -0,0 +1,26 @@
name: Inspection planning smoke
on:
pull_request:
branches: [main]
paths:
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/database/migrations/**'
- 'api-v3/src/reference-data/**'
- 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx'
- 'web-v2/src/pages/FieldBriefingsPage.tsx'
- 'web-v2/src/layout/AppLayout.tsx'
- 'scripts/ci-inspection-planning-smoke.sh'
- '.github/workflows/inspection-planning-smoke.yml'
permissions:
contents: read
jobs:
create-inspection:
name: F6.1 · real inspection create
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rehearse hierarchy and create a real Inspection
run: bash scripts/ci-inspection-planning-smoke.sh
+48
View File
@@ -0,0 +1,48 @@
name: Production dependency audit
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-production-audit-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
name: API · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
web:
name: WEB · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
+3 -1
View File
@@ -12,10 +12,12 @@ Repositorio del sistema DH Inspección V2.
## Documentación vigente ## Documentación vigente
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md) - [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
- [Manual de Usuario](docs/MANUAL_USUARIO.md) - [Manual de Usuario](docs/MANUAL_USUARIO.md)
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.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) - [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 ## Contratos que no deben romperse
@@ -26,4 +28,4 @@ Repositorio del sistema DH Inspección V2.
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend. - 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. - GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
Las reglas detalladas y el procedimiento de cambio seguro están en los manuales enlazados arriba. Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
+48
View File
@@ -0,0 +1,48 @@
# 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" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 22 versionCode = 24
versionName = "0.15.0" versionName = "0.15.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -77,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
} }
@@ -3,11 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <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_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion" android:theme="@style/Theme.DHInspeccion"
@@ -115,7 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) visit = repository.visit(id)
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -137,7 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.operationalArea?.id inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -197,7 +197,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
} }
} }
@@ -205,7 +205,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -213,7 +213,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
private suspend fun reloadCurrentInventory(visitId: String) { private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
} }
@@ -154,6 +154,7 @@ data class VisitDetail(
val code: String, val code: String,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null, val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null, val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null, val leadInspector: PersonSummary? = null,
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) try {
.addOnSuccessListener { location -> client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) if (!continuation.isActive) return@addOnSuccessListener
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) 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) } }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -727,16 +727,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) try {
.addOnSuccessListener { location -> client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) { if (!continuation.isActive) return@addOnSuccessListener
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) if (location == null) {
} else { continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) } else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
} }
} .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } } catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -389,13 +389,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) try {
.addOnSuccessListener { location -> client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) if (!continuation.isActive) return@addOnSuccessListener
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) 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) } }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -431,13 +431,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context) try {
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) LocationServices.getFusedLocationProviderClient(context)
.addOnSuccessListener { location -> .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
if (!continuation.isActive) return@addOnSuccessListener .addOnSuccessListener { location ->
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) if (!continuation.isActive) return@addOnSuccessListener
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) 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) } }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -0,0 +1,57 @@
<?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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,20 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(24, BuildConfig.VERSION_CODE)
assertEquals("0.15.2-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", "name": "dhv2-api",
"version": "0.20.0-2", "version": "0.29.0-1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.20.0-2", "version": "0.29.0-1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.2.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.3", "version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
+5 -1
View File
@@ -42,5 +42,9 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
} }
} }
@@ -0,0 +1,35 @@
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)
)
`);
}
}
@@ -0,0 +1,163 @@
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]);
}
}
@@ -1,9 +1,18 @@
import { IsISO8601, IsUUID } from 'class-validator'; import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
export class CreateInspectionVisitDto { export class CreateInspectionVisitDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId!: string; operationalAreaId!: string;
/**
* Yacimiento concreto de la Inspección. Se mantiene opcional sólo para no
* romper clientes Android anteriores, que todavía abren una Inspección a
* nivel Área; la WEB F6.1 siempre lo envía.
*/
@IsOptional()
@IsUUID('4')
scopeAssetId?: string;
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
@@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import type { InspectionVisitView } from './inspection-visits.service'; import type {
InspectionChecklistItemKind,
InspectionVisitView,
} from './inspection-visits.service';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
type F4ChecklistClassification = {
findingId: string;
itemKind: InspectionChecklistItemKind;
referenceOn: string | null;
};
/** /**
* Capa activa F4 sobre el servicio de Inspecciones. * Capa activa F4 sobre el servicio de Inspecciones.
* *
* Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica * El checklist persistido es una generación histórica append-only. F4 necesita
* la clasificación técnica F4 del checklist. El contrato público de Inspección ya * mostrar la clasificación vigente de controles, pero una lectura nunca debe
* no expone título independiente ni fecha prevista de fin. * reescribir esa generación. La clasificación se deriva con SELECT y se aplica
* sólo a la vista devuelta por la API.
*/ */
@Injectable() @Injectable()
export class F4InspectionVisitsService extends InspectionVisitsService { export class F4InspectionVisitsService extends InspectionVisitsService {
@@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
} }
override async getById(id: string): Promise<InspectionVisitView> { override async getById(id: string): Promise<InspectionVisitView> {
await this.reclassifyCurrentChecklist(id); const visit = await super.getById(id);
return this.normalizeChecklist(await super.getById(id)); return this.normalizeChecklist(await this.classifyChecklistView(visit));
} }
override async create( override async create(
@@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionVisitView> { ): Promise<InspectionVisitView> {
const created = await super.create(dto, principal, request); const visit = await super.create(dto, principal, request);
await this.reclassifyCurrentChecklist(created.id); return this.normalizeChecklist(await this.classifyChecklistView(visit));
return this.normalizeChecklist(await super.getById(created.id));
} }
override async generateChecklist( override async generateChecklist(
@@ -51,88 +60,106 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionVisitView> { ): Promise<InspectionVisitView> {
await super.generateChecklist(id, principal, request); const visit = await super.generateChecklist(id, principal, request);
await this.reclassifyCurrentChecklist(id); return this.normalizeChecklist(await this.classifyChecklistView(visit));
return this.normalizeChecklist(await super.getById(id));
} }
private normalizeChecklist<T>(visit: T): T { private normalizeChecklist<T>(visit: T): T {
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit; if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
const view = visit as T & InspectionVisitView; const view = visit as T & InspectionVisitView;
const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE');
const actionableAssetIds = new Set(
items
.filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind))
.map((item) => item.asset.id),
);
view.checklist = { view.checklist = {
...view.checklist, ...view.checklist,
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
companyOverdue: 0, companyOverdue: 0,
items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'), verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length,
upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
actionableAssets: actionableAssetIds.size,
items,
}; };
return view; return view;
} }
private async reclassifyCurrentChecklist(visitId: string): Promise<void> { /**
await this.f4DataSource.transaction(async (manager) => { * Deriva el estado F4 actual de los ítems sin mutar la generación persistida.
const [visit] = await manager.query(` * La fuente administrativa de empresa vive a nivel Acta; en este checklist de
SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt" * campo sólo importan próximos controles y verificaciones no resueltas.
FROM inspection_visits */
WHERE id=$1 private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> {
FOR UPDATE if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit;
`, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>; const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))];
if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return; if (findingIds.length === 0) return visit;
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10); const classifications = (await this.f4DataSource.query(`
await manager.query(` SELECT
UPDATE inspection_visit_checklist_items item finding.id AS "findingId",
SET CASE
item_kind = CASE WHEN finding.status='OPEN'
WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL
AND finding.next_control_on IS NOT NULL AND finding.next_control_on < $2::date
AND finding.next_control_on < $3::date AND COALESCE((
AND COALESCE(( SELECT verification.outcome
SELECT verification.outcome FROM inspection_finding_verification_visits verification
FROM inspection_finding_verification_visits verification WHERE verification.finding_id=finding.id
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST,
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC,
verification.created_at DESC, verification.id DESC
verification.id DESC LIMIT 1
LIMIT 1 ), '') <> 'RESOLVED'
), '') <> 'RESOLVED' THEN 'VERIFICATION_OVERDUE'
THEN 'VERIFICATION_OVERDUE' WHEN finding.status='OPEN'
WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL
AND finding.next_control_on IS NOT NULL AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30)
AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30) AND COALESCE((
AND COALESCE(( SELECT verification.outcome
SELECT verification.outcome FROM inspection_finding_verification_visits verification
FROM inspection_finding_verification_visits verification WHERE verification.finding_id=finding.id
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST,
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC,
verification.created_at DESC, verification.id DESC
verification.id DESC LIMIT 1
LIMIT 1 ), '') <> 'RESOLVED'
), '') <> 'RESOLVED' THEN 'UPCOMING_CONTROL'
THEN 'UPCOMING_CONTROL' ELSE 'ANTECEDENT'
ELSE 'ANTECEDENT' END AS "itemKind",
END, CASE
reference_on = CASE WHEN finding.status='OPEN'
WHEN finding.status='OPEN' AND finding.next_control_on IS NOT NULL
AND finding.next_control_on IS NOT NULL AND COALESCE((
AND COALESCE(( SELECT verification.outcome
SELECT verification.outcome FROM inspection_finding_verification_visits verification
FROM inspection_finding_verification_visits verification WHERE verification.finding_id=finding.id
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
AND verification.outcome IS NOT NULL ORDER BY verification.result_recorded_at DESC NULLS LAST,
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC,
verification.created_at DESC, verification.id DESC
verification.id DESC LIMIT 1
LIMIT 1 ), '') <> 'RESOLVED'
), '') <> 'RESOLVED' THEN finding.next_control_on::text
THEN finding.next_control_on ELSE NULL
ELSE NULL END AS "referenceOn"
END FROM inspection_findings finding
FROM inspection_findings finding WHERE finding.id=ANY($1::uuid[])
WHERE item.visit_id=$1 `, [findingIds, targetDate])) as F4ChecklistClassification[];
AND item.generation_number=$2
AND finding.id=item.finding_id const byFinding = new Map(classifications.map((item) => [item.findingId, item]));
`, [visitId, visit.generation, targetDate]); visit.checklist = {
}); ...visit.checklist,
items: visit.checklist.items.map((item) => {
const classification = byFinding.get(item.findingId);
return classification
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
: item;
}),
};
return visit;
} }
} }
@@ -129,6 +129,7 @@ export class FieldFindingsService {
visit.id AS "visitId", visit.id AS "visitId",
visit.code AS "visitCode", visit.code AS "visitCode",
visit.status AS "visitStatus", visit.status AS "visitStatus",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId", visit.operational_area_id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName", area.name AS "areaName",
@@ -139,8 +140,7 @@ export class FieldFindingsService {
asset_type.code AS "assetTypeCode", asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId", asset.inventory_family_id AS "inventoryFamilyId",
( (
asset.operational_area_id=visit.operational_area_id asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
OR asset.id=visit.operational_area_id
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -148,9 +148,11 @@ export class FieldFindingsService {
SELECT parent.id,parent.parent_id SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) )
SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1 SELECT 1 FROM ancestors
WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
LIMIT 1
) )
) AS "insideArea", ) AS "insideScope",
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM area_company_relations relation FROM area_company_relations relation
@@ -213,6 +215,7 @@ export class FieldFindingsService {
visitId: string; visitId: string;
visitCode: string; visitCode: string;
visitStatus: string; visitStatus: string;
scopeAssetId: string | null;
areaId: string | null; areaId: string | null;
areaCode: string | null; areaCode: string | null;
areaName: string | null; areaName: string | null;
@@ -222,7 +225,7 @@ export class FieldFindingsService {
assetId: string | null; assetId: string | null;
assetTypeCode: string | null; assetTypeCode: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
insideArea: boolean; insideScope: boolean;
operatorRelationValid: boolean; operatorRelationValid: boolean;
assigned: boolean; assigned: boolean;
selected: boolean; selected: boolean;
@@ -278,10 +281,10 @@ export class FieldFindingsService {
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
}); });
} }
if (!row.insideArea) { if (!row.insideScope) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Área de esta inspección', message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección',
}); });
} }
if (!row.selected) { if (!row.selected) {
@@ -23,6 +23,7 @@ interface MobileVisitContext {
code: string; code: string;
status: string; status: string;
areaId: string; areaId: string;
scopeAssetId: string;
areaCode: string; areaCode: string;
areaName: string; areaName: string;
companyId: string; companyId: string;
@@ -80,12 +81,12 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context); if (query.parentId) await this.requireParentInContext(query.parentId, context);
// Empresa is visit context, never physical ownership. Search by Area ancestry. // Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope.
const args: unknown[] = [context.areaId, visitId]; const args: unknown[] = [context.scopeAssetId, visitId];
const conditions = [ const conditions = [
"asset.information_status <> 'INACTIVE'", "asset.information_status <> 'INACTIVE'",
`( `(
asset.operational_area_id = $1::uuid asset.id = $1::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -198,7 +199,7 @@ export class FieldInventoryService {
principal: AuthPrincipal, principal: AuthPrincipal,
) { ) {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
const effectiveParentId = parentId ?? context.areaId; const effectiveParentId = parentId ?? context.scopeAssetId;
const parent = await this.requireParentInContext(effectiveParentId, context); const parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
@@ -272,7 +273,7 @@ export class FieldInventoryService {
request: RequestWithContext, request: RequestWithContext,
) { ) {
const context = await this.requireVisitContext(visitId, principal, true); const context = await this.requireVisitContext(visitId, principal, true);
const parentId = dto.parentId ?? context.areaId; const parentId = dto.parentId ?? context.scopeAssetId;
await this.requireParentInContext(parentId, context); await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt); const capturedAt = new Date(dto.deviceCapturedAt);
@@ -412,6 +413,7 @@ export class FieldInventoryService {
visit.id, visit.id,
visit.code, visit.code,
visit.status, visit.status,
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId", visit.operational_area_id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName", area.name AS "areaName",
@@ -485,6 +487,7 @@ export class FieldInventoryService {
private publicContext(context: MobileVisitContext) { private publicContext(context: MobileVisitContext) {
return { return {
inspection: { id: context.id, code: context.code, status: context.status }, inspection: { id: context.id, code: context.code, status: context.status },
scopeAssetId: context.scopeAssetId,
area: { id: context.areaId, code: context.areaCode, name: context.areaName }, area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: { operatorCompany: {
id: context.companyId, id: context.companyId,
@@ -503,7 +506,6 @@ export class FieldInventoryService {
asset.asset_type_id AS "typeId", asset.asset_type_id AS "typeId",
( (
asset.id=$2::uuid asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -512,16 +514,16 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
) )
) AS "insideArea" ) AS "insideScope"
FROM assets asset FROM assets asset
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [parentId, context.areaId])) as Array<{ `, [parentId, context.scopeAssetId])) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeId: string; typeId: string;
insideArea: boolean; insideScope: boolean;
}>; }>;
if (!parent) { if (!parent) {
@@ -530,10 +532,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe', message: 'La ubicación padre no existe',
}); });
} }
if (!parent.insideArea) { if (!parent.insideScope) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área de la inspección', message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección',
}); });
} }
return parent; return parent;
@@ -545,7 +547,7 @@ export class FieldInventoryService {
asset.id, asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
( (
asset.operational_area_id=$2::uuid asset.id=$2::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -554,15 +556,15 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
) )
) AS "insideArea" ) AS "insideScope"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [assetId, context.areaId])) as Array<{ `, [assetId, context.scopeAssetId])) as Array<{
id: string; id: string;
eligibleTarget: boolean; eligibleTarget: boolean;
insideArea: boolean; insideScope: boolean;
}>; }>;
if (!asset) { if (!asset) {
@@ -571,10 +573,10 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
if (!asset.insideArea) { if (!asset.insideScope) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área de esta inspección', message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección',
}); });
} }
if (!asset.eligibleTarget) { if (!asset.eligibleTarget) {
@@ -0,0 +1,250 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
import { InspectionVisitsService } from './inspection-visits.service';
/**
* Creación WEB F6.1. Separa explícitamente ubicación territorial y alcance:
* Departamento → Área → Yacimiento. La Operadora no es un padre físico; se
* valida contra la relación temporal vigente del Área para la fecha planificada.
*/
@Injectable()
export class InspectionPlanningCreateService {
private readonly logger = new Logger(InspectionPlanningCreateService.name);
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly visits: InspectionVisitsService,
) {}
async create(
dto: CreateInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const scopeAssetId = dto.scopeAssetId;
if (!scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_REQUIRED',
message: 'Seleccioná el Yacimiento de la Inspección',
});
}
const plannedStartAt = new Date(dto.plannedStartAt);
if (!Number.isFinite(plannedStartAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_START_DATE_REQUIRED',
message: 'Indicá una fecha y hora prevista de inicio válida',
});
}
let stage = 'transaction:start';
try {
const visitId = await this.dataSource.transaction(async (manager) => {
stage = 'scope:validate';
await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId);
stage = 'operator:validate';
const [context] = await manager.query(`
SELECT relation.id
FROM area_company_relations relation
INNER JOIN assets area ON area.id=relation.area_id
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
INNER JOIN assets company ON company.id=relation.company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
AND area_type.operational_role='AREA'
AND company_type.operational_role='COMPANY'
AND area.information_status<>'INACTIVE'
AND company.information_status<>'INACTIVE'
LIMIT 1
`, [dto.operationalAreaId, dto.operatorCompanyId, plannedStartAt]) as Array<{ id: string }>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA',
message: 'La Operadora seleccionada no está vigente para el Área en la fecha de la Inspección',
});
}
stage = 'inspector:validate';
const [inspector] = await manager.query(`
SELECT user_account.id
FROM users user_account
WHERE user_account.id=$1::uuid
AND user_account.status='ACTIVE'
AND EXISTS (
SELECT 1
FROM user_roles user_role
INNER JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id
INNER JOIN permissions permission ON permission.id=role_permission.permission_id
WHERE user_role.user_id=user_account.id
AND permission.code='inspections.execute'
)
LIMIT 1
`, [dto.leadInspectorUserId]) as Array<{ id: string }>;
if (!inspector) {
throw new BadRequestException({
code: 'INSPECTION_MEMBER_INVALID',
message: 'El Inspector responsable no está activo o no tiene permiso de ejecución',
});
}
stage = 'code:next';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const visit = manager.getRepository(InspectionVisit).create({
code,
objective: null,
status: InspectionVisitStatus.DRAFT,
scopeAssetId,
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
leadInspectorUserId: dto.leadInspectorUserId,
plannedStartAt,
actualStartedAt: null,
actualClosedAt: null,
instructions: null,
cancellationReason: null,
checklistGeneration: 1,
checklistGeneratedAt: new Date(),
createdBy: principal.userId,
updatedBy: principal.userId,
});
stage = 'visit:save';
await manager.getRepository(InspectionVisit).save(visit);
stage = 'member:insert';
await manager.query(`
INSERT INTO inspection_visit_members (visit_id,user_id,included,assigned_by)
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
// El checklist sigue el alcance físico del Yacimiento. No usa
// assets.operator_company_id, que en F6 es sólo snapshot histórico.
stage = 'checklist:insert';
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
visit_id,generation_number,finding_id,asset_id,item_kind,
reference_on,finding_status,finding_code,finding_title,severity
)
SELECT
$1::uuid,1,finding.id,finding.asset_id,
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
CASE WHEN finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END,
finding.status,finding.code,finding.title,finding.severity
FROM inspection_findings finding
INNER JOIN assets inventory ON inventory.id=finding.asset_id
WHERE inventory.operational_area_id=$2::uuid
AND finding.status<>'VOIDED'
AND EXISTS (
WITH RECURSIVE ancestors AS (
SELECT current_asset.id,current_asset.parent_id
FROM assets current_asset WHERE current_asset.id=finding.asset_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent INNER JOIN ancestors child ON child.parent_id=parent.id
)
SELECT 1 FROM ancestors WHERE id=$3::uuid LIMIT 1
)
ORDER BY finding.created_at,finding.id
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
stage = 'checklist:assets';
await manager.query(`
INSERT INTO inspection_visit_assets (
visit_id, asset_id, included, planning_source, added_by
)
SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
ON CONFLICT (visit_id,asset_id) DO UPDATE SET
included=true,
planning_source=CASE
WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'
ELSE 'AUTOMATIC'
END,
exclusion_reason=NULL,
excluded_by=NULL,
excluded_at=NULL,
added_by=EXCLUDED.added_by,
updated_at=CURRENT_TIMESTAMP
`, [visit.id, principal.userId]);
stage = 'checklist:asset-events';
await manager.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, reason, actor_user_id, metadata
)
SELECT DISTINCT
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
jsonb_build_object('generation',1,'source','F6.1_CREATE')
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
AND NOT EXISTS (
SELECT 1 FROM inspection_visit_asset_events event
WHERE event.visit_id=$1::uuid
AND event.asset_id=item.asset_id
AND event.event_type='AUTO_INCLUDED'
)
`, [visit.id, principal.userId]);
stage = 'audit:record';
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_CREATED,
entityType: 'inspection_visit',
entityId: visit.id,
afterData: {
id: visit.id,
code,
status: visit.status,
departmentHierarchy: 'Departamento → Área → Yacimiento',
operationalAreaId: dto.operationalAreaId,
scopeAssetId,
operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: plannedStartAt.toISOString(),
leadInspectorUserId: dto.leadInspectorUserId,
},
}, manager);
return visit.id;
});
stage = 'view:load';
return await this.visits.getById(visitId);
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'INSPECTION_CODE_ALREADY_EXISTS',
message: 'El identificador de Inspección ya existe; volvé a intentar',
});
}
const technical = error instanceof Error
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
: String(error);
this.logger.error(`F6.1 inspection create failed at ${stage}: ${technical}`);
throw error;
}
}
}
@@ -0,0 +1,109 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPlanningHierarchyItem {
id: string;
code: string;
name: string;
}
@Injectable()
export class InspectionPlanningHierarchyService {
constructor(private readonly dataSource: DataSource) {}
async departments(): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
const data = await this.dataSource.query(`
SELECT department.id, department.code, department.name
FROM assets department
INNER JOIN asset_types type ON type.id=department.asset_type_id
WHERE lower(type.code)='departamento'
AND type.is_active=true
AND department.information_status<>'INACTIVE'
AND EXISTS (
SELECT 1
FROM assets area
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE area.parent_id=department.id
AND lower(area_type.code)='area'
AND area.information_status<>'INACTIVE'
)
ORDER BY department.name, department.code
`) as InspectionPlanningHierarchyItem[];
return { data };
}
async areasForDepartment(departmentId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(departmentId, 'departamento', 'El Departamento seleccionado no es válido');
const data = await this.dataSource.query(`
SELECT area.id, area.code, area.name
FROM assets area
INNER JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.parent_id=$1::uuid
AND lower(type.code)='area'
AND type.is_active=true
AND area.information_status<>'INACTIVE'
ORDER BY area.name, area.code
`, [departmentId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async yacimientosForArea(areaId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
const data = await this.dataSource.query(`
SELECT yacimiento.id, yacimiento.code, yacimiento.name
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=$1::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
ORDER BY yacimiento.name, yacimiento.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async operatorsForArea(
areaId: string,
at?: string,
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
const effectiveAt = at ? new Date(at) : new Date();
if (!Number.isFinite(effectiveAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_PLANNING_DATE_INVALID',
message: 'La fecha de planificación no es válida',
});
}
const data = await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
INNER JOIN asset_types type ON type.id=company.asset_type_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 type.operational_role='COMPANY'
AND type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY company.name, company.code
`, [areaId, effectiveAt]) as InspectionPlanningHierarchyItem[];
return { data };
}
private async requireType(id: string, typeCode: string, message: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1
FROM assets asset
INNER JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND lower(type.code)=lower($2)
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
LIMIT 1
`, [id, typeCode]) as Array<{ '?column?': number }>;
if (!row) {
throw new BadRequestException({ code: 'INSPECTION_PLANNING_HIERARCHY_INVALID', message });
}
}
}
@@ -0,0 +1,35 @@
import { BadRequestException } from '@nestjs/common';
import type { EntityManager } from 'typeorm';
/**
* F6.1 keeps territorial levels explicit. operationalAreaId freezes the Area
* context while scopeAssetId identifies the Yacimiento inspected inside it.
* Legacy/mobile callers may still use the Area itself as scope until Android
* supplies a Yacimiento explicitly.
*/
export async function validateInspectionPlanningScope(
manager: EntityManager,
operationalAreaId: string,
scopeAssetId: string,
): Promise<void> {
if (scopeAssetId === operationalAreaId) return;
const [scope] = await manager.query(`
SELECT 1
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, operationalAreaId]) as Array<{ '?column?': number }>;
if (!scope) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_OUTSIDE_AREA',
message: 'El Yacimiento seleccionado no pertenece al Área indicada',
});
}
}
@@ -23,6 +23,8 @@ import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@@ -31,6 +33,8 @@ export class InspectionVisitsController {
constructor( constructor(
private readonly visits: InspectionVisitsService, private readonly visits: InspectionVisitsService,
private readonly lifecycle: InspectionVisitLifecycleService, private readonly lifecycle: InspectionVisitLifecycleService,
private readonly planningHierarchy: InspectionPlanningHierarchyService,
private readonly planningCreate: InspectionPlanningCreateService,
) {} ) {}
@Get() @Get()
@@ -45,6 +49,29 @@ export class InspectionVisitsController {
return this.visits.listAssignees(); return this.visits.listAssignees();
} }
@Get('planning-context/departments')
@RequirePermissions('inspections.read')
planningDepartments() {
return this.planningHierarchy.departments();
}
@Get('planning-context/departments/:departmentId/areas')
@RequirePermissions('inspections.read')
planningAreasForDepartment(
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
) {
return this.planningHierarchy.areasForDepartment(departmentId);
}
@Get('planning-context/areas/:areaId/yacimientos')
@RequirePermissions('inspections.read')
planningYacimientos(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
) {
return this.planningHierarchy.yacimientosForArea(areaId);
}
/** Compatibilidad con clientes anteriores: lista todas las Áreas. */
@Get('planning-context/areas') @Get('planning-context/areas')
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
planningAreas() { planningAreas() {
@@ -55,8 +82,9 @@ export class InspectionVisitsController {
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
planningOperators( planningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@Query('at') at?: string,
) { ) {
return this.visits.listPlanningOperators(areaId); return this.planningHierarchy.operatorsForArea(areaId, at);
} }
@Get('mobile/planning-context/areas') @Get('mobile/planning-context/areas')
@@ -84,8 +112,8 @@ export class InspectionVisitsController {
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
assertMobileInspector(principal); assertMobileInspector(principal);
// Android no tiene un lifecycle paralelo: crea la visita y delega las // Android conserva por compatibilidad el alcance a nivel Área hasta que su
// transiciones DRAFT → PLANNED → IN_PROGRESS a los servicios canónicos. // flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
const created = await this.visits.create({ const created = await this.visits.create({
operationalAreaId: dto.operationalAreaId, operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId, operatorCompanyId: dto.operatorCompanyId,
@@ -109,7 +137,7 @@ export class InspectionVisitsController {
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
return this.visits.create(dto, principal, request); return this.planningCreate.create(dto, principal, request);
} }
@Patch(':id') @Patch(':id')
@@ -8,6 +8,8 @@ import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service'; import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller'; import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service'; import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@@ -17,6 +19,8 @@ import { InspectionVisitsService } from './inspection-visits.service';
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController], controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [ providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService }, { provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionVisitLifecycleService, InspectionVisitLifecycleService,
FieldInventoryService, FieldInventoryService,
F3FieldInventoryStructureService, F3FieldInventoryStructureService,
@@ -23,6 +23,7 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code'; import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
export interface InspectionPerson { export interface InspectionPerson {
id: string; id: string;
@@ -312,7 +313,7 @@ export class InspectionVisitsService {
} }
const operationalAreaId = dto.operationalAreaId; const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId; const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId); await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);
await this.requireAsset(manager, operationalAreaId); await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]); await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt); const code = await nextInspectionVisitCode(manager, plannedStartAt);
@@ -382,29 +383,24 @@ export class InspectionVisitsService {
? visit.operatorCompanyId ? visit.operatorCompanyId
: dto.operatorCompanyId; : dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined; const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = contextTouched const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;
? nextAreaId await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
await this.requireAsset(manager, nextScope); await this.requireAsset(manager, nextScope);
if (contextTouched && nextAreaId && nextCompanyId) { if (nextAreaId && nextScope) {
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId); await validateInspectionPlanningScope(manager, nextAreaId, nextScope);
} else if (dto.scopeAssetId !== undefined) {
await this.assertCurrentAssetsInScope(manager, id, nextScope); await this.assertCurrentAssetsInScope(manager, id, nextScope);
} }
if (dto.objective !== undefined) visit.objective = dto.objective; if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) { if (contextTouched) {
visit.operationalAreaId = nextAreaId; visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId; visit.operatorCompanyId = nextCompanyId;
visit.scopeAssetId = nextAreaId; }
} else if (dto.scopeAssetId !== undefined) { if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId; visit.scopeAssetId = dto.scopeAssetId;
} }
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart; if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions; if (dto.instructions !== undefined) visit.instructions = dto.instructions;
if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null; if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
visit.updatedBy = principal.userId; visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit); await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id); const updated = await this.loadView(manager, id);
@@ -463,11 +459,10 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos', message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
}); });
} }
await this.assertAssetsMatchContext( await this.assertAssetsInScope(
manager, manager,
additions, additions,
visit.operationalAreaId, visit.scopeAssetId ?? visit.operationalAreaId,
visit.operatorCompanyId,
); );
for (const assetId of additions) { for (const assetId of additions) {
await manager.query(` await manager.query(`
@@ -629,11 +624,10 @@ export class InspectionVisitsService {
if (!visit.operationalAreaId || !visit.operatorCompanyId) { if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' }); throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
} }
await this.assertAssetsMatchContext( await this.assertAssetsInScope(
manager, manager,
[assetId], [assetId],
visit.operationalAreaId, visit.scopeAssetId ?? visit.operationalAreaId,
visit.operatorCompanyId,
); );
await manager.query(` await manager.query(`
UPDATE inspection_visit_assets UPDATE inspection_visit_assets
@@ -920,6 +914,7 @@ export class InspectionVisitsService {
manager: EntityManager, manager: EntityManager,
operationalAreaId: string | null, operationalAreaId: string | null,
operatorCompanyId: string | null, operatorCompanyId: string | null,
effectiveAt: Date | null = null,
): Promise<void> { ): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return; if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) { if (!operationalAreaId || !operatorCompanyId) {
@@ -938,8 +933,8 @@ export class InspectionVisitsService {
WHERE relation.area_id = area.id WHERE relation.area_id = area.id
AND relation.company_id = company.id AND relation.company_id = company.id
AND relation.relation_role = 'OPERATOR' AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
) AS "activeOperatorRelation" ) AS "activeOperatorRelation"
FROM assets area FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -951,7 +946,7 @@ export class InspectionVisitsService {
AND company_type.operational_role = 'COMPANY' AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE' AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE' AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId])) as Array<{ `, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{
areaId: string; areaId: string;
companyId: string; companyId: string;
activeOperatorRelation: boolean; activeOperatorRelation: boolean;
@@ -970,44 +965,6 @@ export class InspectionVisitsService {
} }
} }
private async assertAssetsMatchContext(
manager: EntityManager,
assetIds: string[],
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM assets asset
WHERE asset.id = ANY($1::uuid[])
AND (
asset.operational_area_id IS DISTINCT FROM $2::uuid
OR asset.operator_company_id IS DISTINCT FROM $3::uuid
)
`, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT',
message: 'Uno o más registros no pertenecen al Área y Operadora de la visita',
});
}
}
private async assertCurrentAssetsMatchContext(
manager: EntityManager,
visitId: string,
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
await this.assertAssetsMatchContext(
manager,
await this.activeAssetIds(manager, visitId),
operationalAreaId,
operatorCompanyId,
);
}
private async appendAssetEvent( private async appendAssetEvent(
manager: EntityManager, manager: EntityManager,
visitId: string, visitId: string,
@@ -1041,7 +998,7 @@ export class InspectionVisitsService {
message: 'Indicá la fecha prevista antes de generar el checklist', message: 'Indicá la fecha prevista antes de generar el checklist',
}); });
} }
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt);
const generation = visit.checklistGeneration + 1; const generation = visit.checklistGeneration + 1;
await manager.query(` await manager.query(`
INSERT INTO inspection_visit_checklist_items ( INSERT INTO inspection_visit_checklist_items (
@@ -1106,14 +1063,25 @@ export class InspectionVisitsService {
FROM inspection_findings finding FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3 WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED' AND finding.status <> 'VOIDED'
AND (
asset.id=$4::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=$4::uuid LIMIT 1
)
)
ORDER BY finding.created_at, finding.id ORDER BY finding.created_at, finding.id
`, [ `, [
visit.id, visit.id,
generation, generation,
visit.operationalAreaId, visit.operationalAreaId,
visit.operatorCompanyId, visit.scopeAssetId ?? visit.operationalAreaId,
visit.plannedStartAt.toISOString().slice(0, 10), visit.plannedStartAt.toISOString().slice(0, 10),
]); ]);
@@ -1295,7 +1263,12 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación', message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
}); });
} }
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId); await this.validatePlanningContext(
manager,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt,
);
if (!visit.scopeAssetId) { if (!visit.scopeAssetId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED', code: 'INSPECTION_SCOPE_REQUIRED',
@@ -1350,13 +1323,10 @@ export class InspectionVisitsService {
message: 'El equipo debe incluir al inspector responsable', message: 'El equipo debe incluir al inspector responsable',
}); });
} }
// F6.1: Inventory membership is physical (Yacimiento scope).
// Company is temporal Area context and must never be reconstructed from
// assets.operator_company_id, which is only a creation/historical snapshot.
await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId); await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId);
await this.assertCurrentAssetsMatchContext(
manager,
visit.id,
visit.operationalAreaId,
visit.operatorCompanyId,
);
} }
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> { private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
@@ -0,0 +1,30 @@
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
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => { test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 22/); assert.match(gradle, /versionCode = 24/);
assert.match(gradle, /versionName = "0\.15\.0"/); assert.match(gradle, /versionName = "0\.15\.2"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
@@ -113,7 +113,7 @@ test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM
); );
}); });
test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => { test('F5/F6.1 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8'); const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8'); const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8'); const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
@@ -129,7 +129,8 @@ test('F5 source contracts keep Empresa out of physical ownership and preserve se
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/); assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
assert.doesNotMatch(mergeSource, /misma Área y Operadora/); assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
assert.match(fieldSource, /Área de esta inspección/); assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/);
assert.match(fieldSource, /scopeAssetId/);
assert.match(fieldSource, /area_company_relations/); assert.match(fieldSource, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/); assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
@@ -0,0 +1,77 @@
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 inspection planning exposes Departamento → Área → Yacimiento explicitly', () => {
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /lower\(type\.code\)='departamento'/);
assert.match(hierarchy, /area\.parent_id=\$1::uuid/);
assert.match(hierarchy, /lower\(type\.code\)='yacimiento'/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(controller, /planning-context\/departments/);
assert.match(controller, /planning-context\/departments\/:departmentId\/areas/);
assert.match(controller, /planning-context\/areas\/:areaId\/yacimientos/);
assert.match(createPage, /Departamento → Área → Yacimiento/);
assert.match(createPage, /<span>Departamento<\/span>/);
assert.match(createPage, /<span>Área<\/span>/);
assert.match(createPage, /<span>Yacimiento<\/span>/);
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
});
test('F6.1 WEB creation freezes Area and uses the selected Yacimiento as physical scope', () => {
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(dto, /scopeAssetId\?: string/);
assert.match(scope, /yacimiento\.parent_id=\$2::uuid/);
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
assert.match(create, /scopeAssetId,/);
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
assert.match(create, /relation\.relation_role='OPERATOR'/);
assert.match(create, /relation\.valid_from <= \$3::timestamptz/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('F6.1 WEB inspection creation does not use Inventory operator snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /area_company_relations relation/);
assert.doesNotMatch(create, /inventory\.operator_company_id/);
assert.doesNotMatch(create, /asset\.operator_company_id/);
});
test('F4 checklist classification is derived on read and never rewrites the persisted generation', () => {
const f4 = source('src/inspection-visits/f4-inspection-visits.service.ts');
const d55 = source('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts');
assert.match(d55, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items/);
assert.match(f4, /classifyChecklistView/);
assert.match(f4, /FROM inspection_findings finding/);
assert.doesNotMatch(f4, /UPDATE inspection_visit_checklist_items/);
assert.doesNotMatch(f4, /reclassifyCurrentChecklist/);
});
test('Field preparation is an Inspection stage, not a parallel sidebar module', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const briefing = source('../web-v2/src/pages/FieldBriefingsPage.tsx');
const detail = source('../web-v2/src/pages/InspectionVisitDetailF61Page.tsx');
const app = source('../web-v2/src/app/App.tsx');
assert.doesNotMatch(layout, /label: 'Preparación de campo'/);
assert.match(briefing, /ETAPA DE LA INSPECCIÓN/);
assert.match(briefing, /inspectionId/);
assert.match(briefing, /Volver a la inspección/);
assert.match(detail, /ETAPA PREVIA AL CAMPO/);
assert.match(detail, /preparacion-campo\?inspectionId=/);
assert.match(app, /InspectionVisitDetailF61Page/);
});
@@ -0,0 +1,72 @@
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');
test('F6.1 field Inventory is bounded by the frozen Yacimiento scope', () => {
const source = api('src/inspection-visits/field-inventory.service.ts');
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(source, /const effectiveParentId = parentId \?\? context\.scopeAssetId/);
assert.match(source, /const parentId = dto\.parentId \?\? context\.scopeAssetId/);
assert.match(source, /\[parentId, context\.scopeAssetId\]/);
assert.match(source, /\[assetId, context\.scopeAssetId\]/);
assert.match(source, /AS "insideScope"/);
assert.doesNotMatch(source, /insideArea/);
});
test('F6.1 field Findings reject Inventory outside the frozen Yacimiento scope', () => {
const source = api('src/inspection-visits/field-findings.service.ts');
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(source, /AS "insideScope"/);
assert.match(source, /if \(!row\.insideScope\)/);
assert.match(source, /Yacimiento definido como alcance/);
assert.doesNotMatch(source, /AS "insideArea"/);
});
test('F6.1 planning preserves Yacimiento and never validates Inventory ownership from Company snapshot', () => {
const source = api('src/inspection-visits/inspection-visits.service.ts');
assert.match(source, /const nextScope = dto\.scopeAssetId === undefined \? visit\.scopeAssetId : dto\.scopeAssetId/);
assert.match(source, /validateInspectionPlanningScope\(manager, nextAreaId, nextScope\)/);
assert.match(source, /visit\.scopeAssetId \?\? visit\.operationalAreaId/);
assert.match(source, /visit\.plannedStartAt,/);
assert.doesNotMatch(source, /visit\.scopeAssetId = nextAreaId/);
assert.doesNotMatch(source, /assertAssetsMatchContext/);
assert.doesNotMatch(source, /assertCurrentAssetsMatchContext/);
assert.doesNotMatch(source, /asset\.operator_company_id IS DISTINCT FROM/);
});
test('F6.1 creation selects actionable checklist Inventory automatically', () => {
const source = api('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(source, /INSERT INTO inspection_visit_assets/);
assert.match(source, /COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL/);
assert.match(source, /'AUTO_INCLUDED'/);
});
test('F6.1 operator choices are resolved at the planned timestamp', () => {
const hierarchy = api('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = api('src/inspection-visits/inspection-visits.controller.ts');
const createPage = web('src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /operatorsForArea\([\s\S]*at\?: string/);
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
assert.match(hierarchy, /relation\.valid_until > \$2::timestamptz/);
assert.match(controller, /@Query\('at'\) at\?: string/);
assert.match(createPage, /operators\?at=\$\{at\}/);
assert.match(createPage, /\[areaId, plannedStartAt\]/);
});
test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => {
const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/);
});
@@ -0,0 +1,13 @@
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/);
});
@@ -0,0 +1,44 @@
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 planning preloads the local start time and resolves the Area operator immediately', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
assert.match(page, /function currentLocalDateTime\(\)/);
assert.match(page, /useState\(currentLocalDateTime\)/);
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
assert.match(page, /response\.data\.length === 1/);
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
assert.match(page, /Sin Operadora vigente para esa fecha/);
assert.match(hierarchy, /relation\.relation_role='OPERATOR'/);
assert.match(hierarchy, /relation\.valid_from <= \$2::timestamptz/);
assert.match(hierarchy, /relation\.valid_until IS NULL OR relation\.valid_until > \$2::timestamptz/);
});
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
assert.match(editor, /showCancelForm/);
assert.match(editor, /cancelReason/);
assert.match(editor, /<textarea/);
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
assert.match(editor, /maxLength=\{500\}/);
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
assert.match(editor, /Confirmar cancelación/);
assert.match(controller, /@Post\(':id\/cancel'\)/);
assert.match(dto, /@MinLength\(10\)/);
assert.match(dto, /@MaxLength\(4000\)/);
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
test('F6.1 presentation metadata keeps the visible WEB version aligned with package metadata', () => {
const pkg = JSON.parse(web('package.json')) as { version: string };
const version = web('src/config/version.ts');
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo ÁreaOperadora consolidado'/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
const app = web('src/app/App.tsx');
const layout = web('src/layout/AppLayout.tsx');
assert.doesNotMatch(app, /relevamientos/i);
assert.doesNotMatch(layout, /relevamientos/i);
assert.match(layout, /label: 'Inspecciones'/);
assert.match(layout, /label: 'Inventarios'/);
});
test('F6.1 presentation keeps the complete Inspector profile and documentary-copy explanation visible', () => {
const user = web('src/pages/UserDetailPage.tsx');
const delivery = api('src/inspection-reports/inspection-document-delivery.service.ts');
for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) {
assert.match(user, new RegExp(`name="${field}"`));
}
assert.match(user, /email es obligatorio para un Inspector/i);
assert.match(user, /la documentación se enviará también/i);
assert.match(delivery, /recipientKind:'INSPECTOR'/);
assert.match(delivery, /documentKind:'ACT_PDF'/);
assert.match(delivery, /documentKind:'REPORT_WORD'/);
});
test('F6.1 presentation keeps Android start, Otro and chronological merge flows wired to the canonical API', () => {
const mobileApi = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
const overview = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const viewModel = android('app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(mobileApi, /inspection-visits\/\{id\}\/start/);
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
assert.match(overview, /"Iniciar inspección"/);
assert.match(overview, /Otro \/ no catalogado/);
assert.match(viewModel, /repository\.startVisit\(id\)/);
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
});
test('F6.1 presentation keeps catalog merge append-only for emitted findings', () => {
const merge = api('src/inspection-findings/finding-catalog-merge.service.ts');
const resolver = api('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(merge, /los Hallazgos emitidos nunca se reescriben/);
assert.match(merge, /historicalFindingsRewritten: false/);
assert.match(merge, /historyPolicy: 'EMITTED_FINDINGS_PRESERVED'/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
@@ -0,0 +1,86 @@
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);
});
@@ -0,0 +1,45 @@
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.2 planning uses the shared CSRF-aware API requester', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const api = source('../web-v2/src/lib/api.ts');
assert.match(page, /import \{ apiRequest \} from '\.\.\/lib\/api';/);
assert.doesNotMatch(page, /async function requestJson/);
assert.doesNotMatch(page, /fetch\(`\/api\/v3/);
assert.match(page, /apiRequest<CreatedInspection>\('\/inspection-visits'/);
assert.match(api, /headers\.set\('x-csrf-token', token\)/);
assert.match(api, /readCsrfCookie\(\)/);
});
test('F6.2 removes the duplicated global operational bar and hidden persisted filters', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const context = source('../web-v2/src/context/OperationalContext.tsx');
assert.doesNotMatch(layout, /OperationalContextBar/);
assert.match(context, /localStorage\.removeItem\(LEGACY_STORAGE_KEY\)/);
assert.doesNotMatch(context, /localStorage\.setItem/);
assert.match(context, /useState\(''\)/);
});
test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () => {
const projection = source('../web-v2/src/components/DocumentPdfProjection.tsx');
const actPage = source('../web-v2/src/pages/InspectionActEditorPage.tsx');
const reportPage = source('../web-v2/src/pages/ReportDetailPage.tsx');
assert.match(projection, /PDF PRELIMINAR · ESTRUCTURA PROYECTADA/);
assert.match(projection, /Contrato de datos estable, renderer reemplazable/);
assert.match(projection, /Identificación y contexto/);
assert.match(projection, /Firmas y manifestaciones/);
assert.match(projection, /GEDO e integridad/);
assert.match(projection, /Anexo final/);
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
});
@@ -59,11 +59,12 @@ test('F6 field findings resolve by exact technical classification and keep OTROS
assert.match(resolver, /label: 'OTROS'/); assert.match(resolver, /label: 'OTROS'/);
}); });
test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => { test('F6.1 mobile finding gate uses Yacimiento scope and temporal Area operator relation instead of Inventory company ownership', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts'); const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/); assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/); assert.match(fieldFindings, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\)/);
assert.match(fieldFindings, /AS "insideScope"/);
assert.match(fieldFindings, /FROM area_company_relations relation/); assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/); assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/); assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
@@ -79,12 +80,15 @@ test('F6 field-created technical Inventory may be provisional only until the sel
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/); assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
}); });
test('F6 Android keeps search and reloads scoped to the current physical parent', () => { test('F6.1 Android roots search and reload at the Inspection Yacimiento with legacy Area fallback', () => {
const model = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt'); const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt'); const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
assert.match(model, /data class VisitDetail[\s\S]*val scopeAsset: AssetSummary\? = null/);
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/); assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/); assert.match(viewModel, /currentVisit\.scopeAsset\?\.id \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /visit\?\.scopeAsset\?\.id \?: visit\?\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/); assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/); assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
}); });
+90 -62
View File
@@ -1,96 +1,124 @@
# Modelo documental de inspecciones # Modelo documental de inspecciones
Este documento fija el modelo funcional validado a partir del Informe Técnico Este documento fija el modelo funcional vigente para Inspecciones, Actas,
Nº 124/2026, el acta manuscrita adjunta y la planilla de hallazgos entregada. Hallazgos e Informes. La estructura visual definitiva de los documentos todavía
puede cambiar cuando Hidrocarburos entregue las plantillas oficiales; el contrato
de datos, la trazabilidad y las reglas de inmutabilidad deben permanecer estables.
## Canales de operación ## Canales de operación
| Función | APK móvil | Dashboard web | | Función | APK móvil | Dashboard web |
|---|---:|---:| |---|---:|---:|
| Planificar visita, activos y equipo | Consulta | Administra | | Planificar Inspección, alcance y equipo | Consulta | Administra |
| Administrar activos y catálogo de hallazgos | Consulta | Administra | | Administrar Inventarios y catálogo de Hallazgos | Consulta | Administra |
| Iniciar y ejecutar la visita | Inspector | No | | Iniciar y ejecutar la Inspección | Inspector | No |
| Crear o modificar el acta y sus hallazgos | Inspector | Sólo consulta | | Crear Actas y registrar Hallazgos de campo | Inspector | Sólo consulta |
| Incorporar evidencia de campo y GPS | Inspector | Sólo consulta | | Incorporar evidencia de campo y GPS | Inspector | Sólo consulta |
| Identificar responsable, firmar y cerrar | Inspector | Sólo consulta | | Identificar responsable, firmar y sellar Acta | Inspector | Sólo consulta |
| Registrar respuesta empresarial, PDF y próximo control | Consulta | Administra | | Preparar y revisar el INF | Consulta | Administra |
| Consultar acta, hallazgos e informe sincronizados | Sí | Sí | | Registrar oficialización GEDO/IF y PDF oficial | Consulta | Administra |
| Registrar respuesta empresarial y seguimientos | Consulta | Administra |
| Consultar Actas, Hallazgos e Informes sincronizados | Sí | Sí |
La API aplica esta separación además de la interfaz: las escrituras operativas La API aplica esta separación además de la interfaz. Las escrituras operativas de
requieren autenticación bearer de la aplicación móvil y el rol `inspector`. campo se realizan desde la aplicación móvil con las autorizaciones correspondientes;
Una sesión web no puede eludir la restricción llamando directamente al endpoint. el dashboard administra planificación, consulta y circuito documental posterior.
## Cardinalidades ## Cardinalidades vigentes
| Origen | Relación | Destino | | Origen | Relación | Destino |
|---|---:|---| |---|---:|---|
| Inspección o visita | 1 a 1 | Acta de inspección | | Inspección o visita | 1 a muchos | Actas de inspección |
| Acta | 1 a muchos | Hallazgos | | Acta | 1 a muchos | Hallazgos |
| Hallazgo | muchos a 1 | Activo o equipo del Maestro | | Hallazgo | muchos a 1 | Elemento de Inventario |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable | | Hallazgo | muchos a 1 | Ítem del catálogo configurable, o propuesta OTROS |
| Hallazgo | 1 a muchos | Evidencias, principalmente fotografías | | Hallazgo | 1 a muchos | Evidencias y seguimientos |
| Acta | 1 a muchos | Firmas de inspectores | | Acta | 1 a muchos | Firmas de inspectores |
| Acta | 1 a 1 | Firma del responsable que atendió la visita | | Acta | 0 o 1 | Firma/manifestación del responsable de empresa |
| Inspección o visita | 1 a 1 | Informe técnico | | Acta sellada | 0 o 1 | Informe técnico (INF) |
| Informe técnico | incorpora | Acta, hallazgos y evidencias congelados | | Informe técnico | 1 a 1 | Acta fuente |
| Informe técnico | incorpora | Acta, Hallazgos, Inventarios y evidencias congelados |
Una Inspección puede, por lo tanto, contener varias Actas y generar varios INF,
pero cada INF conserva una única Acta fuente. No se mezcla el contenido de dos
Actas dentro de un mismo Informe.
## Acta digital ## Acta digital
El acta debe reunir como mínimo: El Acta debe poder reunir como mínimo:
- número anual oficial, fecha, hora y ubicación; - número y año oficial del Acta;
- empresa, área, yacimiento o instalación inspeccionada; - Inspección vinculada, fecha y hora;
- objeto y resultado de la inspección; - Departamento, Área, Yacimiento, Operadora y ubicación de la actuación;
- todos los hallazgos de la visita y sus plazos de corrección; - objeto, alcance y descripción de lo actuado;
- Instalaciones y Subinstalaciones inspeccionadas;
- todos los Hallazgos del Acta, con descripción, Inventario afectado, base legal,
reincidencia, gravedad y plazos que correspondan;
- inspectores intervinientes; - inspectores intervinientes;
- identidad, DNI, cargo y correo del responsable de la empresa que recibió al - identidad, DNI, cargo y correo del responsable de la empresa que recibió al
equipo; equipo, cuando corresponda;
- firmas manuscritas capturadas en pantalla de inspectores y responsable; - firma de inspectores y manifestación/firma del responsable de empresa, incluida
- versión inmutable, hash, sello de tiempo y token de verificación. conformidad, disidencia, negativa o ausencia según el flujo aplicable;
- versión inmutable, hash, sello de tiempo y mecanismo de verificación documental;
- referencias a fotografías/evidencias cuando el formulario oficial determine que
deben formar parte del documento.
Al finalizar la visita, el acta se cierra y ya no admite edición. Se genera el Al sellarse, el Acta queda inmutable. Los cambios posteriores pertenecen al
PDF verificable y se envía al correo del responsable de la empresa y a una seguimiento y no reescriben el contenido constatado. El sistema conserva la
dirección interna de Hidrocarburos definida por configuración. Cada intento y instantánea y hashes necesarios para que el PDF se pueda regenerar o sustituir su
resultado de entrega debe quedar auditado. renderer sin alterar la evidencia fuente.
Si se requiere corregir un acta cerrada, se emitirá una rectificativa vinculada
sin reemplazar ni borrar el documento original.
## Hallazgos ## Hallazgos
La planilla entregada contiene 170 modelos de hallazgo agrupados en 19 El catálogo de Hallazgos es administrable, importable y versionado. Cada Hallazgo
categorías de equipos, con descripción, normativa y glosario. Se implementará operativo conserva una instantánea de su descripción y base legal para que cambios
como catálogo administrable, importable y versionado. No se copiarán esas posteriores del catálogo no modifiquen documentos ya emitidos.
opciones como constantes en la interfaz.
Cada hallazgo operativo conservará una instantánea de su descripción y base Cada Hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y fecha
legal para que posteriores cambios del catálogo no alteren documentos ya comprometida por la empresa, la fecha de próximo control y las verificaciones
emitidos. También podrá registrar estado, observación específica, activo, posteriores. La respuesta empresarial no cierra por sí sola un Hallazgo: el cierre
ubicación/GPS, plazo y evidencias. surge del flujo de verificación autorizado.
Cada hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y ## Informe técnico (INF)
fecha comprometida por la empresa, y la fecha de próximo control fijada por el
organismo. La respuesta empresarial no cierra el hallazgo: sólo una verificación
posterior autorizada puede hacerlo.
## Informe técnico El INF nace desde una Acta sellada y tiene numeración propia. Mientras está en
preparación dispone de un Word editable para el desarrollo técnico; modificar ese
Word o los campos narrativos no modifica el Acta ni sus Hallazgos fuente.
El informe se genera después del cierre del acta, con numeración anual global El contrato proyectado del Informe contempla:
independiente. Siguiendo el modelo entregado, contempla:
- título, número, acta referenciada, empresa, área/yacimiento y autores; - número/año del INF y Acta referenciada;
- objetivos; - Inspección, Empresa, Departamento, Área, Yacimiento y autores;
- antecedentes; - objetivos y antecedentes;
- marco legal; - marco legal;
- resumen ejecutivo; - resumen ejecutivo;
- descripción de la inspección y de todos los hallazgos; - descripción y análisis técnico de la inspección;
- registro fotográfico; - Inventarios inspeccionados;
- conclusión; - detalle de los Hallazgos, reincidencias, gravedad y plazos;
- acta digital completa como anexo final. - registro fotográfico/evidencias seleccionadas;
- conclusiones y recomendaciones;
- trazabilidad e integridad de la fuente;
- Acta digital completa como anexo final.
El informe congela las versiones exactas del acta, hallazgos, activos, Cuando GEDO devuelve el identificador IF y su PDF oficial, ambos se registran de
evidencias y normativa que incorpora. forma inmutable junto con la fecha de oficialización y el hash del archivo. La
carga del IF/PDF oficial no se interpreta por sí sola como notificación a la
empresa ni inicia plazos que dependan de una notificación posterior.
La solicitud de creación del informe corresponde al flujo del inspector en la ## Plantillas PDF provisionales
APK después de cerrar la visita. El backend numera, compone y sella el PDF; el
dashboard sólo lo consulta, descarga y administra su seguimiento documental. Hasta recibir los formularios oficiales, la implementación se divide en dos capas:
1. **Contrato de datos estable.** Define qué información debe estar disponible para
Actas e Informes, de dónde proviene, qué versión se congela y qué hashes prueban
su integridad.
2. **Renderer reemplazable.** Encabezado, logos, tipografías, márgenes, cuadros,
orden visual, QR y anexos podrán adaptarse al modelo institucional definitivo
sin modificar el dominio ni volver a diseñar el circuito documental.
El dashboard muestra una sección **PDF preliminar · estructura proyectada** tanto
en la ficha del Acta como en la del Informe. Esa sección no pretende reemplazar el
formulario oficial: sirve para comprobar ahora que el sistema está capturando toda
la información que después deberá volcarse al documento.
La especificación detallada se mantiene en `docs/PDF_PROVISIONAL_STRUCTURE.md`.
+184
View File
@@ -0,0 +1,184 @@
# Estructura provisional de PDFs · PREVIEW-1
Estado: **provisional hasta recepción de formularios oficiales**.
El objetivo de este documento no es fijar todavía la estética institucional. Fija
el conjunto de datos que DH Inspección debe ser capaz de colocar en los PDFs de
Actas e Informes y separa ese contrato del renderer visual.
## Principio de implementación
El dominio documental no debe depender de coordenadas, cuadros o textos impresos
de una plantilla determinada. Los datos se congelan primero en snapshots
versionados; luego un renderer transforma esa fuente a PDF. Cuando llegue el
formulario oficial se reemplaza o adapta el renderer, no la lógica de negocio.
Se consideran **estables**: identidad del documento, vínculos entre entidades,
contenido constatado, Hallazgos, firmas/manifestaciones, fechas, versiones, hashes
y trazabilidad. Se consideran **reemplazables**: logo, membrete, encabezado,
footer, tipografía, colores, tamaños, posiciones, cantidad de columnas, QR y forma
de los anexos.
## PDF de Acta
### 1. Cabecera institucional
Pendiente de modelo oficial. Debe reservar espacio para organismo, dependencia,
logo/membrete, denominación del documento y numeración.
### 2. Identificación
- número y año del Acta;
- código interno;
- código de Inspección/visita;
- fecha y hora de actuación;
- estado documental y versión.
### 3. Contexto territorial y operativo
- Departamento;
- Área;
- Yacimiento;
- Empresa/Operadora vigente;
- ubicación/GPS cuando corresponda;
- Instalación y Subinstalación relacionadas cuando el Acta tenga ese alcance.
### 4. Personas intervinientes
- Inspectores actuantes;
- responsable de la empresa;
- DNI/identificación;
- cargo o función;
- correo/dato de contacto que exija el formulario.
### 5. Objeto y descripción de lo actuado
- objeto o motivo;
- resumen/constatación;
- observaciones;
- Inventarios inspeccionados con código, nombre, tipo y ruta jerárquica.
### 6. Hallazgos
Por cada Hallazgo:
- código;
- Inventario asociado;
- ítem de catálogo u origen OTROS;
- título/denominación;
- descripción constatada;
- base legal congelada;
- reincidencia y referencia al antecedente, si existe;
- gravedad cuando aplique;
- plazo y tipo de días cuando corresponda;
- referencias a evidencia.
### 7. Firmas y manifestaciones
- firma de cada Inspector interviniente;
- firma/manifestación del responsable de empresa;
- conformidad o disidencia;
- texto de disidencia;
- negativa o ausencia con motivo cuando aplique;
- fecha/hora de cada constancia.
### 8. Integridad
- hash del contenido bloqueado;
- hash final del Acta sellada;
- sello de tiempo;
- versión del esquema;
- espacio para token/QR de verificación si el modelo institucional lo utiliza.
### 9. Anexo de evidencias
El sistema debe estar preparado para incorporar fotografías/evidencias, pero su
inclusión dentro del PDF principal queda pendiente de la plantilla oficial.
## Informe técnico · INF
Cada INF tiene **una sola Acta fuente**. Una Inspección puede contener múltiples
Actas y, por lo tanto, múltiples INF independientes.
### 1. Cabecera e identificación
- número/año y código del INF;
- Acta fuente;
- Inspección;
- fecha de generación;
- autores/Inspector responsable;
- Empresa/Operadora;
- Departamento, Área y Yacimiento.
### 2. Objetivos
Texto editable durante la preparación, si la plantilla oficial lo requiere como
campo separado.
### 3. Antecedentes
Antecedentes relevantes para comprender la inspección y el contexto técnico.
### 4. Marco legal
Normativa aplicable y bases legales congeladas con los Hallazgos del Acta fuente.
### 5. Resumen ejecutivo
Campo narrativo editable en estado de preparación.
### 6. Descripción y análisis técnico
Desarrollo técnico editable que no modifica el contenido del Acta ni de sus
Hallazgos.
### 7. Inventarios inspeccionados
Tabla o listado con código, nombre, tipo y ruta territorial/técnica.
### 8. Hallazgos
Detalle de los Hallazgos del Acta fuente, con descripción, base legal,
reincidencia, gravedad, plazos y vínculo con el Inventario.
### 9. Registro fotográfico
Fotografías/evidencias seleccionadas, identificando Hallazgo/Inventario, fecha y
metadatos disponibles. La maquetación se definirá con el modelo oficial.
### 10. Conclusiones y recomendaciones
Bloque técnico editable durante la preparación del INF.
### 11. GEDO
Al oficializar:
- identificador IF;
- fecha/hora de oficialización;
- nombre del PDF oficial;
- SHA-256 del PDF oficial.
La oficialización no equivale automáticamente a notificación a la empresa.
### 12. Integridad y anexo
- hash del snapshot fuente del INF;
- hash del cierre del Acta;
- versión de Acta utilizada;
- Acta fuente completa como anexo final.
## Reemplazo por la plantilla oficial
Cuando se reciba el Acta o Informe oficial en PDF/Word se deberá:
1. inventariar cada campo del formulario;
2. mapearlo contra este contrato;
3. identificar únicamente los datos faltantes;
4. añadir esos datos al dominio sólo si son información real y no mero diseño;
5. construir el renderer definitivo;
6. comparar visualmente una muestra contra el original;
7. conservar el renderer PREVIEW-1 sólo para pruebas históricas si resultara útil.
No se deben alterar Actas ya selladas para acomodar una nueva plantilla. Los
nuevos renderers deben poder representar snapshots históricos de forma trazable.
+128
View File
@@ -0,0 +1,128 @@
# DH Inspección V2 · corte de presentación F6.1
Este documento define el alcance verificable del corte F6.1 y el recorrido recomendado para una presentación funcional. No reemplaza al Manual de Usuario ni al Manual del Programador.
## Versiones del corte
- API: `0.29.0-1`.
- WEB: `0.23.0-1`.
- Android: `0.15.0` (`versionCode 22`).
- Modelo funcional: **F6.1 · contexto operativo ÁreaOperadora consolidado**.
La versión visible en el footer WEB debe coincidir con `web-v2/package.json`; este contrato queda protegido por tests.
## Matriz de aceptación funcional
### Inventario
- Jerarquía física vigente: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa permanece fuera del árbol físico.
- La Operadora se resuelve por relación temporal vigente con el Área.
- Instalación y Subinstalación admiten familia **Otro / no catalogado** cuando corresponda.
- Un alta de campo conserva inspección, actor, fecha, contexto, GPS y evidencia exigida por el flujo.
> El issue histórico F3.1 describía un árbol que comenzaba en Área. Esa definición fue supersedida por F5.1/F6, que incorporó Departamento como raíz territorial autorizada.
### Merge cronológico de Inventario
- Sólo se fusionan Instalaciones/Subinstalaciones compatibles.
- El registro duplicado no se elimina: queda fusionado/inactivo y conserva identidad histórica.
- Las referencias históricas emitidas no se reescriben.
- El dossier canónico agrega cronológicamente alias y eventos, mostrando el Inventario original cuando corresponde.
- WEB muestra el destino canónico.
- Android, al conciliar un alta nacida en campo, selecciona inmediatamente el canónico y confirma qué código se conserva.
### Hallazgos y catálogo
- `OTROS` permanece disponible en el resolver de Hallazgos.
- La fusión de catálogo mueve aplicabilidad futura al canónico sin reescribir Hallazgos ya emitidos.
- Las instancias históricas conservan el modelo append-only y la trazabilidad de auditoría.
### Inspecciones y Android
- Una inspección planificada puede iniciarse desde Android por un Inspector autorizado y asignado.
- El inicio registra `actualStartedAt`, actor y auditoría del servidor.
- El contexto operativo es Área + Operadora vigente.
- Una Inspección puede tener múltiples Actas, con un único borrador simultáneo.
- El cierre exige Actas selladas y verificaciones requeridas completas.
### Perfil del Inspector
- Nombre y apellido obligatorios.
- Datos administrativos disponibles: DNI, email, teléfono, cargo/función y legajo/matrícula.
- El email es obligatorio para rol Inspector.
### Entrega documental
- El Acta PDF se prepara para Empresa, Oficina e Inspector principal.
- El INF Word se prepara también para el Inspector principal cuando existe.
- El outbox conserva destinatario, estado, intentos, error y fecha de envío; los pendientes son reintentables.
- La entrega depende de SMTP y destinatarios configurados: si faltan, el registro queda en un estado de espera auditable en lugar de perderse.
### Relevamientos
- `Relevamientos` no existe como módulo/ruta/entrada de menú activa.
- La captura en campo vive dentro de Inspecciones + Inventario de campo.
## Seguridad de dependencias del corte
La candidata mantiene una barrera separada para dependencias productivas de API y WEB mediante `npm audit --omit=dev --audit-level=high`.
Para F6.1 se fijaron las resoluciones parcheadas que eliminan los advisories detectados durante el cierre:
- API: `multer 2.3.0` y `qs 6.16.0`.
- WEB: `maplibre-gl 6.4.1`.
Las dependencias de runtime con vulnerabilidades altas o críticas bloquean el corte aunque typecheck, tests y builds estén verdes.
## Barrera técnica obligatoria
Un SHA sólo es candidato de presentación cuando pasan:
1. API typecheck, tests y build.
2. WEB typecheck y build.
3. auditoría de dependencias productivas de API y WEB, sin advisories altos/críticos.
4. migraciones sobre PostGIS limpio.
5. arranque real de API.
6. preflight aislado equivalente al entorno Docker.
7. build de imágenes productivas.
8. Android lint.
9. Android unit tests reales.
10. Android `assembleDebug`.
11. Android `assembleRelease`.
12. APK debug con SHA-256 y metadata del SHA exacto.
## Recorrido sugerido de demo
1. **Login WEB** y Dashboard.
2. **Inventarios**: navegar Departamento → Área → Yacimiento → Instalación → Subinstalación y abrir un dossier.
3. Mostrar la **cronología/dossier**, documentos, fotos y Hallazgos.
4. Mostrar, si existe un caso preparado, una **fusión cronológica** y el aviso de registro canónico.
5. **Usuarios**: abrir un Inspector y mostrar perfil, email documental y roles.
6. **Inspecciones**: crear/abrir una planificación con Área + Operadora, Inspector y checklist.
7. **Android**: ingresar con Inspector, abrir o seleccionar la Inspección e iniciarla.
8. En Android, abrir **Inventario de campo**, seleccionar un registro o crear uno nuevo con GPS y foto. Para una familia no catalogada, usar **Otro / no catalogado**.
9. Crear un **Acta**, registrar un **Hallazgo** (incluyendo `OTROS` si se quiere demostrar el fallback), adjuntar evidencia y sellar el Acta.
10. Cerrar la Inspección cuando todas las Actas estén selladas.
11. En WEB, mostrar **Actas / Informes / Entrega documental** y el estado auditable de los envíos.
## Preparación del entorno de presentación
Antes de una demo con envío real de correo, verificar en Administración:
- email institucional de Oficina;
- email de la Empresa involucrada;
- email del Inspector principal;
- SMTP configurado y operativo.
Para una demo sin correo saliente, el resto del flujo puede demostrarse y la bandeja de entrega debe reflejar el estado de espera correspondiente; no se debe presentar un envío como exitoso si el transporte no está configurado.
## Evidencia del corte
El SHA presentado debe conservarse junto con:
- resultado verde de GitHub Actions;
- auditoría productiva verde;
- metadata de versiones;
- APK debug de la candidata y su SHA-256;
- si se distribuye APK productiva, firma histórica verificada y SHA-256 del artefacto firmado.
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo 'ERROR: expected historical one-shot reset guard on clean database.' >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo 'ERROR: migration chain failed before the expected historical reset guard.' >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
INSERT INTO typeorm_migrations ("timestamp", name)
SELECT 1788652800000, 'ResetProductionOperationalData1788652800000'
WHERE NOT EXISTS (
SELECT 1 FROM typeorm_migrations
WHERE name='ResetProductionOperationalData1788652800000'
);
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
# Prove the presentation preload is a physical tree, not a flattened list.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE invalid_areas integer; invalid_yacimientos integer;
BEGIN
SELECT COUNT(*) INTO invalid_areas
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
LEFT JOIN assets department ON department.id=area.parent_id
LEFT JOIN asset_types department_type ON department_type.id=department.asset_type_id
WHERE lower(type.code)='area'
AND (department.id IS NULL OR lower(department_type.code)<>'departamento');
IF invalid_areas <> 0 THEN
RAISE EXCEPTION 'Invalid Area→Departamento relations: %', invalid_areas;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
LEFT JOIN assets area ON area.id=yacimiento.parent_id
LEFT JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE lower(type.code)='yacimiento'
AND (area.id IS NULL OR lower(area_type.code)<>'area'
OR yacimiento.operational_area_id IS DISTINCT FROM area.id);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Invalid Yacimiento→Area relations: %', invalid_yacimientos;
END IF;
END $$;
SQL
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
# Disposable CI identities. The real Inspector invariant requires an email.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
INSERT INTO users(id,username,email,password_hash,first_name,last_name,status,must_change_password)
VALUES
('11111111-1111-4111-8111-111111111111','ci_planner','ci-planner@example.invalid','ci-unused','CI','Planner','ACTIVE',false),
('22222222-2222-4222-8222-222222222222','ci_inspector','ci-inspector@example.invalid','ci-unused','CI','Inspector','ACTIVE',false);
INSERT INTO user_roles(user_id,role_id,assigned_by)
SELECT '11111111-1111-4111-8111-111111111111'::uuid, role.id,
'11111111-1111-4111-8111-111111111111'::uuid
FROM roles role
WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.manage'
)
AND EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.assign'
)
ORDER BY role.is_system DESC,role.code
LIMIT 1;
INSERT INTO user_roles(user_id,role_id,assigned_by)
SELECT '22222222-2222-4222-8222-222222222222'::uuid, role.id,
'11111111-1111-4111-8111-111111111111'::uuid
FROM roles role
WHERE EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
WHERE rp.role_id=role.id AND p.code='inspections.execute'
)
ORDER BY (lower(role.code)='inspector') DESC,role.is_system DESC,role.code
LIMIT 1;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='11111111-1111-4111-8111-111111111111'::uuid) THEN
RAISE EXCEPTION 'CI planner role with manage+assign permissions not found';
END IF;
IF NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id='22222222-2222-4222-8222-222222222222'::uuid) THEN
RAISE EXCEPTION 'CI inspector role with execute permission not found';
END IF;
END $$;
INSERT INTO auth_sessions(
id,user_id,refresh_token_hash,expires_at,last_used_at,revoked_at,replaced_by_session_id,ip,user_agent,device_label
) VALUES (
'33333333-3333-4333-8333-333333333333',
'11111111-1111-4111-8111-111111111111','ci-unused',
CURRENT_TIMESTAMP + INTERVAL '10 minutes',CURRENT_TIMESTAMP,NULL,NULL,NULL,'CI','inspection-planning-smoke'
);
SQL
docker compose --env-file .env.example up -d api
for _ in $(seq 1 30); do
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null 2>&1 && break
sleep 2
done
curl -fsS http://127.0.0.1:3101/api/v3/health >/dev/null
TOKEN="$(docker compose --env-file .env.example exec -T api node - <<'NODE'
const jwt = require('jsonwebtoken');
process.stdout.write(jwt.sign({
sub:'11111111-1111-4111-8111-111111111111',
sid:'33333333-3333-4333-8333-333333333333',
username:'ci_planner',typ:'access'
}, process.env.JWT_ACCESS_SECRET, {
algorithm:'HS256',expiresIn:300,issuer:'dhv2-api',audience:'dhv2'
}));
NODE
)"
CONTEXT="$(docker compose --env-file .env.example exec -T db \
psql -At -F '|' -U dhv2_owner -d dhv2 -c "
SELECT department.id,area.id,yacimiento.id,relation.company_id
FROM assets department
JOIN asset_types department_type ON department_type.id=department.asset_type_id
JOIN assets area ON area.parent_id=department.id
JOIN asset_types area_type ON area_type.id=area.asset_type_id
JOIN assets yacimiento ON yacimiento.parent_id=area.id
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
JOIN area_company_relations relation ON relation.area_id=area.id
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
WHERE lower(department_type.code)='departamento'
AND lower(area_type.code)='area'
AND lower(yacimiento_type.code)='yacimiento'
ORDER BY department.name,area.name,yacimiento.name
LIMIT 1;
")"
IFS='|' read -r DEPARTMENT_ID AREA_ID YACIMIENTO_ID COMPANY_ID <<< "$CONTEXT"
test -n "$DEPARTMENT_ID" && test -n "$AREA_ID" && test -n "$YACIMIENTO_ID" && test -n "$COMPANY_ID"
curl -fsS -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments \
>/tmp/dhv2-departments.json
grep -Fq "$DEPARTMENT_ID" /tmp/dhv2-departments.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/departments/$DEPARTMENT_ID/areas" \
>/tmp/dhv2-areas.json
grep -Fq "$AREA_ID" /tmp/dhv2-areas.json
curl -fsS -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:3101/api/v3/inspection-visits/planning-context/areas/$AREA_ID/yacimientos" \
>/tmp/dhv2-yacimientos.json
grep -Fq "$YACIMIENTO_ID" /tmp/dhv2-yacimientos.json
START_AT="$(date -u -d '+1 day' +'%Y-%m-%dT%H:%M:%S.000Z')"
HTTP_CODE="$(curl -sS -o /tmp/dhv2-created.json -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"operationalAreaId\":\"$AREA_ID\",\"scopeAssetId\":\"$YACIMIENTO_ID\",\"operatorCompanyId\":\"$COMPANY_ID\",\"plannedStartAt\":\"$START_AT\",\"leadInspectorUserId\":\"22222222-2222-4222-8222-222222222222\"}" \
http://127.0.0.1:3101/api/v3/inspection-visits)"
if [ "$HTTP_CODE" != 201 ]; then
echo "ERROR: real inspection create returned HTTP $HTTP_CODE" >&2
cat /tmp/dhv2-created.json >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
VISIT_ID="$(python - <<'PY'
import json
with open('/tmp/dhv2-created.json',encoding='utf-8') as f:
print(json.load(f)['id'])
PY
)"
VERIFY="$(docker compose --env-file .env.example exec -T db \
psql -At -U dhv2_owner -d dhv2 -c "
SELECT COUNT(*)
FROM inspection_visits visit
JOIN assets yacimiento ON yacimiento.id=visit.scope_asset_id
JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
WHERE visit.id='$VISIT_ID'::uuid
AND visit.operational_area_id='$AREA_ID'::uuid
AND visit.scope_asset_id='$YACIMIENTO_ID'::uuid
AND visit.operator_company_id='$COMPANY_ID'::uuid
AND yacimiento.parent_id=visit.operational_area_id
AND lower(yacimiento_type.code)='yacimiento';
")"
if [ "$VERIFY" != '1' ]; then
echo 'ERROR: created Inspection did not preserve Area/Yacimiento hierarchy.' >&2
cat /tmp/dhv2-created.json >&2
exit 1
fi
echo "F6.1 real inspection create smoke: OK ($VISIT_ID)"
+6 -6
View File
@@ -1,14 +1,14 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.20.0-2", "version": "0.23.0-1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.20.0-2", "version": "0.23.0-1",
"dependencies": { "dependencies": {
"maplibre-gl": "^6.0.0", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^8.0.0" "react-router": "^8.0.0"
@@ -1633,9 +1633,9 @@
} }
}, },
"node_modules/maplibre-gl": { "node_modules/maplibre-gl": {
"version": "6.3.0", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz",
"integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==", "integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@mapbox/point-geometry": "^1.1.0", "@mapbox/point-geometry": "^1.1.0",
+2 -2
View File
@@ -12,7 +12,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"maplibre-gl": "^6.0.0", "maplibre-gl": "6.4.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^8.0.0" "react-router": "^8.0.0"
@@ -24,4 +24,4 @@
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^7.0.0" "vite": "^7.0.0"
} }
} }
+4 -3
View File
@@ -21,7 +21,8 @@ import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage'; import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage'; import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage'; import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
import { InspectionVisitEditorF4Page } from '../pages/InspectionVisitEditorF4Page'; import { InspectionVisitCreateF61Page } from '../pages/InspectionVisitCreateF61Page';
import { InspectionVisitDetailF61Page } from '../pages/InspectionVisitDetailF61Page';
import { InspectionActEditorPage } from '../pages/InspectionActEditorPage'; import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
import { FindingCatalogPage } from '../pages/FindingCatalogPage'; import { FindingCatalogPage } from '../pages/FindingCatalogPage';
import { FindingDetailPage } from '../pages/FindingDetailPage'; import { FindingDetailPage } from '../pages/FindingDetailPage';
@@ -57,10 +58,10 @@ export function App() {
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} /> <Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
<Route element={<PermissionRoute permission="inspections.read" />}> <Route element={<PermissionRoute permission="inspections.read" />}>
<Route path="/inspecciones" element={<InspectionVisitsPage />} /> <Route path="/inspecciones" element={<InspectionVisitsPage />} />
<Route path="/inspecciones/:id" element={<InspectionVisitEditorF4Page />} /> <Route path="/inspecciones/:id" element={<InspectionVisitDetailF61Page />} />
<Route path="/preparacion-campo" element={<FieldBriefingsPage />} /> <Route path="/preparacion-campo" element={<FieldBriefingsPage />} />
</Route> </Route>
<Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitEditorF4Page />} /></Route> <Route element={<PermissionRoute permission="inspections.manage" />}><Route path="/inspecciones/nueva" element={<InspectionVisitCreateF61Page />} /></Route>
<Route element={<PermissionRoute permission="inspection_acts.read" />}> <Route element={<PermissionRoute permission="inspection_acts.read" />}>
<Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} /> <Route path="/inspecciones/actas/:actId" element={<InspectionActEditorPage />} />
<Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} /> <Route path="/inspecciones/actas/nueva" element={<InspectionActEditorPage />} />
@@ -0,0 +1,109 @@
import { Icon } from './Icon';
type ProjectionKind = 'act' | 'report';
interface ProjectionSection {
title: string;
description: string;
}
const actSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año de Acta, Inspección vinculada, fecha y hora, Departamento, Área, Yacimiento, Operadora y ubicación de la actuación.',
},
{
title: 'Intervinientes',
description: 'Inspectores actuantes y responsable de la empresa: nombre, DNI, cargo, correo y demás datos de identificación que exija el modelo oficial.',
},
{
title: 'Alcance y constatación',
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
},
{
title: 'Hallazgos',
description: 'Código, Inventario afectado, descripción, base legal, reincidencia, gravedad y plazos aplicables, conservando la instantánea emitida en campo.',
},
{
title: 'Firmas y manifestaciones',
description: 'Firmas de inspectores y responsable de empresa, conformidad o disidencia, manifestación textual, negativa/ausencia si correspondiera y marcas de tiempo.',
},
{
title: 'Integridad y verificación',
description: 'Versión sellada, hash SHA-256, sello de tiempo y espacio reservado para token o QR de verificación documental.',
},
{
title: 'Anexos',
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
},
];
const reportSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año del INF, Acta fuente, Inspección, Empresa, Departamento, Área, Yacimiento, fechas y autores/intervinientes.',
},
{
title: 'Objetivos y antecedentes',
description: 'Motivo de la inspección, antecedentes relevantes y alcance técnico del Informe.',
},
{
title: 'Marco legal',
description: 'Normativa y bases legales aplicables, incluyendo las referencias congeladas de los Hallazgos del Acta fuente.',
},
{
title: 'Resumen ejecutivo y desarrollo técnico',
description: 'Resumen ejecutivo, descripción de la inspección, análisis técnico y consideraciones editables durante la preparación del INF.',
},
{
title: 'Inventarios y Hallazgos',
description: 'Inventarios inspeccionados y detalle de Hallazgos con descripción, reincidencia, gravedad, plazos y trazabilidad con el Acta.',
},
{
title: 'Registro fotográfico',
description: 'Fotografías y evidencias seleccionadas, con referencia al Inventario/Hallazgo, fecha y metadatos disponibles.',
},
{
title: 'Conclusiones',
description: 'Conclusión técnica, recomendaciones y texto final sujeto a revisión antes de la oficialización.',
},
{
title: 'GEDO e integridad',
description: 'Identificador IF, fecha de oficialización, hash del PDF oficial y hash de la instantánea congelada que originó el Informe.',
},
{
title: 'Anexo final',
description: 'Acta fuente completa e inmutable como anexo del Informe, manteniendo la relación exacta INF → Acta.',
},
];
export function DocumentPdfProjection({ kind }: { kind: ProjectionKind }) {
const isAct = kind === 'act';
const sections = isAct ? actSections : reportSections;
return <section className="panel document-pdf-projection">
<div className="panel-heading">
<div>
<span className="eyebrow">PDF PRELIMINAR · ESTRUCTURA PROYECTADA</span>
<h2>{isAct ? 'Contenido previsto del Acta' : 'Contenido previsto del Informe'}</h2>
<p className="section-copy">El diseño institucional definitivo todavía no fue recibido. Esta vista fija qué información debe poder alimentar el documento, sin comprometer encabezados, tipografías, logos ni distribución final.</p>
</div>
<span className="status-badge pending">Plantilla oficial pendiente</span>
</div>
<div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Contrato de datos estable, renderer reemplazable.</strong> Cuando llegue el PDF/Word oficial se adapta la composición visual sobre estos mismos datos, sin rehacer el circuito de Inspecciones, Actas, Hallazgos ni trazabilidad.</p>
</div>
<div className="dossier-link-list">
{sections.map((section, index) => <div key={section.title}>
<div>
<strong>{index + 1}. {section.title}</strong>
<small>{section.description}</small>
</div>
<span>Previsto</span>
</div>)}
</div>
</section>;
}
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.21.0-1'; export const APP_VERSION = '0.23.0-1';
export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado'; export const APP_PHASE = 'F6.1 · Contexto operativo ÁreaOperadora consolidado';
+7 -20
View File
@@ -10,31 +10,18 @@ interface OperationalContextValue {
clearContext: () => void; clearContext: () => void;
} }
const STORAGE_KEY = 'dhv2.operational-context'; const LEGACY_STORAGE_KEY = 'dhv2.operational-context';
const OperationalContext = createContext<OperationalContextValue | null>(null); const OperationalContext = createContext<OperationalContextValue | null>(null);
function readStoredContext(): { areaId: string; companyId: string } {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { areaId: '', companyId: '' };
const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown };
return {
areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '',
companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '',
};
} catch {
return { areaId: '', companyId: '' };
}
}
export function OperationalContextProvider({ children }: PropsWithChildren) { export function OperationalContextProvider({ children }: PropsWithChildren) {
const initial = useMemo(readStoredContext, []); const [areaId, setAreaState] = useState('');
const [areaId, setAreaState] = useState(initial.areaId); const [companyId, setCompanyState] = useState('');
const [companyId, setCompanyState] = useState(initial.companyId);
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId })); // F6.2 retiró la barra global Área/Operadora. Un contexto persistido sin
}, [areaId, companyId]); // controles visibles podía seguir filtrando pantallas de forma inesperada.
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
}, []);
const value = useMemo<OperationalContextValue>(() => ({ const value = useMemo<OperationalContextValue>(() => ({
areaId, areaId,
@@ -4,7 +4,7 @@ import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api'; import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api'; import type { AssetLineageItem, AssetType } from '../../lib/api';
import { import {
listInventoryCompanies, listInventoryCompanies,
listInventoryDepartments, listInventoryDepartments,
@@ -18,16 +18,61 @@ import type {
} from '../../lib/inventoryBrowserApi'; } from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation'; import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
const structuredInventorySections = [
{
code: 'empresa',
label: 'Empresas',
description: 'Maestro de empresas operadoras. Se vinculan a una o más Áreas sin formar parte del árbol territorial.',
icon: 'users' as const,
},
{
code: 'departamento',
label: 'Departamentos',
description: 'Primer nivel territorial. Desde cada Departamento se accede a sus Áreas.',
icon: 'map' as const,
},
{
code: 'area',
label: 'Áreas',
description: 'Cada Área pertenece a un Departamento y puede tener una o más Empresas operadoras vinculadas.',
icon: 'map' as const,
},
{
code: 'yacimiento',
label: 'Yacimientos',
description: 'Nivel operativo dentro de un Área. Organiza las Instalaciones que pertenecen a ese Yacimiento.',
icon: 'map' as const,
},
{
code: 'instalacion',
label: 'Instalaciones',
description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.',
icon: 'layers' as const,
},
{
code: 'subinstalacion',
label: 'Subinstalaciones',
description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.',
icon: 'layers' as const,
},
] as const;
function navigationHref(base: URLSearchParams, parentId?: string) { function navigationHref(base: URLSearchParams, parentId?: string) {
const params = new URLSearchParams(base); const params = new URLSearchParams(base);
params.delete('view'); params.delete('view');
params.delete('page'); params.delete('page');
params.delete('section'); params.delete('section');
params.delete('companyId'); params.delete('companyId');
params.delete('typeId');
parentId ? params.set('parentId', parentId) : params.delete('parentId'); parentId ? params.set('parentId', parentId) : params.delete('parentId');
return `/inventarios${params.size ? `?${params}` : ''}`; return `/inventarios${params.size ? `?${params}` : ''}`;
} }
function inventoryTypeForSection(types: AssetType[], code: string) {
if (code === 'empresa') return types.find((type) => type.operationalRole === 'COMPANY') ?? null;
return types.find((type) => type.code.toLowerCase() === code) ?? null;
}
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) { function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
return <Link className="asset-browser-item" to={href}> return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span> <span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
@@ -80,7 +125,7 @@ function nextLevelLabel(typeCode: string | undefined) {
} }
} }
export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) { export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery; types: AssetType[] }) {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -123,24 +168,48 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
return <div className="asset-browser-panel"> return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"> <div className="asset-browser-section-heading">
<div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div> <div>
<span className="eyebrow">INVENTARIOS ESTRUCTURADOS</span>
<h2>Elegí qué querés administrar</h2>
<p>La estructura queda fija y fácil de leer: Empresas como maestro de operadoras y, por separado, Departamento Área Yacimiento Instalación Subinstalación.</p>
</div>
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div> <div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
</div> </div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de Inventarios"> <div className="asset-browser-list" aria-label="Secciones estructuradas de Inventarios">
{structuredInventorySections.map((section, index) => {
const assetType = inventoryTypeForSection(types, section.code);
const content = <>
<span className="asset-browser-item-icon"><Icon name={section.icon} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{section.label}</strong>
<small>{section.description}</small>
</span>
<span className="asset-browser-item-status"><small>{section.code === 'empresa' ? 'Maestro independiente' : `Nivel ${index}`}</small></span>
{assetType && <Icon name="chevron" size={16} />}
</>;
return assetType
? <Link key={section.code} className="asset-browser-item" to={`/inventarios?view=list&typeId=${assetType.id}`}>{content}</Link>
: <div key={section.code} className="asset-browser-item" aria-disabled="true">{content}</div>;
})}
</div>
<div className="asset-browser-levels" aria-label="Jerarquía territorial de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i> <div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i> <div><span>2</span><strong>Área</strong><small>operadoras vinculadas</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i> <div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i> <div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div> <div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div> </div>
<section className="asset-browser-group" style={{ marginTop: 20 }}> <section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div> <div className="asset-browser-group-heading">
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>} <div><h3>Recorrido por territorio</h3><p>También podés navegar como árbol: entrá por un Departamento y descendé hasta la Instalación o Subinstalación concreta.</p></div>
<span>{departments.length}</span>
</div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
</section> </section>
</div>; </div>;
} }
-3
View File
@@ -3,7 +3,6 @@ import { NavLink, Outlet, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { APP_PHASE, APP_VERSION } from '../config/version'; import { APP_PHASE, APP_VERSION } from '../config/version';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { OperationalContextBar } from '../components/OperationalContextBar';
import type { IconName } from '../components/Icon'; import type { IconName } from '../components/Icon';
interface NavItem { interface NavItem {
@@ -16,7 +15,6 @@ interface NavItem {
const operational: NavItem[] = [ const operational: NavItem[] = [
{ to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' }, { to: '/', label: 'Inicio', icon: 'home', permission: 'dashboard.read' },
{ to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' }, { to: '/inspecciones', label: 'Inspecciones', icon: 'clipboard', permission: 'inspections.read' },
{ to: '/preparacion-campo', label: 'Preparación de campo', icon: 'clipboard', permission: 'inspections.read' },
]; ];
const followUp: NavItem[] = [ const followUp: NavItem[] = [
@@ -119,7 +117,6 @@ export function AppLayout() {
<strong>DH Inspección</strong> <strong>DH Inspección</strong>
<span className="mobile-version">v{APP_VERSION}</span> <span className="mobile-version">v{APP_VERSION}</span>
</header> </header>
<OperationalContextBar />
<main className="content"><Outlet /></main> <main className="content"><Outlet /></main>
<footer className="app-footer" aria-label="Versión de la aplicación"> <footer className="app-footer" aria-label="Versión de la aplicación">
<span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span> <span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span>
+51 -8
View File
@@ -33,6 +33,40 @@ const quickViews = [
{ key: 'out', label: 'Fuera de servicio' }, { key: 'out', label: 'Fuera de servicio' },
] as const; ] as const;
const structuredTypeCopy: Record<string, { title: string; description: string }> = {
departamento: {
title: 'Departamentos',
description: 'Raíz territorial del Inventario. Cada Departamento contiene sus Áreas.',
},
area: {
title: 'Áreas',
description: 'Áreas dentro de un Departamento, con sus Empresas operadoras vinculadas.',
},
yacimiento: {
title: 'Yacimientos',
description: 'Yacimientos pertenecientes a un Área y utilizados para organizar sus Instalaciones.',
},
instalacion: {
title: 'Instalaciones',
description: 'Instalaciones dentro de un Yacimiento, con clasificación técnica y contexto operativo.',
},
subinstalacion: {
title: 'Subinstalaciones',
description: 'Subinstalaciones dependientes de una Instalación, último nivel de la estructura.',
},
};
function structuredPresentation(type: AssetType | undefined) {
if (!type) return null;
if (type.operationalRole === 'COMPANY') {
return {
title: 'Empresas',
description: 'Maestro independiente de Empresas operadoras. Se vinculan a una o más Áreas.',
};
}
return structuredTypeCopy[type.code.toLowerCase()] ?? null;
}
export function AssetsPage() { export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams(); const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext(); const operationalContext = useOperationalContext();
@@ -58,6 +92,8 @@ export function AssetsPage() {
const needsValidation = quick === 'validation' ? true : undefined; const needsValidation = quick === 'validation' ? true : undefined;
const hasGeometry = quick === 'location' ? false : undefined; const hasGeometry = quick === 'location' ? false : undefined;
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus; const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
const selectedType = types.find((type) => type.id === typeId);
const selectedPresentation = structuredPresentation(selectedType);
useEffect(() => { useEffect(() => {
listAssetTypes().then((data) => setTypes(data.filter((type) => listAssetTypes().then((data) => setTypes(data.filter((type) =>
@@ -102,15 +138,22 @@ export function AssetsPage() {
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
}; };
const advancedActive = Boolean(typeId || status || rawOperationalStatus); const advancedActive = Boolean(typeId || status || rawOperationalStatus);
const sectionTitle = view === 'list' && selectedPresentation ? selectedPresentation.title : 'Inventarios';
const sectionDescription = view === 'list' && selectedPresentation
? selectedPresentation.description
: 'Empresas y estructura territorial: Departamento → Área → Yacimiento → Instalación → Subinstalación.';
return <section> return <section>
<div className="page-heading asset-center-heading"> <div className="page-heading asset-center-heading">
<div> <div>
<span className="eyebrow">INVENTARIOS</span> <span className="eyebrow">{selectedPresentation && view === 'list' ? 'INVENTARIOS · SECCIÓN' : 'INVENTARIOS'}</span>
<h1>Inventarios</h1> <h1>{sectionTitle}</h1>
<p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p> <p>{sectionDescription}</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{selectedPresentation && view === 'list' && <Link className="button secondary" to="/inventarios"><Icon name="layers" />Ver estructura</Link>}
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div> </div>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div> </div>
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} /> <AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
@@ -126,7 +169,7 @@ export function AssetsPage() {
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>} {view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div> </div>
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel"> {view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Sección</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todas las secciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.operationalRole === 'COMPANY' ? 'Empresas' : structuredTypeCopy[type.code.toLowerCase()]?.title ?? type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button> <button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -135,14 +178,14 @@ export function AssetsPage() {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{view === 'hierarchy' {view === 'hierarchy'
? <AssetHierarchyView filters={filters} /> ? <AssetHierarchyView filters={filters} types={types} />
: loading : loading
? <LoadingBlock label="Cargando Inventarios…" /> ? <LoadingBlock label="Cargando Inventarios…" />
: assets.length === 0 : assets.length === 0
? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." /> ? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
: <div className="table-panel compact-assets-table"> : <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div> <div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody> <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}> {assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td> <td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td> <td><span className="tag">{asset.type.name}</span></td>
+22 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { Alert, EmptyState, LoadingBlock } from '../components/Feedback'; import { Alert, EmptyState, LoadingBlock } from '../components/Feedback';
import { formatDate, formatDateOnly } from '../lib/format'; import { formatDate, formatDateOnly } from '../lib/format';
@@ -17,6 +17,7 @@ interface VisitOption {
code: string; code: string;
status: string; status: string;
plannedStartAt: string | null; plannedStartAt: string | null;
scopeAsset?: { name: string; typeName?: string } | null;
operationalArea: { name: string } | null; operationalArea: { name: string } | null;
operatorCompany: { name: string } | null; operatorCompany: { name: string } | null;
} }
@@ -116,8 +117,10 @@ function findingAction(finding: BriefingFinding, act: BriefingAct, plannedOn: st
} }
export function FieldBriefingsPage() { export function FieldBriefingsPage() {
const [searchParams] = useSearchParams();
const requestedInspectionId = searchParams.get('inspectionId') ?? '';
const [visits, setVisits] = useState<VisitOption[]>([]); const [visits, setVisits] = useState<VisitOption[]>([]);
const [visitId, setVisitId] = useState(''); const [visitId, setVisitId] = useState(requestedInspectionId);
const [briefing, setBriefing] = useState<FieldBriefing | null>(null); const [briefing, setBriefing] = useState<FieldBriefing | null>(null);
const [loadingVisits, setLoadingVisits] = useState(true); const [loadingVisits, setLoadingVisits] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -132,12 +135,15 @@ export function FieldBriefingsPage() {
const all = [...planned.data, ...drafts.data] const all = [...planned.data, ...drafts.data]
.sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? ''))); .sort((a, b) => String(a.plannedStartAt ?? '').localeCompare(String(b.plannedStartAt ?? '')));
setVisits(all); setVisits(all);
const onlyVisit = all[0]; if (requestedInspectionId && all.some((visit) => visit.id === requestedInspectionId)) {
if (all.length === 1 && onlyVisit) setVisitId(onlyVisit.id); setVisitId(requestedInspectionId);
} else if (all.length === 1 && all[0]) {
setVisitId(all[0].id);
}
}).catch((cause) => { }).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause)); setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoadingVisits(false)); }).finally(() => setLoadingVisits(false));
}, []); }, [requestedInspectionId]);
useEffect(() => { useEffect(() => {
if (!visitId) { if (!visitId) {
@@ -158,11 +164,12 @@ export function FieldBriefingsPage() {
); );
return <section className="field-briefing-page"> return <section className="field-briefing-page">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Preparación para campo</span></div>
<div className="page-heading"> <div className="page-heading">
<div> <div>
<span className="eyebrow">ANTES DE SALIR A CAMPO</span> <span className="eyebrow">ETAPA DE LA INSPECCIÓN</span>
<h1>Preparación de campo</h1> <h1>Preparación para campo</h1>
<p>Seleccioná la inspección y revisá los pendientes de Actas anteriores del mismo Área/Yacimiento y Operadora.</p> <p>Revisá antecedentes y pendientes antes de iniciar la Inspección desde la APK.</p>
</div> </div>
{briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}> {briefing && <button className="button secondary no-print" type="button" onClick={() => window.print()}>
Imprimir preparación Imprimir preparación
@@ -170,15 +177,15 @@ export function FieldBriefingsPage() {
</div> </div>
<div className="form-card no-print"> <div className="form-card no-print">
<label>Inspección planificada</label> <label>Inspección</label>
{loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect {loadingVisits ? <LoadingBlock label="Cargando inspecciones…" /> : <SearchableSelect
value={visitId} value={visitId}
onChange={(event) => setVisitId(event.target.value)} onChange={(event) => setVisitId(event.target.value)}
searchPlaceholder="Buscar por código, Área u Operadora…" searchPlaceholder="Buscar por código, Yacimiento, Área u Operadora…"
> >
<option value="">Seleccionar inspección</option> <option value="">Seleccionar inspección</option>
{visits.map((visit) => <option key={visit.id} value={visit.id}> {visits.map((visit) => <option key={visit.id} value={visit.id}>
{visit.code} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'} {visit.code} · {visit.scopeAsset?.name ?? 'Sin Yacimiento'} · {visit.operationalArea?.name ?? 'Sin Área'} · {visit.operatorCompany?.name ?? 'Sin Operadora'}
</option>)} </option>)}
</SearchableSelect>} </SearchableSelect>}
{selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>} {selected?.plannedStartAt && <small className="block-muted">Salida prevista: {formatDate(selected.plannedStartAt)}</small>}
@@ -189,18 +196,18 @@ export function FieldBriefingsPage() {
{!loading && !briefing && !error && <EmptyState {!loading && !briefing && !error && <EmptyState
title="Seleccioná una inspección" title="Seleccioná una inspección"
text="El sistema reunirá automáticamente los pendientes de inspecciones anteriores del mismo contexto." text="El sistema reunirá automáticamente sus antecedentes y controles pendientes."
/>} />}
{!loading && briefing && <> {!loading && briefing && <>
<div className="page-heading compact"> <div className="page-heading compact">
<div> <div>
<span className="eyebrow">{briefing.inspection.code}</span> <span className="eyebrow">{briefing.inspection.code}</span>
<h2>{briefing.inspection.area?.name ?? 'Área sin definir'} · {briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'}</h2> <h2>{selected?.scopeAsset?.name ?? 'Yacimiento sin definir'} · {briefing.inspection.area?.name ?? 'Área sin definir'}</h2>
<p>Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p> <p>{briefing.inspection.operatorCompany?.name ?? 'Operadora sin definir'} · Fecha de referencia: {formatDateOnly(briefing.plannedOn)}</p>
</div> </div>
<Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}> <Link className="button secondary no-print" to={`/inspecciones/${briefing.inspection.id}`}>
Abrir planificación Volver a la inspección
</Link> </Link>
</div> </div>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { import {
@@ -70,6 +71,7 @@ export function InspectionActEditorPage() {
<div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div> <div className="inspection-act-asset-grid">{act.assets.map((asset) => <div className="inspection-member selected" key={asset.id}><span><strong>{asset.name}</strong><small>{asset.code} · {asset.typeName}</small></span></div>)}</div>
</section>} </section>}
{act && <DocumentPdfProjection kind="act" />}
{act && <InspectionFindingsPanel act={act} />} {act && <InspectionFindingsPanel act={act} />}
{act && <InspectionClosurePanel act={act} />} {act && <InspectionClosurePanel act={act} />}
@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect';
import { apiRequest } from '../lib/api';
interface Option {
id: string;
code: string;
name: string;
}
interface Inspector {
id: string;
username: string;
firstName: string;
lastName: string;
}
interface CreatedInspection {
id: string;
code: string;
}
function inspectorName(person: Inspector): string {
return `${person.firstName} ${person.lastName}`.trim() || person.username;
}
function currentLocalDateTime(): string {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
export function InspectionVisitCreateF61Page() {
const navigate = useNavigate();
const [departments, setDepartments] = useState<Option[]>([]);
const [areas, setAreas] = useState<Option[]>([]);
const [yacimientos, setYacimientos] = useState<Option[]>([]);
const [operators, setOperators] = useState<Option[]>([]);
const [inspectors, setInspectors] = useState<Inspector[]>([]);
const [departmentId, setDepartmentId] = useState('');
const [areaId, setAreaId] = useState('');
const [yacimientoId, setYacimientoId] = useState('');
const [operatorId, setOperatorId] = useState('');
const [inspectorId, setInspectorId] = useState('');
const [plannedStartAt, setPlannedStartAt] = useState(currentLocalDateTime);
const [loading, setLoading] = useState(true);
const [loadingOperators, setLoadingOperators] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([
apiRequest<{ data: Option[] }>('/inspection-visits/planning-context/departments'),
apiRequest<{ data: Inspector[] }>('/inspection-visits/assignees'),
]).then(([departmentResponse, inspectorResponse]) => {
setDepartments(departmentResponse.data);
setInspectors(inspectorResponse.data);
}).catch((cause) => {
setError(cause instanceof Error ? cause.message : String(cause));
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
setAreaId('');
setYacimientoId('');
setOperatorId('');
setAreas([]);
setYacimientos([]);
setOperators([]);
if (!departmentId) return;
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/departments/${departmentId}/areas`)
.then((response) => setAreas(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [departmentId]);
useEffect(() => {
setYacimientoId('');
setOperatorId('');
setYacimientos([]);
setOperators([]);
if (!areaId) return;
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)
.then((response) => setYacimientos(response.data))
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));
}, [areaId]);
useEffect(() => {
setOperatorId('');
setOperators([]);
if (!areaId || !plannedStartAt) return;
const parsedStart = new Date(plannedStartAt);
if (Number.isNaN(parsedStart.getTime())) return;
const at = encodeURIComponent(parsedStart.toISOString());
setLoadingOperators(true);
apiRequest<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)
.then((response) => {
setOperators(response.data);
if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);
})
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setLoadingOperators(false));
}, [areaId, plannedStartAt]);
const submit = async (event: FormEvent) => {
event.preventDefault();
setError('');
if (!departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId) {
setError('Completá Departamento, Área, Yacimiento, Operadora, fecha e Inspector.');
return;
}
const parsedStart = new Date(plannedStartAt);
if (Number.isNaN(parsedStart.getTime())) {
setError('La fecha y hora de inicio no es válida.');
return;
}
setBusy(true);
try {
const created = await apiRequest<CreatedInspection>('/inspection-visits', {
method: 'POST',
body: JSON.stringify({
operationalAreaId: areaId,
scopeAssetId: yacimientoId,
operatorCompanyId: operatorId,
plannedStartAt: parsedStart.toISOString(),
leadInspectorUserId: inspectorId,
}),
});
navigate(`/inspecciones/${created.id}`, { replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setBusy(false);
}
};
if (loading) return <LoadingBlock label="Cargando estructura territorial…" />;
return <section className="survey-editor inspection-editor">
<div className="breadcrumb"><Link to="/inspecciones">Inspecciones</Link><span>/</span><span>Planificar inspección</span></div>
<div className="page-heading survey-editor-heading">
<div>
<span className="eyebrow">INSPECCIÓN</span>
<h1>Planificar inspección</h1>
<p>Seleccioná la ubicación respetando la jerarquía Departamento Área Yacimiento.</p>
</div>
</div>
{error && <Alert>{error}</Alert>}
<form className="panel form-panel inspection-quick-create" onSubmit={submit}>
<div className="panel-heading">
<div>
<span className="eyebrow">CREACIÓN RÁPIDA</span>
<h2>Ubicación, contexto y fecha</h2>
<p className="section-copy">La Operadora pertenece al contexto temporal del Área. El Yacimiento define el alcance físico de esta Inspección.</p>
</div>
</div>
<div className="form-grid">
<label className="field">
<span>Departamento</span>
<SearchableSelect value={departmentId} onChange={(event) => setDepartmentId(event.target.value)} searchPlaceholder="Buscar Departamento…" required>
<option value="">Seleccionar Departamento</option>
{departments.map((department) => <option key={department.id} value={department.id}>{department.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Área</span>
<SearchableSelect value={areaId} onChange={(event) => setAreaId(event.target.value)} searchPlaceholder="Buscar Área…" required disabled={!departmentId}>
<option value="">Seleccionar Área</option>
{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Yacimiento</span>
<SearchableSelect value={yacimientoId} onChange={(event) => setYacimientoId(event.target.value)} searchPlaceholder="Buscar Yacimiento…" required disabled={!areaId}>
<option value="">Seleccionar Yacimiento</option>
{yacimientos.map((yacimiento) => <option key={yacimiento.id} value={yacimiento.id}>{yacimiento.name}</option>)}
</SearchableSelect>
</label>
<label className="field">
<span>Operadora del Área</span>
<SearchableSelect value={operatorId} onChange={(event) => setOperatorId(event.target.value)} searchPlaceholder="Buscar Operadora…" required disabled={!areaId || !plannedStartAt || loadingOperators || operators.length === 0}>
<option value="">{loadingOperators ? 'Cargando Operadora…' : operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}</option>
{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name}</option>)}
</SearchableSelect>
<small className="muted">Se obtiene de la relación vigente del Área para la fecha y hora de inicio.</small>
</label>
<label className="field">
<span>Fecha y hora de inicio</span>
<input type="datetime-local" value={plannedStartAt} onChange={(event) => setPlannedStartAt(event.target.value)} required />
</label>
<label className="field">
<span>Inspector responsable</span>
<SearchableSelect value={inspectorId} onChange={(event) => setInspectorId(event.target.value)} searchPlaceholder="Buscar Inspector…" required>
<option value="">Seleccionar Inspector</option>
{inspectors.map((inspector) => <option key={inspector.id} value={inspector.id}>{inspectorName(inspector)} · {inspector.username}</option>)}
</SearchableSelect>
</label>
</div>
{areaId && yacimientos.length === 0 && <Alert>Esta Área no tiene Yacimientos cargados. No se puede planificar una Inspección hasta corregir su jerarquía.</Alert>}
{areaId && plannedStartAt && !loadingOperators && operators.length === 0 && <Alert>Esta Área no tiene una Operadora vigente para la fecha seleccionada. Podés consultar el Inventario, pero no planificar una Inspección operativa hasta definir esa relación.</Alert>}
<div className="form-actions">
<Link className="button secondary" to="/inspecciones">Volver</Link>
<button className="button primary" disabled={busy || !departmentId || !areaId || !yacimientoId || !operatorId || !plannedStartAt || !inspectorId}>
<Icon name="check" />{busy ? 'Creando…' : 'Crear inspección'}
</button>
</div>
</form>
</section>;
}
@@ -0,0 +1,28 @@
import { Link, useParams } from 'react-router';
import { Icon } from '../components/Icon';
import { InspectionVisitEditorF4Page } from './InspectionVisitEditorF4Page';
/**
* F6.1 keeps Preparación para campo as a stage of the Inspection. The existing
* detail remains unchanged underneath; this wrapper makes the relationship
* explicit without creating a second operational module.
*/
export function InspectionVisitDetailF61Page() {
const { id } = useParams<{ id: string }>();
return <>
{id && <article className="panel survey-status-actions no-print">
<div>
<span className="eyebrow">ETAPA PREVIA AL CAMPO</span>
<strong>Preparación para campo</strong>
<p>Revisá antecedentes, hallazgos pendientes y próximos controles de esta Inspección antes de iniciarla desde la APK.</p>
</div>
<div className="form-actions">
<Link className="button secondary" to={`/preparacion-campo?inspectionId=${id}`}>
<Icon name="clipboard" /> Abrir preparación
</Link>
</div>
</article>}
<InspectionVisitEditorF4Page />
</>;
}
@@ -109,6 +109,8 @@ export function InspectionVisitEditorF4Page() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const [showCancelForm, setShowCancelForm] = useState(false);
const [cancelReason, setCancelReason] = useState('');
const applyVisit = (value: InspectionVisit, syncForm = true) => { const applyVisit = (value: InspectionVisit, syncForm = true) => {
setVisit(value); setVisit(value);
@@ -207,6 +209,7 @@ export function InspectionVisitEditorF4Page() {
} else if (id) { } else if (id) {
applyVisit(await updateInspectionVisit(id, { applyVisit(await updateInspectionVisit(id, {
objective: form.objective.trim() || null, objective: form.objective.trim() || null,
scopeAssetId: visit?.scopeAsset?.id ?? null,
operationalAreaId: form.operationalAreaId || null, operationalAreaId: form.operationalAreaId || null,
operatorCompanyId: form.operatorCompanyId || null, operatorCompanyId: form.operatorCompanyId || null,
plannedStartAt, plannedStartAt,
@@ -286,7 +289,7 @@ export function InspectionVisitEditorF4Page() {
finally { setBusy(false); } finally { setBusy(false); }
}; };
const lifecycleAction = async (action: 'PLAN' | 'UNPLAN' | 'CANCEL') => { const lifecycleAction = async (action: 'PLAN' | 'UNPLAN') => {
if (!id) return; if (!id) return;
setBusy(true); setError(''); setSuccess(''); setBusy(true); setError(''); setSuccess('');
try { try {
@@ -294,23 +297,38 @@ export function InspectionVisitEditorF4Page() {
if (action === 'PLAN') { if (action === 'PLAN') {
updated = await planInspectionVisit(id); updated = await planInspectionVisit(id);
setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.'); setSuccess('Inspección planificada. Ya puede iniciarse desde la APK.');
} else if (action === 'UNPLAN') { } else {
updated = await unplanInspectionVisit(id); updated = await unplanInspectionVisit(id);
setSuccess('La Inspección volvió a borrador para corregir la planificación.'); setSuccess('La Inspección volvió a borrador para corregir la planificación.');
} else {
const reason = window.prompt('Indicá el motivo de cancelación (mínimo 10 caracteres):')?.trim() ?? '';
if (reason.length < 10) {
if (reason) setError('El motivo debe tener al menos 10 caracteres.');
return;
}
updated = await cancelInspectionVisit(id, reason);
setSuccess('Inspección cancelada con motivo auditado.');
} }
applyVisit(updated); applyVisit(updated);
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusy(false); } finally { setBusy(false); }
}; };
const cancelCurrentVisit = async (event: FormEvent) => {
event.preventDefault();
if (!id) return;
const reason = cancelReason.trim();
if (reason.length < 10) {
setError('El motivo de cancelación debe tener al menos 10 caracteres.');
return;
}
if (reason.length > 500) {
setError('El motivo de cancelación no puede superar los 500 caracteres.');
return;
}
setBusy(true); setError(''); setSuccess('');
try {
const updated = await cancelInspectionVisit(id, reason);
applyVisit(updated);
setCancelReason('');
setShowCancelForm(false);
setSuccess('Inspección cancelada. El motivo quedó registrado y auditado.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setBusy(false); }
};
if (loading) return <LoadingBlock label="Cargando inspección…" />; if (loading) return <LoadingBlock label="Cargando inspección…" />;
return <section className="survey-editor inspection-editor"> return <section className="survey-editor inspection-editor">
@@ -325,7 +343,8 @@ export function InspectionVisitEditorF4Page() {
<div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div> <div className="panel-heading"><div><span className="eyebrow">{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}</span><h2>Contexto y fecha de inicio</h2><p className="section-copy">No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.</p></div>{visit && <small className="muted">Actualizado {formatDate(visit.updatedAt)}</small>}</div>
{visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>} {visit && <div className="inspection-generated-code"><small>Identificador institucional</small><strong>{visit.code}</strong></div>}
<div className="form-grid"> <div className="form-grid">
<label className="field"><span>Área / Yacimiento</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label> <label className="field"><span>Área</span><SearchableSelect searchPlaceholder="Buscar Área…" value={form.operationalAreaId} onChange={(event) => { context.setAreaId(event.target.value); setForm((current) => ({ ...current, operationalAreaId: event.target.value, operatorCompanyId: '' })); }} required disabled={!canManage || !planningEditable}><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name} · {area.code}</option>)}</SearchableSelect></label>
{!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}
<label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label> <label className="field"><span>Operadora</span><SearchableSelect searchPlaceholder="Buscar Operadora…" value={form.operatorCompanyId} onChange={(event) => { context.setCompanyId(event.target.value); setForm((current) => ({ ...current, operatorCompanyId: event.target.value })); }} required disabled={!canManage || !planningEditable || !form.operationalAreaId}><option value="">Seleccionar Operadora</option>{operators.map((operator) => <option key={operator.id} value={operator.id}>{operator.name} · {operator.code}</option>)}</SearchableSelect></label>
<label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label> <label className="field"><span>Fecha y hora de inicio</span><input type="datetime-local" value={form.plannedStartAt} onChange={(event) => setForm((current) => ({ ...current, plannedStartAt: event.target.value }))} required disabled={!canManage || !planningEditable} /></label>
{isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>} {isNew && <label className="field"><span>Inspector responsable</span><SearchableSelect searchPlaceholder="Buscar Inspector…" value={leadInspectorId} onChange={(event) => setLeadInspectorId(event.target.value)} required disabled={!canAssign}><option value="">Seleccionar Inspector</option>{assignees.map((person) => <option key={person.id} value={person.id}>{personName(person)} · {person.username}</option>)}</SearchableSelect></label>}
@@ -351,7 +370,9 @@ export function InspectionVisitEditorF4Page() {
{visit && <InspectionActsPanel visit={visit} />} {visit && <InspectionActsPanel visit={visit} />}
{visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => void lifecycleAction('CANCEL')}>Cancelar</button>}</div></article>} {visit && <article className="panel survey-status-actions"><div><strong>Flujo de la Inspección</strong><p>La oficina planifica. El inicio y cierre operativo se realizan desde la APK. Una Inspección no puede cerrarse hasta que todas sus Actas estén selladas.</p></div><div className="form-actions">{canManage && visit.status === 'DRAFT' && <button type="button" className="button primary" disabled={busy} onClick={() => void lifecycleAction('PLAN')}>Confirmar planificación</button>}{canManage && visit.status === 'PLANNED' && <button type="button" className="button secondary" disabled={busy} onClick={() => void lifecycleAction('UNPLAN')}>Volver a borrador</button>}{canManage && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <button type="button" className="button danger-outline" disabled={busy} onClick={() => { setError(''); setSuccess(''); setShowCancelForm(true); }}>Cancelar inspección</button>}</div></article>}
{visit && showCancelForm && ['DRAFT', 'PLANNED', 'IN_PROGRESS'].includes(visit.status) && <form className="panel form-panel" onSubmit={cancelCurrentVisit}><div className="panel-heading"><div><span className="eyebrow">CANCELACIÓN</span><h2>Cancelar inspección</h2><p className="section-copy">La cancelación es definitiva y queda auditada. No se permite si la Inspección ya tiene un Acta sellada.</p></div></div><label className="field"><span>Motivo de cancelación</span><textarea value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} minLength={10} maxLength={500} rows={4} placeholder="Explicá el motivo de la cancelación…" required /><small className="muted">{cancelReason.trim().length}/500 · mínimo 10 caracteres</small></label><div className="form-actions"><button type="button" className="button secondary" disabled={busy} onClick={() => { setShowCancelForm(false); setCancelReason(''); }}>Volver</button><button type="submit" className="button danger-outline" disabled={busy || cancelReason.trim().length < 10 || cancelReason.trim().length > 500}>{busy ? 'Cancelando…' : 'Confirmar cancelación'}</button></div></form>}
{visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>} {visit?.status === 'PLANNED' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Planificación lista.</strong> El Inspector asignado debe iniciar la Inspección desde la APK.</p></div>}
{visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>} {visit?.status === 'IN_PROGRESS' && <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Inspección en curso.</strong> Actas, Hallazgos, firmas y cierre se registran desde la APK.</p></div>}
+4 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { DocumentPdfProjection } from '../components/DocumentPdfProjection';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
@@ -193,7 +194,7 @@ export function ReportDetailPage() {
{success && <Alert type="success">{success}</Alert>} {success && <Alert type="success">{success}</Alert>}
{report && <> {report && <>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> El Word puede editarse durante la preparación del Informe. El Acta sellada y los Hallazgos que contiene permanecen inmutables.</p></div> <div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un INF corresponde a una sola Acta.</strong> Una Inspección puede contener varias Actas y, por lo tanto, varios INF independientes. El Word puede editarse durante la preparación; el Acta sellada y sus Hallazgos permanecen inmutables.</p></div>
<section className="panel report-summary-panel"> <section className="panel report-summary-panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div> <div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD DOCUMENTAL</span><h2>{report.code}</h2></div><small className="muted">Versión del Acta: {report.actVersion}</small></div>
@@ -210,6 +211,8 @@ export function ReportDetailPage() {
</div> </div>
</section> </section>
<DocumentPdfProjection kind="report" />
<section className="panel"> <section className="panel">
<div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div> <div className="panel-heading"><div><span className="eyebrow">WORD EDITABLE</span><h2>Preparación del INF</h2><p className="section-copy">El Inspector puede revisar y ajustar el texto del Informe antes de incorporarlo a GEDO. Esta edición no altera el Acta fuente.</p></div>{report.wordStatus === 'READY' && <a className="button secondary" href={inspectionReportWordDownloadUrl(report.id)}>Descargar Word</a>}</div>
<form className="form-section" onSubmit={saveNarrative}> <form className="form-section" onSubmit={saveNarrative}>