Compare commits

..
Author SHA1 Message Date
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -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
admin e549a44ae5 fix(F6.1): unificar contexto operativo Área–Operadora (#33)
Cierra inconsistencias residuales del contexto operativo F6.1. Inventario queda ligado al Área física; la Operadora vigente se resuelve por relaciones temporales. Incluye correcciones pre-merge de búsquedas WEB, alta contextual, merge de duplicados y barreras CI/runtime. Producción permanece sin desplegar.
2026-09-09 17:43:07 -03:00
admin 1dd424698a test(f6.1): require build-safe active operator fallback 2026-09-09 17:36:04 -03:00
admin d9cdb48a26 fix(f6.1): make active operator selection build-safe 2026-09-09 17:35:42 -03:00
admin bec92f0c6d test(f6.1): cover inventory merge physical candidate search 2026-09-09 17:32:16 -03:00
admin 3dab0938d6 fix(f6.1): merge inventory candidates by physical context 2026-09-09 17:31:54 -03:00
admin f93e8240ad test(f6.1): cover pre-merge operator consistency blockers 2026-09-09 17:27:48 -03:00
admin 257ee20148 fix(f6.1): derive creation operator from active area relation 2026-09-09 17:27:20 -03:00
admin 264ab70214 fix(f6.1): search preventive inventory by physical area 2026-09-09 17:26:05 -03:00
admin 3903ae22e6 fix(f6.1): reconcile field inventory by physical area 2026-09-09 17:24:45 -03:00
admin c454eb3255 test(f6.1): block legacy operator snapshot search contracts 2026-09-09 15:29:06 -03:00
admin a7cc9e0886 fix(f6.1): reject legacy operator snapshot tree filters 2026-09-09 15:28:22 -03:00
admin 7d475cdc6f fix(f6.1): reject legacy operator snapshot asset filters 2026-09-09 15:28:09 -03:00
admin 8b6608ecae test(f6.1): cover historical context and web operator separation 2026-09-09 15:23:39 -03:00
admin 5b237a4d4e fix(f6.1): keep operator editable only at inventory creation 2026-09-09 15:23:09 -03:00
admin 16ca67cb76 fix(f6.1): keep act lists and snapshots on visit context 2026-09-09 15:20:42 -03:00
admin ca1fddc834 fix(f6.1): separate physical context from area operator in web 2026-09-09 15:18:28 -03:00
admin b713be4d15 fix(f6.1): keep reports on frozen inspection context 2026-09-09 15:16:45 -03:00
admin 20ecbb2872 fix(f6.1): keep finding worklist on historical visit context 2026-09-09 15:15:07 -03:00
admin 62f612dd24 ci(f6.1): require real Nest startup and health check 2026-09-09 15:13:32 -03:00
admin fc978ad74e ci(f6.1): run Android on operational API contract changes 2026-09-09 15:12:55 -03:00
admin 4f1f26ad93 test(f6.1): lock operational context invariants 2026-09-09 15:12:43 -03:00
admin ec8a9be99e test(f6.1): enforce operator relation without inventory rewrite 2026-09-09 15:12:27 -03:00
admin 5738306a14 fix(f6.1): resolve verification operator from temporal area relation 2026-09-09 15:09:04 -03:00
admin 33c0c6b8f6 fix(f6.1): preserve inventory snapshots when operator changes 2026-09-09 15:07:52 -03:00
admin 44f6002869 fix(f6.1): resolve temporal operator from area relation history 2026-09-09 15:07:17 -03:00
admin 517f934d5e fix(f6.1): keep operator snapshot immutable on inventory updates 2026-09-09 15:06:54 -03:00
admin e4acb7bebf fix(f6.1): block operator changes through inventory context 2026-09-09 15:06:42 -03:00
admin f7145427c0 F6.1 · Auditoría de consistencia y manuales (#32)
Consolida invariantes F6.1, corrige terminología de contexto operativo, agrega contratos transversales y crea manuales de programador/usuario. Deploy permanece pausado.
2026-09-09 14:58:46 -03:00
admin 433220ee3a docs(code): documentar apertura móvil canónica 2026-09-09 14:52:57 -03:00
admin ba38dc7dda docs: enlazar manuales y contrato F6.1 2026-09-09 14:52:26 -03:00
admin 51ace362d7 docs: registrar auditoría de consistencia F6.1 2026-09-09 14:52:03 -03:00
admin 3a149691e2 docs: crear manual de usuario 2026-09-09 14:50:08 -03:00
admin 294abc4cf3 docs: crear manual del programador 2026-09-09 14:49:21 -03:00
admin 90443b3961 docs: evitar duplicación del modelo F6 2026-09-09 14:48:30 -03:00
admin 60c6d97f1a docs: evitar duplicación del modelo F6 2026-09-09 14:48:20 -03:00
admin ae7326f20a docs: consolidar modelo canónico de Inventarios F6.1 2026-09-09 14:47:52 -03:00
admin 5a9c4ed5ef test: fijar invariantes transversales F6.1 2026-09-09 14:47:19 -03:00
admin 0959e85487 fix: alinear terminología del contexto de inspección 2026-09-09 14:44:57 -03:00
admin a4fad611d4 docs(code): fijar invariante multi-Acta 2026-09-09 14:44:26 -03:00
admin d28186a567 docs(code): documentar jerarquía canónica de Inventario 2026-09-09 14:44:06 -03:00
admin 25a63cd3d4 F6.1 · Hallazgos en Yacimientos + abrir Inspección desde APK (#31)
Hallazgos directos en Yacimiento, Instalación y Subinstalación con OTROS siempre disponible. Apertura de inspección desde Android mediante Área → Operadora vigente, autoasignación y lifecycle canónico.
2026-09-09 14:17:08 -03:00
admin 3d8ace5529 F6.1 · actualizar contrato de APK de prueba 2026-09-09 14:11:01 -03:00
admin 47203ff0da F6.1 · contrato de catálogo contextual en Yacimientos 2026-09-09 14:10:50 -03:00
admin b701becab7 F6.1 · usar catálogo contextual por tipo en Yacimientos 2026-09-09 14:10:20 -03:00
admin 9983201de7 F6.1 · actualizar contrato de health metadata 2026-09-09 14:04:26 -03:00
admin 4fc05b73b1 F6.1 · artefacto APK 0.15.0 2026-09-09 14:00:34 -03:00
admin 40cfeff42e F6.1 · Android 0.15.0 2026-09-09 14:00:24 -03:00
admin e3f89f4d5a F6.1 · metadata API 2026-09-09 14:00:11 -03:00
admin 7284c252ff F6.1 · version API 0.29.0-1 2026-09-09 14:00:01 -03:00
admin 7baf306a23 F6.1 · contratos Hallazgos y apertura móvil 2026-09-09 13:59:49 -03:00
admin 27a51c2fe2 F6.1 · tratar Yacimiento como objetivo de Hallazgos 2026-09-09 13:59:33 -03:00
admin b326eb737d F6.1 · permitir Hallazgos sobre Yacimientos 2026-09-09 13:59:07 -03:00
admin 6a5db887c0 F6.1 · abrir inspecciones desde APK 2026-09-09 13:58:23 -03:00
admin baf63a861c F6.1 · usar home móvil con apertura de inspecciones 2026-09-09 13:57:57 -03:00
admin d61e4ad04d F6.1 · pantalla APK para abrir inspección en campo 2026-09-09 13:57:34 -03:00
admin 37885c0454 F6.1 · cliente Android para abrir inspecciones 2026-09-09 13:57:08 -03:00
admin b6707cef43 F6.1 · contrato para abrir inspección desde APK 2026-09-09 13:56:28 -03:00
admin 74fc08bf24 F6 hotfix · deploy espera versión candidata (#30)
Endurece el healthcheck del watcher para esperar status ok, versión exacta candidata y database ok antes de aceptar el recreate.
2026-09-09 11:34:37 -03:00
admin ad7ec2ca2b fix(deploy): wait for exact candidate health version 2026-09-09 11:28:53 -03:00
admin d938db7913 F6 · Modelo definitivo de Inventarios + APK Android (#29)
Jerarquía física definitiva, compatibilidades N↔N de clasificaciones, Hallazgos y campos técnicos por familia, navegación WEB/Android y APK F6 0.14.0-debug.
2026-09-09 11:19:12 -03:00
admin 0f4bb13fc7 docs(f6): documentar modelo definitivo 2026-09-09 09:11:16 -03:00
admin f0db23bc9b docs(f6): ampliar contrato definitivo 2026-09-09 09:11:08 -03:00
admin 0b0851d9ec docs(f6): fijar contrato definitivo de Inventarios 2026-09-09 09:11:00 -03:00
admin 7338f277ad F5.1 · Inventario limpio, jerarquía manual y Hallazgos filtrados (#28)
Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora.
2026-09-09 08:55:01 -03:00
admin 3d83545033 F5 hotfix · restaurar provider interno del dossier
Restaura únicamente InventoryFunctionService como provider interno requerido por el dossier fusionado, sin reabrir el catálogo de funciones, y agrega regresión de arranque.
2026-09-09 07:04:46 -03:00
admin 2b87e07fd2 F5 · Android 0.13.0 de prueba y compatibilidad de familias
Integra el corte Android F5 validado por CI, con artifact 0.13.0-debug y compatibilidad correcta de familias Otro / no catalogado en Inventario de campo.
2026-09-09 06:50:06 -03:00
104 changed files with 5889 additions and 1433 deletions
+95 -20
View File
@@ -1,35 +1,36 @@
name: Android APK name: Android CI / RC
# F5: genera una APK debug verificable contra la API productiva F5. # 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*'
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/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- '.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
@@ -51,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-F5-0.13.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
+101 -64
View File
@@ -69,7 +69,7 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose - name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations on clean PostGIS - name: Rehearse migrations and real API startup on clean PostGIS
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
cleanup() { cleanup() {
@@ -80,12 +80,9 @@ jobs:
docker compose --env-file .env.example up -d db docker compose --env-file .env.example up -d db
# The historical production reset is a one-shot operational migration, # Historical production reset is a one-shot migration that expects the
# not a bootstrap migration: it requires production data/configuration # production admin. Prove the clean chain reaches that exact guard,
# that cannot exist at its timestamp in a database rebuilt from zero. # mark only that historical reset as applied, then continue the chain.
# Prove the clean chain reaches that exact guard, then mark only that
# one-shot migration as already applied and continue the reproducible
# schema chain. The historical migration itself remains untouched.
bootstrap_log="$(mktemp)" bootstrap_log="$(mktemp)"
set +e set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log" docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
@@ -104,17 +101,14 @@ jobs:
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 $$
DECLARE DECLARE reset_rows integer;
reset_rows integer;
BEGIN BEGIN
SELECT COUNT(*) INTO reset_rows SELECT COUNT(*) INTO reset_rows
FROM typeorm_migrations FROM typeorm_migrations
WHERE name = 'ResetProductionOperationalData1788652800000'; WHERE name = 'ResetProductionOperationalData1788652800000';
IF reset_rows <> 0 THEN IF reset_rows <> 0 THEN
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows; RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
END IF; END IF;
INSERT INTO typeorm_migrations ("timestamp", name) INSERT INTO typeorm_migrations ("timestamp", name)
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000'); VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
END $$; END $$;
@@ -122,17 +116,24 @@ 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 performs the historical clean start. F6.1 then seeds only the
# presentation territory approved by DH: 7 Departamentos, 64 Áreas,
# 230 Yacimientos and 12 real Empresas.
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 $$
DECLARE DECLARE
f5_migrations integer; f5_migrations integer;
real_inventory integer; domain_assets integer;
source_areas integer; audits integer;
source_yacimientos integer; applicability 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;
department_types integer;
area_department_rules integer;
BEGIN BEGIN
SELECT COUNT(*) INTO f5_migrations SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations FROM typeorm_migrations
@@ -141,28 +142,40 @@ jobs:
'F5CanonicalInventoryHierarchy1790087150000', 'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000', 'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000', 'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000' 'F5AuthoritativeInventoryCatalog1790087300000',
'F51CleanManualInventory1790087400000'
); );
IF f5_migrations <> 5 THEN IF f5_migrations <> 6 THEN
RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations; RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
END IF; END IF;
SELECT COUNT(*) INTO real_inventory SELECT COUNT(*) INTO domain_assets FROM assets;
FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE'; IF domain_assets <> 313 THEN
IF real_inventory <> 0 THEN RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
END IF; END IF;
SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'), SELECT COUNT(*) INTO audits FROM audit_events;
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento') IF audits <> 0 THEN
INTO source_areas,source_yacimientos RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
FROM source_documents document END IF;
JOIN asset_source_documents link ON link.document_id=document.id
JOIN assets asset ON asset.id=link.asset_id SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
JOIN asset_types type ON type.id=asset.asset_type_id IF applicability <> 0 THEN
WHERE document.document_number='DH-F5-TERRITORY'; RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
IF source_areas <> 64 OR source_yacimientos <> 230 THEN END IF;
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
SELECT COUNT(*) INTO territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF territory_sources <> 0 THEN
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) INTO presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF presentation_sources <> 1 THEN
RAISE EXCEPTION 'F6.1 must contain exactly one presentation territory source, got %', presentation_sources;
END IF; END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'), SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
@@ -171,7 +184,7 @@ jobs:
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 family preload mismatch: 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
@@ -179,43 +192,67 @@ 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 finding preload mismatch: %', source_findings; RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
FROM asset_types
WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true;
IF department_types <> 1 THEN
RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types;
END IF;
SELECT COUNT(*) INTO area_department_rules
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';
IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF; END IF;
END $$; END $$;
SQL SQL
# Prove the five F5 migrations are actually reversible on a clean state. # Both destructive cuts are intentionally one-way: production rollback is
for _ in 1 2 3 4 5; do # the PRE database backup, not migration:revert. Prove the completed chain
docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert # is idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: F6.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
rm -f "$rerun_log"
# A build-only preflight cannot catch Nest dependency-injection or
# runtime configuration failures. Start the production API image against
# the migrated database and require the public health endpoint to answer.
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
export SMTP_SETTINGS_MASTER_KEY=''
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
sleep 2
done done
if [ "$api_ready" -ne 1 ]; then
docker compose --env-file .env.example exec -T db \ echo 'ERROR: production API image did not become healthy.' >&2
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' docker compose --env-file .env.example logs --no-color api >&2 || true
DO $$ exit 1
DECLARE f5_migrations integer; instance_column integer; fi
BEGIN grep -Fq '"status":"ok"' /tmp/dhv2-health.json || {
SELECT COUNT(*) INTO f5_migrations echo 'ERROR: /api/v3/health did not report status ok.' >&2
FROM typeorm_migrations cat /tmp/dhv2-health.json >&2
WHERE name LIKE 'F5%1790087%'; exit 1
IF f5_migrations <> 0 THEN }
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations; - name: Isolated builder test preflight
END IF;
SELECT COUNT(*) INTO instance_column
FROM information_schema.columns
WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance';
IF instance_column <> 0 THEN
RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind';
END IF;
END $$;
SQL
# Reapply them once more. Each F5 migration performs its own source/cardinality checks.
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \
"SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \
| grep -Fx 'F5_REAPPLY_OK'
- name: VPS-equivalent isolated API preflight
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}" image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
@@ -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
+26 -5
View File
@@ -2,9 +2,30 @@
Repositorio del sistema DH Inspección V2. Repositorio del sistema DH Inspección V2.
## Estructura ## Componentes
- `api-v3/`: API v3. - `api-v3/`: API NestJS/TypeORM y contratos de dominio.
- `web-v2/`: aplicación web. - `web-v2/`: aplicación WEB React/Vite.
- `scripts/`: utilidades operativas y de despliegue. - `android-app/`: aplicación Android para trabajo de campo.
- `docs/`: documentación técnica. - `scripts/`: utilidades operativas, preflight y despliegue.
- `docs/`: documentación funcional y técnica.
## Documentación vigente
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
- [Contrato de release Android F6.1](android-app/RELEASE.md)
## Contratos que no deben romperse
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
+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.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 20 versionCode = 23
versionName = "0.13.0" versionName = "0.15.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -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"
android:roundIcon="@drawable/ic_mendoza_launcher"
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"
@@ -49,6 +49,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList()) var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList()) var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
@@ -96,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList() visits = emptyList()
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -112,6 +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
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -121,6 +125,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeVisitView() { fun closeVisitView() {
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -132,6 +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
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -189,25 +195,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
inventory = repository.fieldInventory(id, search, parentId).data val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
} }
} }
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val id = visit?.id ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
fieldTypes = repository.fieldTypes(id, parentId).data val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
} }
} }
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección." notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id) selectedAct = actsRepository.ensureAsset(draft.id, item.id)
@@ -236,7 +251,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
error = "Elegí una familia técnica o la opción Otro / no catalogado." error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return return
} }
launchBusy { launchBusy {
@@ -254,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
} }
} }
@@ -278,7 +293,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id) selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable." notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
@@ -311,7 +326,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
"Fotografía registrada." "Fotografía registrada."
} }
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) { if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id) selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
@@ -0,0 +1,103 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
data class MobilePlanningAsset(
val id: String,
val code: String,
val name: String,
)
data class MobilePlanningAssetResponse(
val data: List<MobilePlanningAsset> = emptyList(),
)
data class OpenMobileInspectionRequest(
val operationalAreaId: String,
val operatorCompanyId: String,
)
private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas")
suspend fun areas(
@Header("Authorization") authorization: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("areaId") areaId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open")
suspend fun open(
@Header("Authorization") authorization: String,
@Body request: OpenMobileInspectionRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}")
}
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId)
}
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
api.open(
"Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
} }
val source = CancellationTokenSource() val 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() }
} }
@@ -92,7 +92,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
) )
model.fieldFindingOptions != null -> FieldFindingScreen(model) model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model) model.visit != null -> F3VisitRoot(model)
else -> DhApp(model) else -> MobileHomeScreen(model)
} }
} }
} }
@@ -431,13 +431,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine 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,231 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
) {
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,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>
@@ -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(23, BuildConfig.VERSION_CODE)
assertEquals("0.15.1-debug", BuildConfig.VERSION_NAME)
}
@Test
fun fieldBuildTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
+8 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "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
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.26.0-1", "version": "0.29.0-1",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -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"
} }
} }
@@ -26,6 +26,9 @@ import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
@@ -39,6 +42,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsController, AssetsController,
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController, InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, FieldInventoryMergeController,
@@ -56,6 +60,8 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService, InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
MergedInventoryDossierService, MergedInventoryDossierService,
@@ -170,20 +170,9 @@ export class AssetOperationalRelationsService {
RETURNING id RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory. // F5/F6: changing the Area operator is a temporal relation event only.
// area_company_relations remains the temporal source of truth. // Existing Inventory keeps its creation/historical operator snapshot and
if (dto.relationRole === AreaOrganizationRole.OPERATOR) { // physical hierarchy unchanged. Runtime ownership must resolve this row.
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
const created = await this.loadRelation(manager, row.id); const created = await this.loadRelation(manager, row.id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -192,7 +181,7 @@ export class AssetOperationalRelationsService {
entityId: row.id, entityId: row.id,
afterData: this.auditView(created), afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true } ? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
: undefined, : undefined,
}, manager); }, manager);
return created; return created;
@@ -223,8 +212,8 @@ export class AssetOperationalRelationsService {
}); });
} }
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. // F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory. // Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(` await manager.query(`
UPDATE area_company_relations UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP, SET valid_until = CURRENT_TIMESTAMP,
@@ -137,21 +137,41 @@ export class AssetTemporalService {
if (!row) throw temporalAssetNotFound(); if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at); const asOf = new Date(query.at);
row.asOf = asOf; row.asOf = asOf;
// Physical context is reconstructed from the Inventory history, while the
// operator is resolved independently from the temporal Area↔Empresa ledger.
// This keeps historical operator changes from rewriting the Inventory.
const [context] = (await this.dataSource.query(` const [context] = (await this.dataSource.query(`
SELECT SELECT
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany" CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',operator_company.id,'code',operator_company.code,'name',operator_company.name
) END AS "operatorCompany"
FROM asset_context_history history FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN assets company ON company.id=history.operator_company_id LEFT JOIN LATERAL (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id=history.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
LIMIT 1
) operator_relation ON true
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id
WHERE history.asset_id=$1 WHERE history.asset_id=$1
AND history.valid_from <= $2 AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2) AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC ORDER BY history.valid_from DESC
LIMIT 1 LIMIT 1
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>; `, [assetId, asOf])) as Array<{
parent: Record<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | null;
}>;
if (context && row.snapshot) { if (context && row.snapshot) {
row.snapshot = { row.snapshot = {
...row.snapshot, ...row.snapshot,
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto { export class ChangeAssetContextDto {
@IsOptional() @IsOptional()
@@ -10,8 +10,13 @@ export class ChangeAssetContextDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string | null; operationalAreaId?: string | null;
/**
* Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física
* del Inventario de la Operadora temporal del Área, por lo que este valor no
* puede modificarse desde un cambio de contexto del Inventario.
*/
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional() @IsOptional()
@@ -9,8 +9,11 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
export const INVENTORY_STRUCTURE_KINDS = [ export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA', 'EMPRESA',
'DEPARTAMENTO',
'AREA', 'AREA',
'YACIMIENTO', 'YACIMIENTO',
'INSTALACION', 'INSTALACION',
@@ -22,8 +22,10 @@ export class CreateInventoryFamilyDto {
name!: string; name!: string;
@IsOptional() @IsOptional()
@IsUUID('4') @IsArray()
parentFamilyId?: string | null; @ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -42,8 +44,10 @@ export class UpdateInventoryFamilyDto {
name?: string; name?: string;
@IsOptional() @IsOptional()
@IsUUID('4') @IsArray()
parentFamilyId?: string | null; @ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -0,0 +1,103 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const DATA_TYPES = ['TEXT','NUMBER','BOOLEAN','DATE','DATETIME','SELECT'] as const;
export type InventoryFamilyAttributeDataType = typeof DATA_TYPES[number];
export class CreateInventoryFamilyAttributeDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsString()
@Matches(/^[a-z][a-z0-9_]*$/)
@MaxLength(80)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name!: string;
@IsIn(DATA_TYPES)
dataType!: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@ValidateIf((object) => object.dataType === 'SELECT')
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
export class UpdateInventoryFamilyAttributeDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(160)
name?: string;
@IsOptional()
@IsIn(DATA_TYPES)
dataType?: InventoryFamilyAttributeDataType;
@IsOptional()
@IsBoolean()
isRequired?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@ValidateIf((_object, value) => value !== null && value !== undefined)
@IsString()
@MaxLength(40)
unit?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsString({ each: true })
@MaxLength(160, { each: true })
options?: string[] | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
sortOrder?: number;
}
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities'; import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto { export class ListAssetTreeQueryDto {
@@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string; operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
* filtrar por el snapshot histórico de Empresa del Inventario.
*/
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string; operatorCompanyId?: string;
@IsOptional() @IsOptional()
@@ -1,6 +1,7 @@
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsEmpty,
IsEnum, IsEnum,
IsInt, IsInt,
IsOptional, IsOptional,
@@ -66,7 +67,12 @@ export class ListAssetsQueryDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string; operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada con consumidores históricos.
* F5/F6 no permite buscar ownership actual por el snapshot de Empresa del
* Inventario; la navegación Área↔Operadora usa area_company_relations.
*/
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string; operatorCompanyId?: string;
} }
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsEmpty,
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
@@ -47,8 +48,12 @@ export class UpdateAssetDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string | null; operationalAreaId?: string | null;
/**
* Snapshot histórico/de compatibilidad. La Operadora vigente pertenece a la
* relación temporal Área↔Empresa y no puede editarse como propiedad física.
*/
@IsOptional() @IsOptional()
@IsUUID('4') @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional() @IsOptional()
@@ -0,0 +1,6 @@
import { IsObject } from 'class-validator';
export class UpdateInventoryTechnicalValuesDto {
@IsObject()
values!: Record<string, unknown>;
}
@@ -13,6 +13,16 @@ export class InventoryBrowserController {
return this.inventoryBrowser.items(query); return this.inventoryBrowser.items(query);
} }
@Get('departments')
departments(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.departments(query);
}
@Get('companies')
companies(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.companies(query);
}
@Get('areas') @Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) { areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query); return this.inventoryBrowser.areas(query);
@@ -16,9 +16,9 @@ export class InventoryBrowserService {
async items(query: InventoryBrowserQueryDto) { async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = []; const params: unknown[] = [];
const conditions = [ const conditions = [
'asset.is_inventory_instance=true',
"asset.information_status<>'INACTIVE'", "asset.information_status<>'INACTIVE'",
'type.is_active=true', 'type.is_active=true',
"(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))",
]; ];
const add = (value: unknown): string => { const add = (value: unknown): string => {
params.push(value); params.push(value);
@@ -36,15 +36,21 @@ export class InventoryBrowserService {
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'"); if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`); if (query.operationalAreaId) {
const area = add(query.operationalAreaId);
conditions.push(`(asset.id=${area}::uuid OR asset.operational_area_id=${area}::uuid)`);
}
if (query.operatorCompanyId) { if (query.operatorCompanyId) {
const company = add(query.operatorCompanyId); const company = add(query.operatorCompanyId);
conditions.push(`EXISTS ( conditions.push(`(
SELECT 1 FROM area_company_relations relation asset.id=${company}::uuid
WHERE relation.area_id=asset.operational_area_id OR EXISTS (
AND relation.company_id=${company}::uuid SELECT 1 FROM area_company_relations relation
AND relation.relation_role='OPERATOR' WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.id)
AND relation.valid_until IS NULL AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)
)`); )`);
} }
@@ -76,7 +82,8 @@ export class InventoryBrowserService {
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id WHERE relation.area_id=COALESCE(asset.operational_area_id,
CASE WHEN type.operational_role='AREA' THEN asset.id ELSE NULL END)
AND relation.relation_role='OPERATOR' AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC ORDER BY relation.valid_from DESC,relation.created_at DESC
@@ -84,7 +91,8 @@ export class InventoryBrowserService {
) AS "operatorCompany", ) AS "operatorCompany",
asset.information_status AS "informationStatus", asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus", asset.operational_status AS "operationalStatus",
0::integer AS "childrenCount", (SELECT COUNT(*)::integer FROM assets child
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType", CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount", (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
@@ -104,7 +112,14 @@ export class InventoryBrowserService {
LIMIT 1 LIMIT 1
) geometry_type ON true ) geometry_type ON true
WHERE ${where} WHERE ${where}
ORDER BY asset.name,asset.code ORDER BY CASE
WHEN lower(type.code)='departamento' THEN 0
WHEN type.operational_role='COMPANY' THEN 1
WHEN lower(type.code)='area' THEN 2
WHEN lower(type.code)='yacimiento' THEN 3
WHEN lower(type.code)='instalacion' THEN 4
WHEN lower(type.code)='subinstalacion' THEN 5 ELSE 9 END,
asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam} LIMIT ${limit} OFFSET ${offsetParam}
`,params); `,params);
@@ -119,28 +134,30 @@ export class InventoryBrowserService {
}; };
} }
async areas(query: InventoryBrowserQueryDto) { async departments(query: InventoryBrowserQueryDto) {
const params: unknown[] = []; const params: unknown[] = [];
const conditions = [ const conditions = [
"type.operational_role='AREA'", "lower(type.code)='departamento'",
"area.information_status<>'INACTIVE'", "department.information_status<>'INACTIVE'",
'type.is_active=true', 'type.is_active=true',
]; ];
const add = (value: unknown): string => { const add = (value: unknown): string => {
params.push(value); params.push(value);
return `$${params.length}`; return `$${params.length}`;
}; };
if (query.search?.trim()) { if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`); const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`); conditions.push(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`);
} }
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) { if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId); const p = add(query.operatorCompanyId);
conditions.push(`EXISTS ( conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation SELECT 1
WHERE relation.area_id=area.id FROM assets area
JOIN asset_types atype ON atype.id=area.asset_type_id
JOIN area_company_relations relation ON relation.area_id=area.id
WHERE area.parent_id=department.id
AND lower(atype.code)='area'
AND relation.company_id=${p}::uuid AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR' AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL AND relation.valid_until IS NULL
@@ -149,41 +166,86 @@ export class InventoryBrowserService {
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
SELECT SELECT
area.id,area.code,area.name,area.common_name AS "commonName", department.id,department.code,department.name,department.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus", department.information_status AS "informationStatus",
area.operational_status AS "operationalStatus", department.operational_status AS "operationalStatus",
( (SELECT COUNT(*)::integer FROM assets area
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) JOIN asset_types atype ON atype.id=area.asset_type_id
FROM area_company_relations relation WHERE area.parent_id=department.id AND lower(atype.code)='area'
JOIN assets company ON company.id=relation.company_id AND area.information_status<>'INACTIVE') AS "areaCount",
WHERE relation.area_id=area.id (SELECT COUNT(*)::integer FROM assets child
AND relation.relation_role='OPERATOR' WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount"
AND relation.valid_until IS NULL FROM assets department
ORDER BY relation.valid_from DESC,relation.created_at DESC JOIN asset_types type ON type.id=department.asset_type_id
LIMIT 1 WHERE ${conditions.join(' AND ')}
) AS "currentOperator", ORDER BY department.name,department.code
( `, params);
SELECT COUNT(*)::integer return { data, meta: { count: data.length } };
FROM assets yacimiento }
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id async companies(query: InventoryBrowserQueryDto) {
AND lower(ytype.code)='yacimiento' const params: unknown[] = [];
AND yacimiento.information_status<>'INACTIVE' const conditions = [
) AS "yacimientoCount", "type.operational_role='COMPANY'",
( "company.information_status<>'INACTIVE'",
SELECT COUNT(*)::integer 'type.is_active=true',
FROM assets inventory ];
WHERE inventory.is_inventory_instance=true if (query.search?.trim()) {
AND inventory.information_status<>'INACTIVE' params.push(`%${query.search.trim()}%`);
AND inventory.operational_area_id=area.id conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`);
) AS "inventoryCount" }
FROM assets area const data = await this.dataSource.query(`
JOIN asset_types type ON type.id=area.asset_type_id SELECT company.id,company.code,company.name,company.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
company.information_status AS "informationStatus",
(SELECT COUNT(*)::integer FROM area_company_relations relation
WHERE relation.company_id=company.id AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL) AS "areaCount"
FROM assets company
JOIN asset_types type ON type.id=company.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY company.name,company.code
`,params);
return { data,meta:{count:data.length} };
}
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => { params.push(value); return `$${params.length}`; };
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
}
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL)`);
}
const data = await this.dataSource.query(`
SELECT area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus",area.operational_status AS "operationalStatus",
(SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC LIMIT 1) AS "currentOperator",
(SELECT COUNT(*)::integer FROM assets yacimiento JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE') AS "yacimientoCount",
(SELECT COUNT(*)::integer FROM assets inventory
WHERE inventory.information_status<>'INACTIVE' AND inventory.operational_area_id=area.id) AS "inventoryCount"
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code ORDER BY area.name,area.code
`, params); `, params);
return { data, meta: { count: data.length } }; return { data, meta: { count: data.length } };
} }
@@ -199,9 +261,6 @@ export class InventoryBrowserService {
"asset.information_status<>'INACTIVE'", "asset.information_status<>'INACTIVE'",
'type.is_active=true', 'type.is_active=true',
]; ];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) { if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`); params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`; const p = `$${params.length}`;
@@ -223,20 +282,12 @@ export class InventoryBrowserService {
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level 'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END AS "inventoryFamily", ) END AS "inventoryFamily",
( (SELECT COUNT(*)::integer FROM assets child
SELECT COUNT(*)::integer WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount",
FROM assets child CASE WHEN family.id IS NULL THEN 0 ELSE (
WHERE child.parent_id=asset.id SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping
AND child.information_status<>'INACTIVE' WHERE mapping.inventory_family_id=family.id
AND ( ) END AS "findingCount",
lower(type.code)='area'
OR child.is_inventory_instance=true
OR EXISTS (
SELECT 1 FROM asset_types child_type
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
)
)
) AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt" asset.updated_at AS "updatedAt"
FROM assets asset FROM assets asset
@@ -263,10 +314,10 @@ export class InventoryBrowserService {
if (!parent) { if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' }); throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
} }
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) { if (!['departamento','area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({ throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID', code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación', message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación',
}); });
} }
return parent; return parent;
@@ -274,6 +325,7 @@ export class InventoryBrowserService {
private allowedChildType(typeCode: string): string | null { private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) { switch (typeCode.toLowerCase()) {
case 'departamento': return 'area';
case 'area': return 'yacimiento'; case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion'; case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion'; case 'instalacion': return 'subinstalacion';
@@ -17,6 +17,10 @@ import {
ReplaceInventoryFamilyFindingsDto, ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto, UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto'; } from './dto/inventory-family-admin.dto';
import {
CreateInventoryFamilyAttributeDto,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families') @Controller('inventory-families')
@@ -50,6 +54,35 @@ export class InventoryFamilyCatalogController {
return this.families.update(familyId,dto,principal,request); return this.families.update(familyId,dto,principal,request);
} }
@Get(':familyId/attributes')
@RequirePermissions('asset_types.read')
attributes(@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string) {
return this.families.attributes(familyId);
}
@Post(':familyId/attributes')
@RequirePermissions('asset_types.manage')
createAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:CreateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.createAttribute(familyId,dto,principal,request);
}
@Patch(':familyId/attributes/:attributeId')
@RequirePermissions('asset_types.manage')
updateAttribute(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Param('attributeId',new ParseUUIDPipe({version:'4'})) attributeId:string,
@Body() dto:UpdateInventoryFamilyAttributeDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.updateAttribute(familyId,attributeId,dto,principal,request);
}
@Put(':familyId/findings') @Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage') @RequirePermissions('finding_catalog.manage')
replaceFindings( replaceFindings(
@@ -15,7 +15,13 @@ import type {
ReplaceInventoryFamilyFindingsDto, ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto, UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto'; } from './dto/inventory-family-admin.dto';
import type {
CreateInventoryFamilyAttributeDto,
InventoryFamilyAttributeDataType,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = { type FamilyRow = {
id: string; id: string;
code: string; code: string;
@@ -24,12 +30,24 @@ type FamilyRow = {
informationLabels: string[]; informationLabels: string[];
sourceReference: string | null; sourceReference: string | null;
isActive: boolean; isActive: boolean;
parentFamilyId: string | null; parentFamilyIds: string[];
parentFamilyCode: string | null; parentFamilies: FamilyParent[];
parentFamilyName: string | null;
assetCount: number; assetCount: number;
findingCount: number; findingCount: number;
findingItemIds: string[]; findingItemIds: string[];
technicalAttributeCount: number;
};
type AttributeRow = {
id: string;
inventoryFamilyId: string;
code: string;
name: string;
dataType: InventoryFamilyAttributeDataType;
isRequired: boolean;
isActive: boolean;
unit: string | null;
options: string[] | null;
sortOrder: number;
}; };
@Injectable() @Injectable()
@@ -40,27 +58,7 @@ export class InventoryFamilyCatalogService {
) {} ) {}
async listAdmin() { async listAdmin() {
const data = await this.dataSource.query(` const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data }; return { data };
} }
@@ -80,27 +78,28 @@ export class InventoryFamilyCatalogService {
return { family, items, count: items.length }; return { family, items, count: items.length };
} }
async attributes(familyId: string) {
const family = await this.family(familyId, false);
const items = await this.attributeRows(this.dataSource.manager, familyId);
return { family, items, count: items.length };
}
async create( async create(
dto: CreateInventoryFamilyDto, dto: CreateInventoryFamilyDto,
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
) { ) {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null); const parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`; const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(` const [inserted] = (await manager.query(`
INSERT INTO inventory_families( INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true) ) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
RETURNING id RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>; `,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario'); if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) { await this.replaceParents(manager, inserted.id, parentIds);
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager); const created = await this.family(inserted.id,false,manager);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal,request), ...administrationAuditContext(principal,request),
@@ -108,7 +107,7 @@ export class InventoryFamilyCatalogService {
entityType: 'inventory_family', entityType: 'inventory_family',
entityId: inserted.id, entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>, afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' }, metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' },
},manager); },manager);
return created; return created;
}); });
@@ -122,10 +121,13 @@ export class InventoryFamilyCatalogService {
) { ) {
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true); const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined const nextParentIds = dto.parentFamilyIds === undefined
? before.parentFamilyId ? before.parentFamilyIds
: dto.parentFamilyId; : dto.parentFamilyIds;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId); const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId);
if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) {
await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds);
}
await manager.query(` await manager.query(`
UPDATE inventory_families SET UPDATE inventory_families SET
name=COALESCE($2::varchar,name), name=COALESCE($2::varchar,name),
@@ -139,15 +141,7 @@ export class InventoryFamilyCatalogService {
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)), dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null, dto.isActive ?? null,
]); ]);
if (before.level==='SUBINSTALLATION') { if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager); const after = await this.family(familyId,false,manager);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal,request), ...administrationAuditContext(principal,request),
@@ -162,6 +156,85 @@ export class InventoryFamilyCatalogService {
}); });
} }
async createAttribute(
familyId: string,
dto: CreateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const options = this.attributeOptions(dto.dataType,dto.options);
try {
const [created] = (await manager.query(`
INSERT INTO inventory_family_attribute_definitions(
inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by
) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid)
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null,
options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[];
if (!created) throw new Error('No se pudo crear el campo técnico');
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:created.id,
afterData:created as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId},
},manager);
return created;
} catch (error) {
if (this.isUniqueViolation(error)) throw new ConflictException({
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
message:'Ya existe un campo técnico con ese código en la clasificación',
});
throw error;
}
});
}
async updateAttribute(
familyId: string,
attributeId: string,
dto: UpdateInventoryFamilyAttributeDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
await this.family(familyId,false,manager,true);
const before = await this.attribute(manager,familyId,attributeId,true);
const nextType = dto.dataType ?? before.dataType;
const nextOptions = dto.options === undefined
? this.attributeOptions(nextType,before.options ?? undefined)
: this.attributeOptions(nextType,dto.options ?? undefined);
const [after] = (await manager.query(`
UPDATE inventory_family_attribute_definitions SET
name=COALESCE($3::varchar,name),
data_type=COALESCE($4::asset_attribute_data_type,data_type),
is_required=COALESCE($5::boolean,is_required),
is_active=COALESCE($6::boolean,is_active),
unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END,
options=$9::jsonb,
sort_order=COALESCE($10::integer,sort_order),
updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
`,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null,
dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions),
dto.sortOrder ?? null,principal.userId])) as AttributeRow[];
if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_attribute',entityId:attributeId,
beforeData:before as unknown as Record<string,unknown>,afterData:after as unknown as Record<string,unknown>,
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId},
},manager);
return after;
});
}
async replaceFindings( async replaceFindings(
familyId: string, familyId: string,
dto: ReplaceInventoryFamilyFindingsDto, dto: ReplaceInventoryFamilyFindingsDto,
@@ -178,12 +251,10 @@ export class InventoryFamilyCatalogService {
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>; `,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) { if (Number(count?.total ?? 0)!==uniqueIds.length) throw new BadRequestException({
throw new BadRequestException({ code:'INVENTORY_FAMILY_FINDING_INVALID',
code:'INVENTORY_FAMILY_FINDING_INVALID', message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo', });
});
}
} }
const beforeIds=family.findingItemIds; const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]); await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
@@ -198,11 +269,9 @@ export class InventoryFamilyCatalogService {
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal,request), ...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED, action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings', entityType:'inventory_family_findings',entityId:familyId,
entityId:familyId, beforeData:{itemIds:beforeIds},afterData:{itemIds:after.findingItemIds},
beforeData:{ itemIds:beforeIds }, metadata:{operation:'INVENTORY_FAMILY_FINDINGS_REPLACED',reason:dto.reason},
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
},manager); },manager);
return this.findingsWithManager(manager,familyId); return this.findingsWithManager(manager,familyId);
}); });
@@ -217,77 +286,150 @@ export class InventoryFamilyCatalogService {
FROM finding_catalog_item_inventory_families mapping FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid WHERE mapping.inventory_family_id=$1::uuid AND item.is_active=true AND category.is_active=true
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]); `,[familyId]);
return {family,items,count:items.length}; return {family,items,count:items.length};
} }
private familySelect(suffix:string) {
return `
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds",
(SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition
WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount"
FROM inventory_families family
${suffix}
`;
}
private async family( private async family(
familyId:string, familyId:string,
activeOnly:boolean, activeOnly:boolean,
manager:EntityManager=this.dataSource.manager, manager:EntityManager=this.dataSource.manager,
lock=false, lock=false,
):Promise<FamilyRow> { ):Promise<FamilyRow> {
const rows=(await manager.query(` const lockClause=lock ? 'FOR UPDATE OF family' : '';
SELECT family.id,family.code,family.name,family.level, const rows=(await manager.query(this.familySelect(`
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''} WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''} ${lockClause}
`,[familyId])) as FamilyRow[]; `),[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({ if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe', code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
}); });
return rows[0]; return rows[0];
} }
private async validateParent( private async validateParents(
manager:EntityManager, manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION', level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null, requestedIds:string[],
ownId:string|null, ownId:string|null,
):Promise<string|null> { ):Promise<string[]> {
const ids=[...new Set(requestedIds)];
if (level==='INSTALLATION') { if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({ if (ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no tiene clasificación padre', message:'Una clasificación de Instalación no lleva compatibilidades padre',
}); });
return null; return [];
} }
if (!parentFamilyId) throw new BadRequestException({ if (!ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED', code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación', message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
}); });
if (parentFamilyId===ownId) throw new BadRequestException({ if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre', code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
}); });
const rows=(await manager.query(` const [count]=(await manager.query(`
SELECT id FROM inventory_families SELECT COUNT(*)::integer AS total FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
LIMIT 1 `,[ids])) as Array<{total:number}>;
`,[parentFamilyId])) as Array<{id:string}>; if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
if (!rows[0]) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID', code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa', message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
}); });
return parentFamilyId; return ids;
}
private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentIds.length) await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id)
ON CONFLICT (child_family_id,parent_family_id) DO NOTHING
`,[familyId,parentIds]);
}
private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) {
const rows=await manager.query(`
SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total
FROM assets child
JOIN assets parent ON parent.id=child.parent_id
WHERE child.inventory_family_id=$1::uuid
AND parent.inventory_family_id IS NOT NULL
AND NOT (parent.inventory_family_id=ANY($2::uuid[]))
GROUP BY child.name,parent.name
ORDER BY total DESC
LIMIT 1
`,[childFamilyId,nextParentIds]);
if (rows[0]) throw new ConflictException({
code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE',
message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`,
});
}
private async attributeRows(manager:EntityManager,familyId:string):Promise<AttributeRow[]> {
return manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[familyId]) as Promise<AttributeRow[]>;
}
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
const rows=(await manager.query(`
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
${lock ? 'FOR UPDATE' : ''}
`,[attributeId,familyId])) as AttributeRow[];
if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
return rows[0];
}
private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null {
if (type!=='SELECT') return null;
const values=[...new Map((raw ?? []).map((value) => {
const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const;
}).filter(([,value]) => Boolean(value))).values()];
if (!values.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED',
message:'Un campo de lista necesita al menos una opción',
});
return values;
} }
private cleanLabels(labels:string[]):string[] { private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>(); const unique=new Map<string,string>();
for (const raw of labels) { for (const raw of labels) {
const clean=raw.trim(); const clean=raw.trim(); if (!clean) continue;
if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR'); const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean); if (!unique.has(identity)) unique.set(identity,clean);
} }
@@ -296,4 +438,8 @@ export class InventoryFamilyCatalogService {
}); });
return [...unique.values()]; return [...unique.values()];
} }
private isUniqueViolation(error:unknown) {
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
}
} }
@@ -20,6 +20,7 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service'; import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string }; type StructureTypeRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = { type FamilyRow = {
id: string; id: string;
code: string; code: string;
@@ -27,9 +28,8 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION'; level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null; legacyTypeCode: string | null;
informationLabels: string[]; informationLabels: string[];
parentFamilyId: string | null; parentFamilyIds: string[];
parentFamilyCode: string | null; parentFamilies: FamilyParent[];
parentFamilyName: string | null;
}; };
type ParentRow = { type ParentRow = {
id: string; id: string;
@@ -38,8 +38,10 @@ type ParentRow = {
typeCode: string; typeCode: string;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
}; };
type IdRow = { id: string };
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = { const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area', AREA: 'area',
YACIMIENTO: 'yacimiento', YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion', INSTALACION: 'instalacion',
@@ -47,7 +49,8 @@ const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, stri
}; };
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = { const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null, EMPRESA: null,
AREA: null, DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area', YACIMIENTO: 'area',
INSTALACION: 'yacimiento', INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion', SUBINSTALACION: 'instalacion',
@@ -70,22 +73,24 @@ export class InventoryStructureService {
SELECT id,code,name SELECT id,code,name
FROM asset_types FROM asset_types
WHERE ( WHERE (
lower(code) IN ('area','yacimiento','instalacion','subinstalacion') lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
OR operational_role='COMPANY' OR operational_role='COMPANY'
) AND is_active=true ) AND is_active=true
ORDER BY CASE ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0 WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='area' THEN 1 WHEN lower(code)='departamento' THEN 1
WHEN lower(code)='yacimiento' THEN 2 WHEN lower(code)='area' THEN 2
WHEN lower(code)='instalacion' THEN 3 WHEN lower(code)='yacimiento' THEN 3
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END WHEN lower(code)='instalacion' THEN 4
WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END
`)) as StructureTypeRow[]; `)) as StructureTypeRow[];
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase())); const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
const departamento = types.find((item) => item.code.toLowerCase()==='departamento');
const area = types.find((item) => item.code.toLowerCase()==='area'); const area = types.find((item) => item.code.toLowerCase()==='area');
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento'); const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion'); const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion'); const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) { if (!company || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({ throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa', message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
@@ -96,10 +101,15 @@ export class InventoryStructureService {
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels", family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true WHERE family.is_active=true
ORDER BY family.level,family.name,family.code ORDER BY family.level,family.name,family.code
`)) as FamilyRow[]; `)) as FamilyRow[];
@@ -109,7 +119,8 @@ export class InventoryStructureService {
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false }, { kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
], ],
levels: [ levels: [
{ kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false }, { kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false },
{ kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false }, { kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true }, { kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true }, { kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
@@ -121,7 +132,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) { async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind; const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') { if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
throw new BadRequestException({ throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre', message: 'El nivel indicado no requiere un registro padre',
@@ -153,7 +164,7 @@ export class InventoryStructureService {
AND asset.information_status<>'INACTIVE' AND asset.information_status<>'INACTIVE'
${searchSql} ${searchSql}
ORDER BY asset.name,asset.code ORDER BY asset.name,asset.code
LIMIT 80 LIMIT 100
`, parameters); `, parameters);
return { data: rows }; return { data: rows };
} }
@@ -169,7 +180,9 @@ export class InventoryStructureService {
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null; const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
? await this.resolveAreaId(manager, parent)
: null;
const inserted = (await manager.query(` const inserted = (await manager.query(`
INSERT INTO assets ( INSERT INTO assets (
@@ -193,9 +206,9 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT, AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN, AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL, AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5', dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null, family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
principal.userId, principal.userId,
])) as Array<{ id: string }>; ])) as Array<{ id: string }>;
const id = inserted[0]?.id; const id = inserted[0]?.id;
@@ -210,11 +223,7 @@ export class InventoryStructureService {
} }
const versionNumber = await this.history.capture( const versionNumber = await this.history.capture(
manager, manager,id,AssetVersionChangeType.CREATED,principal,request,
id,
AssetVersionChangeType.CREATED,
principal,
request,
); );
await manager.query(` await manager.query(`
INSERT INTO asset_context_history ( INSERT INTO asset_context_history (
@@ -222,38 +231,28 @@ export class InventoryStructureService {
change_reason,asset_version_number,source,request_id,created_by change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7) ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [ `, [
id, id,parent?.id ?? null,operationalAreaId,
parent?.id ?? null, dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
operationalAreaId, versionNumber,request.requestId,principal.userId,
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
versionNumber,
request.requestId,
principal.userId,
]); ]);
const created = await this.loadView(manager, id); const created = await this.loadView(manager, id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED, action: AuditAction.ASSET_CREATED,
entityType: 'asset', entityType: 'asset',entityId: id,
entityId: id,
afterData: created as unknown as Record<string, unknown>, afterData: created as unknown as Record<string, unknown>,
metadata: { metadata: {
versionNumber, versionNumber,inventoryStructureKind: dto.kind,
inventoryStructureKind: dto.kind, inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false, operatorOwnership: false,
}, },
}, manager); }, manager);
return created; return created;
}); });
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) { if (isUniqueViolation(error)) throw new ConflictException({
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
code: 'ASSET_CODE_ALREADY_EXISTS', });
message: 'Ya existe un registro con ese código',
});
}
throw error; throw error;
} }
} }
@@ -264,12 +263,9 @@ export class InventoryStructureService {
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`; : `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]]; const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[]; const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) { if (!rows[0]) throw new ConflictException({
throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', });
message: `El nivel ${kind} no está configurado`,
});
}
return rows[0]; return rows[0];
} }
@@ -280,22 +276,18 @@ export class InventoryStructureService {
): Promise<ParentRow | null> { ): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind]; const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) { if (!expectedType) {
if (parentId) { if (parentId) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: kind === 'EMPRESA'
message: kind === 'EMPRESA' ? 'Una Empresa es un maestro independiente y no puede tener padre'
? 'Una Empresa es un maestro independiente y no puede tener padre' : 'Un Departamento es un registro raíz y no puede tener padre',
: 'Un Área es un registro raíz y no puede tener padre', });
});
}
return null; return null;
} }
if (!parentId) { if (!parentId) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED', message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`, });
});
}
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId" asset.inventory_family_id AS "inventoryFamilyId"
@@ -306,12 +298,10 @@ export class InventoryStructureService {
`, [parentId])) as ParentRow[]; `, [parentId])) as ParentRow[];
const parent = rows[0]; const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' }); if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) { if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
code: 'INVENTORY_STRUCTURE_PARENT_INVALID', message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación', });
});
}
return parent; return parent;
} }
@@ -330,12 +320,10 @@ export class InventoryStructureService {
LIMIT 1 LIMIT 1
`,[parent.id])) as IdRow[]; `,[parent.id])) as IdRow[];
const areaId=rows[0]?.id; const areaId=rows[0]?.id;
if (!areaId) { if (!areaId) throw new ConflictException({
throw new ConflictException({ code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING', message:'La ubicación seleccionada no pertenece a un Área válida',
message:'La ubicación seleccionada no pertenece a un Área válida', });
});
}
return areaId; return areaId;
} }
@@ -349,49 +337,59 @@ export class InventoryStructureService {
if (!expectedLevel) { if (!expectedLevel) {
if (familyId) throw new BadRequestException({ if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Empresa, Área y Yacimiento no llevan familia técnica', message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
}); });
return null; return null;
} }
if (!familyId) throw new BadRequestException({ if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`, message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
}); });
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
family.information_labels AS "informationLabels", COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1 LIMIT 1
`, [familyId])) as FamilyRow[]; `, [familyId])) as FamilyRow[];
const family = rows[0]; const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' }); if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({ if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID', code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La familia técnica no corresponde al nivel seleccionado', message: 'La clasificación técnica no corresponde al nivel seleccionado',
}); });
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) { if (kind === 'SUBINSTALACION') {
throw new BadRequestException({ if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida',
});
const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada', message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
}); });
} }
return family; return family;
} }
private generatedCode(kind: InventoryStructureKind, name: string): string { private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; const prefix = kind === 'EMPRESA' ? 'EMP'
const readable = name : kind === 'DEPARTAMENTO' ? 'DEP'
.normalize('NFD') : kind === 'AREA' ? 'AREA'
.replace(/[\u0300-\u036f]/g, '') : kind === 'YACIMIENTO' ? 'YAC'
.toUpperCase() : kind === 'INSTALACION' ? 'INST'
.replace(/[^A-Z0-9]+/g, '-') : 'SUB';
.replace(/^-+|-+$/g, '') const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.slice(0, 48) || 'REGISTRO'; .replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120); return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
} }
@@ -417,5 +415,3 @@ export class InventoryStructureService {
return rows[0]; return rows[0];
} }
} }
type IdRow = { id: string };
@@ -0,0 +1,28 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
@Controller('assets/:assetId/technical-values')
export class InventoryTechnicalValuesController {
constructor(private readonly technical:InventoryTechnicalValuesService) {}
@Get()
@RequirePermissions('assets.read')
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string) {
return this.technical.get(assetId);
}
@Put()
@RequirePermissions('assets.update')
replace(
@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,
@Body() dto:UpdateInventoryTechnicalValuesDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.technical.replace(assetId,dto,principal,request);
}
}
@@ -0,0 +1,118 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
type DefinitionRow = {
id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT';
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
};
type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null };
@Injectable()
export class InventoryTechnicalValuesService {
constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {}
async get(assetId:string) {
return this.load(this.dataSource.manager,assetId);
}
async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) {
return this.dataSource.transaction(async(manager) => {
const before=await this.load(manager,assetId,true);
const definitions=before.definitions as DefinitionRow[];
const definitionById=new Map(definitions.map((definition)=>[definition.id,definition]));
const normalized:Record<string,unknown>={};
for (const [definitionId,raw] of Object.entries(dto.values)) {
const definition=definitionById.get(definitionId);
if (!definition || !definition.isActive) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
});
const value=this.normalize(definition,raw);
if (value!==undefined) normalized[definitionId]=value;
}
for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) {
if (!(definition.id in normalized)) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`,
});
}
await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]);
const entries=Object.entries(normalized);
if (entries.length) await manager.query(`
INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by)
SELECT $1::uuid,item.definition_id,item.value,$3::uuid
FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb)
`,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]);
const after=await this.load(manager,assetId);
await this.audit.record({
...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED,
entityType:'asset_inventory_technical_values',entityId:assetId,
beforeData:{values:before.values},afterData:{values:after.values},
metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id},
},manager);
return after;
});
}
private async load(manager:EntityManager,assetId:string,lock=false) {
const rows=(await manager.query(`
SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId",
family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel"
FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''}
`,[assetId])) as AssetRow[];
const asset=rows[0];
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
});
const definitions=(await manager.query(`
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
unit,options,sort_order AS "sortOrder"
FROM inventory_family_attribute_definitions
WHERE inventory_family_id=$1::uuid
ORDER BY is_active DESC,sort_order,name,code
`,[asset.inventoryFamilyId])) as DefinitionRow[];
const valueRows=await manager.query(`
SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid
`,[assetId]) as Array<{id:string;value:unknown}>;
return {
assetId,
family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel},
definitions,
values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])),
};
}
private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined {
if (raw===null || raw===undefined || raw==='') return undefined;
switch(definition.dataType) {
case 'TEXT': {
if (typeof raw!=='string') return this.invalid(definition);
const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value;
}
case 'NUMBER': {
const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN;
if (!Number.isFinite(value)) return this.invalid(definition); return value;
}
case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw;
case 'DATE': {
if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition);
return raw;
}
case 'DATETIME': {
if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString();
}
case 'SELECT': {
if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw;
}
}
}
private invalid(definition:DefinitionRow):never {
throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`});
}
}
@@ -30,6 +30,8 @@ export enum InspectionDeadlineBasis {
GEDO_DATE = 'GEDO_DATE', GEDO_DATE = 'GEDO_DATE',
} }
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
@Entity({ name: 'inspection_acts' }) @Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -0,0 +1,168 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F51CleanManualInventory1790087400000 implements MigrationInterface {
name = 'F51CleanManualInventory1790087400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// F5.1 is an intentional clean-start cut. The deployment process creates a
// full database backup before migrations, so old domain data is recovered
// from that backup rather than by pretending a destructive migration can
// reconstruct historical rows.
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
// Remove all operational/domain instances and every table that depends on
// them (visits, acts, findings, reports, versions, media, relations, etc.).
// Users/roles/permissions and technical configuration are deliberately not
// part of this TRUNCATE.
await queryRunner.query('TRUNCATE TABLE assets CASCADE');
// Imported territorial/source material must not silently repopulate or
// influence the new manually curated structure.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
await queryRunner.query('TRUNCATE TABLE source_documents CASCADE');
// Start the classification ↔ finding applicability review from zero while
// preserving both master catalogs themselves.
await queryRunner.query('TRUNCATE TABLE finding_catalog_item_inventory_families');
// Explicitly clear audit history, including authentication/admin events
// accumulated during development. New events continue to be recorded after
// this migration.
await queryRunner.query('TRUNCATE TABLE audit_events');
// Import/reconciliation tables can contain rows not connected to a current
// Asset. Clear every asset_import_* data table without coupling this cut to
// one historical import implementation.
await queryRunner.query(`
DO $$
DECLARE table_name text;
BEGIN
FOR table_name IN
SELECT tablename
FROM pg_tables
WHERE schemaname = current_schema()
AND tablename LIKE 'asset_import_%'
LOOP
EXECUTE format('TRUNCATE TABLE %I CASCADE', table_name);
END LOOP;
END $$;
`);
// F5.1 decouples the physical Inventory tree from Empresa. A structural
// Asset may therefore inherit an Area while no operator has been assigned
// yet. Keep the useful invariant that an operator can never exist without
// an Area, but remove the old all-or-nothing pair requirement.
await queryRunner.query(`
ALTER TABLE asset_context_history
DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair
`);
await queryRunner.query(`
ALTER TABLE asset_context_history
ADD CONSTRAINT chk_asset_context_history_context_pair
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
`);
// Departamento becomes the real root of the physical Inventory tree.
await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT 'departamento','Departamento','Departamento administrativo que contiene Áreas.',true,true,'GENERIC'
WHERE NOT EXISTS (
SELECT 1 FROM asset_types WHERE lower(code)='departamento'
)
`);
await queryRunner.query(`
UPDATE asset_types
SET name='Departamento',
description='Departamento administrativo que contiene Áreas.',
can_be_root=true,
is_active=true,
operational_role='GENERIC',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='departamento'
`);
await queryRunner.query(`
UPDATE asset_types
SET can_be_root=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='area'
`);
// Area has exactly one canonical structural parent kind: Departamento.
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING asset_types child
WHERE rule.child_type_id=child.id AND lower(child.code)='area'
`);
await queryRunner.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id)
SELECT child.id,parent.id
FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)='area' AND lower(parent.code)='departamento'
ON CONFLICT (child_type_id,parent_type_id) DO NOTHING
`);
// Database-level guard: UI/API bugs cannot create an invalid physical tree.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_code text; parent_code text;
BEGIN
SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id;
IF child_code IN ('empresa','organizacion','departamento') THEN
IF NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Departamento son maestros raíz independientes';
END IF;
RETURN NEW;
END IF;
IF child_code NOT IN ('area','yacimiento','instalacion','subinstalacion') THEN
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La estructura requiere Departamento → Área → Yacimiento → Instalación → Subinstalación';
END IF;
SELECT lower(type.code) INTO parent_code
FROM assets parent
JOIN asset_types type ON type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id;
IF (child_code='area' AND parent_code<>'departamento')
OR (child_code='yacimiento' AND parent_code<>'area')
OR (child_code='instalacion' AND parent_code<>'yacimiento')
OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía inválida: Departamento → Área → Yacimiento → Instalación → Subinstalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE TRIGGER trg_f5_canonical_asset_hierarchy
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy()
`);
const rows = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::integer FROM assets) AS assets,
(SELECT COUNT(*)::integer FROM audit_events) AS audits,
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families) AS applicability,
(SELECT COUNT(*)::integer FROM asset_types WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true) AS departments,
(SELECT COUNT(*)::integer
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') AS area_rules
`)) as Array<{ assets: number; audits: number; applicability: number; departments: number; area_rules: number }>;
const check = rows[0];
if (!check || Number(check.assets) !== 0 || Number(check.audits) !== 0 || Number(check.applicability) !== 0
|| Number(check.departments) !== 1 || Number(check.area_rules) !== 1) {
throw new Error(`F5.1 clean-start verification failed: ${JSON.stringify(check ?? {})}`);
}
}
public async down(): Promise<void> {
throw new Error('F5.1 is an intentional destructive clean-start migration. Restore the pre-deploy database backup to recover previous data.');
}
}
@@ -0,0 +1,226 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F6SolidInventoryModel1790091000000 implements MigrationInterface {
name = 'F6SolidInventoryModel1790091000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// A technical Subinstallation family may be valid under several Installation
// families. F3.1 used child_family_id as the PK, which made this relation
// accidentally one-to-one from the child's point of view.
await queryRunner.query(`
DO $$
DECLARE constraint_name text;
BEGIN
SELECT con.conname INTO constraint_name
FROM pg_constraint con
JOIN pg_class rel ON rel.oid=con.conrelid
WHERE rel.relname='inventory_family_parent_rules' AND con.contype='p'
LIMIT 1;
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT %I',constraint_name);
END IF;
END $$;
`);
await queryRunner.query(`
ALTER TABLE inventory_family_parent_rules
ADD CONSTRAINT pk_inventory_family_parent_rules
PRIMARY KEY (child_family_id,parent_family_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inventory_family_parent_rules_parent
ON inventory_family_parent_rules(parent_family_id,child_family_id)
`);
// Protect compatibility semantics at database level.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_inventory_family_compatibility_rule()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_level text; parent_level text;
BEGIN
SELECT level::text INTO child_level FROM inventory_families WHERE id=NEW.child_family_id;
SELECT level::text INTO parent_level FROM inventory_families WHERE id=NEW.parent_family_id;
IF child_level IS DISTINCT FROM 'SUBINSTALLATION' OR parent_level IS DISTINCT FROM 'INSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La compatibilidad debe vincular Subinstalación con Instalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
await queryRunner.query(`
CREATE TRIGGER trg_inventory_family_compatibility_rule
BEFORE INSERT OR UPDATE OF child_family_id,parent_family_id ON inventory_family_parent_rules
FOR EACH ROW EXECUTE FUNCTION enforce_inventory_family_compatibility_rule()
`);
// Family-specific technical fields. Generic asset type attributes remain for
// data genuinely shared by a whole structural level.
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inventory_family_id uuid NOT NULL REFERENCES inventory_families(id) ON DELETE CASCADE,
code varchar(80) NOT NULL,
name varchar(160) NOT NULL,
data_type asset_attribute_data_type NOT NULL,
is_required boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
unit varchar(40) NULL,
options jsonb NULL,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid NULL,
updated_by uuid NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_inventory_family_attribute_code UNIQUE(inventory_family_id,code),
CONSTRAINT chk_inventory_family_attribute_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
CONSTRAINT chk_inventory_family_attribute_options CHECK (
(data_type='SELECT' AND options IS NOT NULL AND jsonb_typeof(options)='array')
OR (data_type<>'SELECT')
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inventory_family_attributes_family
ON inventory_family_attribute_definitions(inventory_family_id,is_active,sort_order,name)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values (
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
definition_id uuid NOT NULL REFERENCES inventory_family_attribute_definitions(id) ON DELETE CASCADE,
value jsonb NOT NULL,
updated_by uuid NULL,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(asset_id,definition_id)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_asset_inventory_attribute_values_definition
ON asset_inventory_attribute_values(definition_id,asset_id)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_inventory_attribute_family()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE asset_family uuid; definition_family uuid;
BEGIN
SELECT inventory_family_id INTO asset_family FROM assets WHERE id=NEW.asset_id;
SELECT inventory_family_id INTO definition_family
FROM inventory_family_attribute_definitions WHERE id=NEW.definition_id AND is_active=true;
IF asset_family IS NULL OR definition_family IS NULL OR asset_family<>definition_family THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El campo técnico no pertenece a la clasificación del Inventario';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
await queryRunner.query(`
CREATE TRIGGER trg_asset_inventory_attribute_family
BEFORE INSERT OR UPDATE OF asset_id,definition_id ON asset_inventory_attribute_values
FOR EACH ROW EXECUTE FUNCTION enforce_asset_inventory_attribute_family()
`);
// Enforce classification level and Subinstallation compatibility on every
// physical Inventory write, not just through the current WEB/API.
// A FIELD_SURVEY+DRAFT row may exist momentarily without family because the
// field-discovery transaction creates the provisional record before the F6
// structure service assigns the family selected by the inspector. Such a row
// remains unusable for findings until classification is present.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f6_asset_family_contract()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_type text; family_level text; parent_family uuid;
BEGIN
SELECT lower(code) INTO child_type FROM asset_types WHERE id=NEW.asset_type_id;
IF child_type IN ('departamento','area','yacimiento') THEN
IF NEW.inventory_family_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento, Área y Yacimiento no llevan clasificación técnica';
END IF;
RETURN NEW;
END IF;
IF child_type='instalacion' THEN
IF NEW.inventory_family_id IS NULL THEN
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación requiere clasificación técnica';
END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Instalación';
END IF;
RETURN NEW;
END IF;
IF child_type='subinstalacion' THEN
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere una Instalación padre';
END IF;
IF NEW.inventory_family_id IS NULL THEN
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere clasificación técnica';
END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW.parent_id;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Subinstalación';
END IF;
IF parent_family IS NULL OR NOT EXISTS (
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación de Subinstalación no es compatible con la Instalación padre';
END IF;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
await queryRunner.query(`
CREATE TRIGGER trg_f6_asset_family_contract
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,inventory_family_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f6_asset_family_contract()
`);
const [check] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::integer FROM pg_constraint c JOIN pg_class r ON r.oid=c.conrelid
WHERE r.relname='inventory_family_parent_rules' AND c.contype='p'
AND pg_get_constraintdef(c.oid) LIKE '%child_family_id, parent_family_id%') AS composite_pk,
to_regclass('inventory_family_attribute_definitions') IS NOT NULL AS family_attributes,
to_regclass('asset_inventory_attribute_values') IS NOT NULL AS family_values
`)) as Array<{ composite_pk:number; family_attributes:boolean; family_values:boolean }>;
if (!check || Number(check.composite_pk)!==1 || !check.family_attributes || !check.family_values) {
throw new Error(`F6 inventory model verification failed: ${JSON.stringify(check ?? {})}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f6_asset_family_contract()');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_asset_inventory_attribute_family()');
await queryRunner.query('DROP TABLE IF EXISTS asset_inventory_attribute_values');
await queryRunner.query('DROP TABLE IF EXISTS inventory_family_attribute_definitions');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inventory_family_compatibility_rule()');
await queryRunner.query('DROP INDEX IF EXISTS idx_inventory_family_parent_rules_parent');
// Old schema allowed only one Installation family per Subinstallation family.
// Keep a deterministic first relation if F6 data must be rolled back.
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_family_parent_rules keep
WHERE rule.child_family_id=keep.child_family_id
AND rule.parent_family_id>keep.parent_family_id
`);
await queryRunner.query('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT IF EXISTS pk_inventory_family_parent_rules');
await queryRunner.query(`
ALTER TABLE inventory_family_parent_rules
ADD CONSTRAINT inventory_family_parent_rules_pkey PRIMARY KEY(child_family_id)
`);
}
}
@@ -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]);
}
}
@@ -154,41 +154,26 @@ export class InspectionActsService {
SELECT 1 SELECT 1
FROM inspection_act_assets search_link FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id WHERE search_link.act_id = act.id
AND search_link.included = true AND search_link.included = true
AND ( AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search})
search_asset.code ILIKE ${search} )
OR search_asset.name ILIKE ${search} OR EXISTS (
OR search_company.name ILIKE ${search} SELECT 1 FROM assets search_company
OR search_area.name ILIKE ${search} WHERE search_company.id=visit.operator_company_id
) AND search_company.name ILIKE ${search}
)
OR EXISTS (
SELECT 1 FROM assets search_area
WHERE search_area.id=visit.operational_area_id
AND search_area.name ILIKE ${search}
) )
)`); )`);
} }
if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) { if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
conditions.push(`EXISTS ( if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
SELECT 1
FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.inspectorId) { if (query.inspectorId) {
const inspector = add(query.inspectorId); const inspector = add(query.inspectorId);
conditions.push(`( conditions.push(`(
@@ -245,24 +230,8 @@ export class InspectionActsService {
} }
if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) { if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
conditions.push(`EXISTS ( if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
SELECT 1 FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.inspectorId) { if (query.inspectorId) {
const inspector = add(query.inspectorId); const inspector = add(query.inspectorId);
conditions.push(`( conditions.push(`(
@@ -548,21 +517,16 @@ export class InspectionActsService {
LEFT JOIN inspection_reports report ON report.act_id = act.id LEFT JOIN inspection_reports report ON report.act_id = act.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
'id', company.id, JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
'code', company.code, ) END AS companies,
'name', company.name CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( ) END AS areas
'id', area.id, FROM inspection_visits context_visit
'code', area.code, LEFT JOIN assets company ON company.id=context_visit.operator_company_id
'name', area.name LEFT JOIN assets area ON area.id=context_visit.operational_area_id
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas WHERE context_visit.id=act.visit_id
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true
) context ON true ) context ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
@@ -813,6 +777,8 @@ export class InspectionActsService {
'code', visit.code, 'code', visit.code,
'status', visit.status, 'status', visit.status,
'scopeAssetId', visit.scope_asset_id, 'scopeAssetId', visit.scope_asset_id,
'operationalAreaId', visit.operational_area_id,
'operatorCompanyId', visit.operator_company_id,
'actualStartedAt', visit.actual_started_at 'actualStartedAt', visit.actual_started_at
), ),
'assets', COALESCE(( 'assets', COALESCE((
@@ -1,10 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable() @Injectable()
export class F3FindingCatalogResolverService { export class F3FindingCatalogResolverService {
constructor(private readonly dataSource: DataSource) {} constructor(
private readonly dataSource: DataSource,
private readonly contextualCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(` const [asset] = await this.dataSource.query(`
@@ -36,19 +40,28 @@ export class F3FindingCatalogResolverService {
enabled: true, enabled: true,
code: 'OTHER', code: 'OTHER',
label: 'OTROS', label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.', help: 'Usalo cuando el Hallazgo no exista entre los asociados a este Inventario. Quedará registrado para revisión en oficina.',
}; };
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica if (asset.typeCode.toLowerCase() === 'yacimiento') {
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS. const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query);
return {
...contextual,
inventoryFamily: null,
catalogSource: 'ASSET_TYPE' as const,
configurationReason: contextual.configurationReason
?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.',
other,
};
}
if (!asset.familyId || asset.familyActive !== true) { if (!asset.familyId || asset.familyActive !== true) {
return { return {
asset: { id: asset.id, code: asset.code, name: asset.name }, asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null, inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false, typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento' configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
categories: [], categories: [],
items: [], items: [],
other, other,
@@ -59,7 +72,6 @@ export class F3FindingCatalogResolverService {
'mapping.inventory_family_id=$1::uuid', 'mapping.inventory_family_id=$1::uuid',
'item.is_active=true', 'item.is_active=true',
'category.is_active=true', 'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL', 'merge_record.source_item_id IS NULL',
]; ];
const params: unknown[] = [asset.familyId]; const params: unknown[] = [asset.familyId];
@@ -106,8 +118,9 @@ export class F3FindingCatalogResolverService {
name: asset.familyName, name: asset.familyName,
level: asset.familyLevel, level: asset.familyLevel,
}, },
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: true, typeConfigured: true,
configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`, configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories, categories,
items, items,
other, other,
@@ -153,13 +153,16 @@ export class InspectionFindingWorklistService {
workflowFilters.push(`finding.status = 'OPEN'`); workflowFilters.push(`finding.status = 'OPEN'`);
} }
// Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se
// leen del contexto congelado de la Inspección que originó el Hallazgo, no
// del snapshot de compatibilidad del Inventario.
const joins = ` const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets company ON company.id = visit.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = visit.operational_area_id
`; `;
const filters = [...contextFilters, ...workflowFilters]; const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
@@ -408,30 +408,24 @@ export class InspectionReportsService {
} }
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string { private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
return `EXISTS ( // Reports are historical documents. Their Area/Company filters must use the
SELECT 1 // parent Inspection snapshot, never the mutable/current Inventory context.
FROM inspection_act_assets context_filter_link return `visit.${column} = ${parameter}::uuid`;
INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id
WHERE context_filter_link.act_id = act.id
AND context_filter_link.included = true
AND context_filter_asset.${column} = ${parameter}::uuid
)`;
} }
private contextSelect(): string { private contextSelect(): string {
return ` return `
SELECT SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
'id',company.id,'code',company.code,'name',company.name JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies, ) END AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
'id',area.id,'code',area.code,'name',area.name JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas ) END AS areas
FROM inspection_act_assets context_link FROM inspection_visits context_visit
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets company ON company.id=context_asset.operator_company_id LEFT JOIN assets area ON area.id=context_visit.operational_area_id
LEFT JOIN assets area ON area.id=context_asset.operational_area_id WHERE context_visit.id=act.visit_id
WHERE context_link.act_id=act.id AND context_link.included=true
`; `;
} }
@@ -66,9 +66,6 @@ interface LockedFindingRow {
assetId: string; assetId: string;
assetCode: string; assetCode: string;
assetName: string; assetName: string;
companyId: string | null;
companyCode: string | null;
companyName: string | null;
areaId: string | null; areaId: string | null;
areaCode: string | null; areaCode: string | null;
areaName: string | null; areaName: string | null;
@@ -76,6 +73,12 @@ interface LockedFindingRow {
status: string; status: string;
} }
interface PlannedOperatorRow {
companyId: string;
companyCode: string;
companyName: string;
}
@Injectable() @Injectable()
export class InspectionVerificationsService { export class InspectionVerificationsService {
constructor( constructor(
@@ -142,8 +145,19 @@ export class InspectionVerificationsService {
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN LATERAL (
SELECT operator_company.id,operator_company.code,operator_company.name
FROM area_company_relations relation
INNER JOIN assets operator_company ON operator_company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND operator_company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
) company ON true
${activeVisitJoin} ${activeVisitJoin}
`; `;
@@ -495,15 +509,11 @@ export class InspectionVerificationsService {
finding.next_control_on AS "nextControlOn", finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode", asset.code AS "assetCode",
asset.name AS "assetName", asset.name AS "assetName",
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName",
area.id AS "areaId", area.id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName" area.name AS "areaName"
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
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
WHERE finding.id = ANY($1::uuid[]) WHERE finding.id = ANY($1::uuid[])
FOR UPDATE OF finding FOR UPDATE OF finding
@@ -519,25 +529,51 @@ export class InspectionVerificationsService {
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`, message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
}); });
} }
const missingContext = rows.find((row) => !row.companyId || !row.areaId); const missingArea = rows.find((row) => !row.areaId);
if (missingContext) { if (missingArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_REQUIRED', code: 'VERIFICATION_CONTEXT_REQUIRED',
message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`, message: `${missingArea.code} necesita un Área física antes de planificar la visita`,
}); });
} }
const companyId = rows[0]?.companyId;
const areaId = rows[0]?.areaId; const areaId = rows[0]?.areaId;
if (!companyId || !areaId) { if (!areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' }); throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' });
} }
if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) { if (rows.some((row) => row.areaId !== areaId)) {
throw new BadRequestException({ throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_MIXED', code: 'VERIFICATION_CONTEXT_MIXED',
message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación', message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación',
}); });
} }
// A verification visit is a new operational act. Its operator is therefore
// the Company valid for the Area at the planned visit date, never the
// historical compatibility snapshot stored on the Inventory.
const [operator] = await manager.query(`
SELECT
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
AND company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
FOR SHARE OF relation
`, [areaId, plannedStartAt]) as PlannedOperatorRow[];
if (!operator) {
throw new ConflictException({
code: 'VERIFICATION_OPERATOR_NOT_ACTIVE',
message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación',
});
}
const companyId = operator.companyId;
const activeLinks = await manager.query(` const activeLinks = await manager.query(`
SELECT verification_link.finding_id AS "findingId", visit.code SELECT verification_link.finding_id AS "findingId", visit.code
FROM inspection_finding_verification_visits verification_link FROM inspection_finding_verification_visits verification_link
@@ -552,7 +588,7 @@ export class InspectionVerificationsService {
}); });
} }
const companyName = rows[0]?.companyName ?? 'Empresa'; const companyName = operator.companyName;
const areaName = rows[0]?.areaName ?? 'Área'; const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt); const code = await nextInspectionVisitCode(manager, plannedStartAt);
const findingCodes = rows.map((row) => row.code).join(', '); const findingCodes = rows.map((row) => row.code).join(', ');
@@ -637,6 +673,7 @@ export class InspectionVerificationsService {
areaId, areaId,
findingIds, findingIds,
assetIds, assetIds,
operatorSource: 'area_company_relations',
}, },
}, manager); }, manager);
@@ -647,7 +684,7 @@ export class InspectionVerificationsService {
status: visit.status, status: visit.status,
plannedStartAt: visit.plannedStartAt, plannedStartAt: visit.plannedStartAt,
}, },
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, company: { id: companyId, code: operator.companyCode, name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
findingCount: findingIds.length, findingCount: findingIds.length,
assetCount: assetIds.length, assetCount: assetIds.length,
@@ -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;
@@ -0,0 +1,9 @@
import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto {
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
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;
} }
} }
@@ -138,8 +138,31 @@ export class FieldFindingsService {
asset.id AS "assetId", asset.id AS "assetId",
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 AS "assetAreaId", (
asset.operator_company_id AS "assetCompanyId", asset.operational_area_id=visit.operational_area_id
OR asset.id=visit.operational_area_id
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=visit.operational_area_id LIMIT 1
)
) AS "insideArea",
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid",
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM inspection_visit_members member FROM inspection_visit_members member
@@ -199,8 +222,8 @@ export class FieldFindingsService {
assetId: string | null; assetId: string | null;
assetTypeCode: string | null; assetTypeCode: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
assetAreaId: string | null; insideArea: boolean;
assetCompanyId: string | null; operatorRelationValid: boolean;
assigned: boolean; assigned: boolean;
selected: boolean; selected: boolean;
captureRequired: boolean; captureRequired: boolean;
@@ -230,28 +253,35 @@ export class FieldFindingsService {
message: 'La inspección no tiene Área y Operadora definidas', message: 'La inspección no tiene Área y Operadora definidas',
}); });
} }
if (!row.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!row.assetId) { if (!row.assetId) {
throw new NotFoundException({ throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND', code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) { const assetTypeCode = String(row.assetTypeCode).toLowerCase();
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED', code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento', message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
}); });
} }
if (!row.inventoryFamilyId) { if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED', code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos', message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
}); });
} }
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) { if (!row.insideArea) {
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 y Operadora de esta inspección', message: 'El Inventario no pertenece al Área de esta inspección',
}); });
} }
if (!row.selected) { if (!row.selected) {
@@ -0,0 +1,207 @@
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 = '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,80 @@
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 };
}
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',
});
}
}
@@ -295,11 +295,13 @@ export class InspectionVisitLifecycleService {
} }
} }
// El contexto operativo de una Inspección es Área + Operadora vigente. El
// Yacimiento pertenece al alcance físico y nunca reemplaza al Área raíz.
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> { private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) { if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_PLANNING_INCOMPLETE', code: 'INSPECTION_PLANNING_INCOMPLETE',
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar', message: 'Definí Área, Operadora, Inspector responsable y fecha de inicio antes de planificar',
}); });
} }
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) { if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
@@ -320,7 +322,7 @@ export class InspectionVisitLifecycleService {
if (!relation) { if (!relation) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_OPERATOR_RELATION_INVALID', code: 'INSPECTION_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento', message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área',
}); });
} }
const [inspector] = await manager.query(` const [inspector] = await manager.query(`
@@ -13,14 +13,18 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { 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';
@@ -29,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()
@@ -43,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() {
@@ -57,6 +86,43 @@ export class InspectionVisitsController {
return this.visits.listPlanningOperators(areaId); return this.visits.listPlanningOperators(areaId);
} }
@Get('mobile/planning-context/areas')
@RequirePermissions('inspections.execute')
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
assertMobileInspector(principal);
return this.visits.listPlanningAreas();
}
@Get('mobile/planning-context/areas/:areaId/operators')
@RequirePermissions('inspections.execute')
mobilePlanningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.visits.listPlanningOperators(areaId);
}
@Post('mobile/open')
@RequirePermissions('inspections.execute')
async openMobile(
@Body() dto: OpenMobileInspectionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
assertMobileInspector(principal);
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
const created = await this.visits.create({
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: new Date().toISOString(),
leadInspectorUserId: principal.userId,
}, principal, request);
await this.lifecycle.plan(created.id, principal, request);
return this.lifecycle.start(created.id, principal, request);
}
@Get(':id') @Get(':id')
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -70,7 +136,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,
@@ -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
@@ -1,2 +1,2 @@
export const API_VERSION = '0.26.0-1'; export const API_VERSION = '0.29.0-1';
export const API_PHASE = 'F5'; export const API_PHASE = 'F6.1';
@@ -33,13 +33,15 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
}); });
test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => { test('D5.3/F6.1 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts'); const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/); assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/); assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/); assert.match(service, /Ending an operator relation never moves, rewrites or blocks existing Inventory/);
assert.match(service, /operatorSnapshotPreserved:true/);
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/); assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/); assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.doesNotMatch(service, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
}); });
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F5 release', () => { test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F5'); assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.26.0-1'); assert.equal(API_VERSION, '0.29.0-1');
}); });
@@ -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';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F5.1 clean start deletes domain instances and audit history but preserves master configuration by contract', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
assert.match(migration, /TRUNCATE TABLE audit_events/);
assert.match(migration, /TRUNCATE TABLE administrative_departments CASCADE/);
assert.match(migration, /TRUNCATE TABLE finding_catalog_item_inventory_families/);
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/);
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/);
assert.doesNotMatch(migration, /TRUNCATE TABLE users/);
});
test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(dto, /'DEPARTAMENTO'/);
assert.match(structure, /DEPARTAMENTO: 'departamento'/);
assert.match(structure, /AREA: 'departamento'/);
assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(migration, /child_code='area' AND parent_code<>'departamento'/);
assert.match(migration, /WHERE lower\(code\)='area'/);
assert.match(migration, /SET can_be_root=false/);
});
test('F5.1 manual creation permits Area context without requiring an operator', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/);
assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.match(structure, /operator_company_id,inventory_family_id/);
assert.match(structure, /asset_context_history/);
assert.match(structure, /operational_area_id,operator_company_id/);
});
test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => {
const browser = source('src/asset-master/inventory-browser.service.ts');
const controller = source('src/asset-master/inventory-browser.controller.ts');
assert.match(browser, /'departamento','area','yacimiento','instalacion','subinstalacion'/);
assert.match(browser, /case 'departamento': return 'area'/);
assert.doesNotMatch(browser, /['"]asset\.is_inventory_instance=true['"]/);
assert.match(controller, /@Get\('departments'\)/);
assert.match(controller, /@Get\('companies'\)/);
});
test('F5.1 Finding Catalog defaults to associated findings and exposes all items only for linking', () => {
const panel = source('../web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx');
assert.match(panel, /type ViewMode = 'ASSOCIATED' \| 'ALL'/);
assert.match(panel, /useState<ViewMode>\('ASSOCIATED'\)/);
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
assert.match(panel, /Todos para vincular/);
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Filtrar por tipo de Instalación/);
});
test('F5.1 Web creation and configuration expose Departamento as the physical root', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /familyId=\$\{familyEditor\.id\}/);
});
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8'); return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
} }
test('F5 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 = 20/); assert.match(gradle, /versionCode = 23/);
assert.match(gradle, /versionName = "0\.13\.0"/); assert.match(gradle, /versionName = "0\.15\.1"/);
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"/);
}); });
test('F5 field inventory exposes Other families as reviewable choices to Android', () => { test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync( const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'), resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8', 'utf8',
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { AssetMasterModule } from '../../src/asset-master/asset-master.module';
import { InventoryFunctionService } from '../../src/asset-master/inventory-function.service';
test('F5 AssetMasterModule keeps dossier-only function dependency injectable without reopening its controller', () => {
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AssetMasterModule) ?? []) as unknown[];
const controllers = (Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AssetMasterModule) ?? []) as Array<{ name?: string }>;
const dossierSource = readFileSync(
resolve(process.cwd(), 'src/asset-master/merged-inventory-dossier.service.ts'),
'utf8',
);
assert.match(
dossierSource,
/private readonly functions: InventoryFunctionService/,
'MergedInventoryDossierService must still depend on InventoryFunctionService for historical dossier data',
);
assert.ok(
providers.includes(InventoryFunctionService),
'AssetMasterModule must provide InventoryFunctionService so production Nest bootstrap can resolve the dossier',
);
assert.equal(
controllers.some((controller) => controller?.name === 'InventoryFunctionController'),
false,
'F5 must not reopen the retired Inventory Function controller',
);
});
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 allows direct findings only on Yacimiento, Instalacion and Subinstalacion', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
assert.match(findings, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
assert.match(findings, /FIELD_FINDING_TARGET_LEVEL_INVALID/);
assert.match(findings, /Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación/);
assert.match(findings, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
assert.match(findings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-another everywhere', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
const catalog = source('src/inspection-findings/finding-catalog.service.ts');
const androidFinding = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
assert.match(findings, /canAddAnother: true/);
assert.match(resolver, /typeCode\.toLowerCase\(\) === 'yacimiento'/);
assert.match(resolver, /contextualCatalog\.listApplicableForAsset\(assetId, query\)/);
assert.match(resolver, /catalogSource: 'ASSET_TYPE'/);
assert.match(catalog, /finding_catalog_item_asset_types mapping/);
assert.match(catalog, /finding_catalog_asset_overrides override/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
assert.match(androidFinding, /OTROS · No está en el catálogo/);
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
});
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
assert.match(client, /inspection-visits\/mobile\/open/);
assert.match(home, /Abrir inspección/);
assert.match(home, /Abrir inspección ahora/);
assert.match(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
});
@@ -0,0 +1,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,102 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 preserves Inventory operator snapshots and changes operator only through Area relations', () => {
const relations = source('src/asset-master/asset-operational-relations.service.ts');
const migration = source('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts');
assert.match(migration, /Runtime ownership and search MUST NOT depend on it/);
assert.match(migration, /La Empresa se cambia en la relación temporal del Área, no en el Inventario/);
assert.match(relations, /operatorSnapshotPreserved:true/);
assert.doesNotMatch(relations, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
});
test('F6.1 blocks operator mutation through generic Inventory edit DTOs', () => {
const updateDto = source('src/asset-master/dto/update-asset.dto.ts');
const contextDto = source('src/asset-master/dto/change-asset-context.dto.ts');
for (const dto of [updateDto, contextDto]) {
assert.match(dto, /@IsEmpty/);
assert.match(dto, /La Operadora se administra en la relación temporal del Área, no en el Inventario/);
}
});
test('F6.1 rejects legacy Inventory searches that filter by operator snapshot', () => {
const listDto = source('src/asset-master/dto/list-assets-query.dto.ts');
const treeDto = source('src/asset-master/dto/list-asset-tree-query.dto.ts');
for (const dto of [listDto, treeDto]) {
assert.match(dto, /operatorCompanyId\?: string/);
assert.match(dto, /@IsEmpty/);
assert.match(dto, /Filtrá la Operadora mediante la relación temporal del Área/);
}
});
test('F6.1 resolves temporal Inventory operator from Area relation history', () => {
const temporal = source('src/asset-master/asset-temporal.service.ts');
assert.match(temporal, /FROM area_company_relations relation/);
assert.match(temporal, /relation\.valid_from <= \$2/);
assert.match(temporal, /relation\.valid_until IS NULL OR relation\.valid_until > \$2/);
assert.match(temporal, /operator_company\.id=operator_relation\.company_id/);
assert.doesNotMatch(temporal, /operator_company\.id=history\.operator_company_id/);
});
test('F6.1 verification planning uses Area relation truth instead of Inventory operator snapshot', () => {
const verification = source('src/inspection-verifications/inspection-verifications.service.ts');
assert.match(verification, /FROM area_company_relations relation/);
assert.match(verification, /relation\.valid_from <= \$2::timestamptz/);
assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/);
assert.match(verification, /operatorSource: 'area_company_relations'/);
assert.doesNotMatch(verification, /company\.id = asset\.operator_company_id/);
});
test('F6.1 historical operational lists read Company and Area from the parent Inspection', () => {
const acts = source('src/inspection-acts/inspection-acts.service.ts');
const findings = source('src/inspection-findings/inspection-finding-worklist.service.ts');
const reports = source('src/inspection-reports/inspection-reports.service.ts');
assert.match(acts, /visit\.operator_company_id/);
assert.match(acts, /visit\.operational_area_id/);
assert.doesNotMatch(acts, /context_asset\.operator_company_id/);
assert.match(findings, /company\.id = visit\.operator_company_id/);
assert.match(findings, /area\.id = visit\.operational_area_id/);
assert.doesNotMatch(findings, /company\.id = asset\.operator_company_id/);
assert.match(reports, /visit\.\$\{column\} = \$\{parameter\}::uuid/);
assert.match(reports, /company\.id=context_visit\.operator_company_id/);
assert.match(reports, /area\.id=context_visit\.operational_area_id/);
assert.doesNotMatch(reports, /context_asset\.operator_company_id/);
});
test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => {
const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
}
assert.doesNotMatch(inventoryMerge, /source\.operatorCompany/);
});
test('F6.1 WEB derives the creation snapshot from the active Area operator and removes it from Inventory edits', () => {
const editor = source('../web-v2/src/pages/AssetEditorPage.tsx');
const contextPanel = source('../web-v2/src/features/assets/AssetContextHistoryPanel.tsx');
assert.match(editor, /createAsset\(\{[\s\S]*operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(editor, /updateAsset\(id, \{[^}]*operatorCompanyId/);
assert.doesNotMatch(editor, /parent\.operatorCompany/);
assert.match(editor, /listCompaniesForArea\(operationalAreaId\)\.then\(\(loadedCompanies\)/);
assert.match(editor, /loadedCompanies\.length === 1 \? \(loadedCompanies\[0\]\?\.id \?\? ''\) : ''/);
assert.match(editor, /Snapshot histórico de alta/);
assert.match(contextPanel, /Cambiar contexto físico/);
assert.doesNotMatch(contextPanel, /operatorCompanyId:/);
});
@@ -0,0 +1,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,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,71 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 conserva una jerarquía territorial única y Empresa fuera del árbol físico', () => {
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
assert.match(dto, /'EMPRESA',[\s\S]*'DEPARTAMENTO',[\s\S]*'AREA',[\s\S]*'YACIMIENTO',[\s\S]*'INSTALACION',[\s\S]*'SUBINSTALACION'/);
assert.match(structure, /EMPRESA:\s*null/);
assert.match(structure, /DEPARTAMENTO:\s*null/);
assert.match(structure, /AREA:\s*'departamento'/);
assert.match(structure, /YACIMIENTO:\s*'area'/);
assert.match(structure, /INSTALACION:\s*'yacimiento'/);
assert.match(structure, /SUBINSTALACION:\s*'instalacion'/);
assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
});
test('F6.1 usa Área + Operadora como contexto de planificación y no mezcla Yacimiento con Área', () => {
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
const visits = source('src/inspection-visits/inspection-visits.service.ts');
assert.match(lifecycle, /El contexto operativo de una Inspección es Área \+ Operadora vigente/);
assert.doesNotMatch(lifecycle, /Área\/Yacimiento/);
assert.match(visits, /FROM area_company_relations/);
assert.match(visits, /relation\.relation_role = 'OPERATOR'/);
});
test('F6.1 mantiene múltiples Actas por Inspección pero un solo borrador simultáneo', () => {
const act = source('src/database/entities/inspection-act.entity.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.match(act, /uq_inspection_acts_one_draft_per_visit/);
assert.match(act, /where: "status = 'DRAFT'"/);
assert.match(act, /Una Inspección puede acumular múltiples Actas/);
assert.match(lifecycle, /Todas las Actas deben estar SELLADAS antes de cerrar la Inspección/);
});
test('F6.1 permite Hallazgos directos sólo en Yacimiento, Instalación y Subinstalación y conserva OTROS', () => {
const field = source('src/inspection-visits/field-findings.service.ts');
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(field, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
assert.match(field, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
assert.match(field, /canAddAnother: true/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
test('F6.1 abre Inspecciones desde Android usando el lifecycle canónico', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
});
test('F6.1 separa oficialización GEDO de la activación del vencimiento no urgente', () => {
const report = source('src/inspection-reports/inspection-report-workflow.service.ts');
const act = source('src/database/entities/inspection-act.entity.ts');
assert.match(report, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/);
assert.match(report, /deadlineActivation: 'PENDING_NOTIFICATION_EVENT'/);
assert.match(act, /NOTIFICATION_DATE = 'GEDO_DATE'/);
});
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6 models concrete Installation 1→N Subinstallations while each child keeps exactly one physical parent', () => {
const asset = source('src/database/entities/asset.entity.ts');
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(asset, /@Column\(\{ name: 'parent_id', type: 'uuid', nullable: true \}\)/);
assert.match(asset, /parentId!: string \| null/);
assert.match(migration, /IF child_type='subinstalacion' THEN/);
assert.match(migration, /IF NEW\.parent_id IS NULL THEN/);
assert.doesNotMatch(asset, /parentIds!/);
});
test('F6 makes technical Installation↔Subinstallation compatibility truly many-to-many', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const catalog = source('src/asset-master/inventory-family-catalog.service.ts');
assert.match(migration, /PRIMARY KEY \(child_family_id,parent_family_id\)/);
assert.match(catalog, /parentFamilyIds: string\[\]/);
assert.match(catalog, /requestedIds:string\[\]/);
assert.match(catalog, /INSERT INTO inventory_family_parent_rules\(child_family_id,parent_family_id\)/);
assert.match(catalog, /UNNEST\(\$2::uuid\[\]\)/);
assert.match(catalog, /ON CONFLICT \(child_family_id,parent_family_id\) DO NOTHING/);
});
test('F6 database rejects a Subinstallation classification incompatible with its concrete Installation parent', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(migration, /SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW\.parent_id/);
assert.match(migration, /rule\.child_family_id=NEW\.inventory_family_id AND rule\.parent_family_id=parent_family/);
assert.match(migration, /La clasificación de Subinstalación no es compatible con la Instalación padre/);
});
test('F6 technical values are defined by classification and cannot leak between families', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const service = source('src/asset-master/inventory-technical-values.service.ts');
assert.match(migration, /CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values/);
assert.match(migration, /El campo técnico no pertenece a la clasificación del Inventario/);
assert.match(service, /inventory_family_attribute_definitions/);
assert.match(service, /asset_inventory_attribute_values/);
});
test('F6 field findings resolve by exact technical classification and keep OTROS available', () => {
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(resolver, /mapping\.inventory_family_id=\$1::uuid/);
assert.match(resolver, /asset\.inventory_family_id AS "familyId"/);
assert.doesNotMatch(resolver, /category\.code='f5model'/);
assert.match(resolver, /code: 'OTHER'/);
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', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
});
test('F6 field-created technical Inventory may be provisional only until the selected family is assigned', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const fieldStructure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(migration, /NEW\.data_origin='FIELD_SURVEY' AND NEW\.information_status='DRAFT'/);
assert.match(fieldStructure, /SET inventory_family_id=\$2::uuid/);
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
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');
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
+91
View File
@@ -0,0 +1,91 @@
# Auditoría de consistencia · F6.1
**Baseline revisado:** `25a63cd3d4b2c561803cc5105cce100983c8eeff`
**Alcance:** API, modelo de datos, Inventarios, Inspecciones, Actas, Hallazgos, Informes/GEDO, WEB, Android, permisos, versionado documental y CI.
## Resultado
El modelo funcional consolidado de F6.1 es coherente. No se detectó una contradicción estructural que requiera rediseñar tablas o migrar datos antes de continuar.
La auditoría sí encontró deuda de terminología/documentación y una inconsistencia de metadata generada. Las correcciones funcionales/documentales se integran en esta rama; la metadata de lockfiles se documenta como observación no funcional y debe regenerarse con npm en un corte controlado, no editarse parcialmente a mano.
## Contratos verificados
### Inventario
- Jerarquía física única: Departamento → Área → Yacimiento → Instalación → Subinstalación.
- Empresa es un maestro independiente.
- Operadora vigente se resuelve por relación temporal Área ⇄ Empresa.
- Cambiar Operadora no reparenta Inventario.
- Departamento, Área y Yacimiento no llevan familia técnica.
- Instalación y Subinstalación requieren familia técnica.
- Subinstalación debe ser compatible con la familia de la Instalación padre.
- PostgreSQL protege jerarquía y compatibilidad, además de las validaciones de API.
### Inspecciones
- Lifecycle canónico DRAFT → PLANNED → IN_PROGRESS → CLOSED.
- Planificación requiere Área, Operadora vigente, Inspector responsable, fecha/hora y checklist.
- La APK abre una Inspección reutilizando el mismo create/plan/start; no existe un lifecycle móvil paralelo.
- No hay referencias activas a `plannedEndAt` / `planned_end_at` en el código actual.
### Actas
- Una Inspección puede contener múltiples Actas.
- El constraint histórico de una sola Acta por visita fue eliminado por F1.1.
- Existe un índice parcial que permite sólo un Acta DRAFT simultánea por Inspección.
- El cierre de la Inspección exige que todas las Actas no canceladas estén SELLADAS.
- El Acta finalizada/bloqueada es inmutable.
### Hallazgos
- Destinos directos: Yacimiento, Instalación y Subinstalación.
- Departamento y Área no son destinos directos.
- OTROS / Agregar otro permanece disponible.
- Yacimiento no requiere familia técnica.
- Instalación/Subinstalación sí requieren clasificación.
- Inventario nacido en campo necesita GPS + foto antes de admitir Hallazgos.
### Informes / GEDO
- El Informe pertenece al Acta.
- La oficialización GEDO/IF es inmutable.
- Oficializar en GEDO no activa por sí solo el vencimiento No urgente.
- El valor persistido histórico `GEDO_DATE` se conserva como ABI de base; en código existe el alias semántico `NOTIFICATION_DATE` para evitar interpretar GEDO como notificación automática.
## Correcciones integradas en la auditoría
1. Se corrigió la terminología residual `Área/Yacimiento` en el lifecycle de Inspecciones. El contexto operativo queda definido inequívocamente como **Área + Operadora vigente**.
2. Se documentó en código la separación entre Empresa y la jerarquía física del Inventario.
3. Se documentó en la entidad Acta el contrato multi-Acta + único DRAFT simultáneo.
4. Se agregó un test transversal F6.1 que protege jerarquía, contexto operativo, multi-Acta, Hallazgos, apertura móvil y separación GEDO/vencimiento.
5. Se consolidó `docs/F6_INVENTORY_MODEL.md` como única referencia viva y los duplicados históricos apuntan a ella.
6. Se crearon `MANUAL_PROGRAMADOR.md` y `MANUAL_USUARIO.md`.
## Compatibilidades históricas que NO deben “limpiarse” sin migración
- Migraciones antiguas conservan nombres y reglas que fueron válidas en fases anteriores. No se modifican; migraciones posteriores son las que expresan la evolución.
- `InspectionDeadlineBasis.GEDO_DATE` comparte valor persistido con `NOTIFICATION_DATE` por compatibilidad de esquema.
- `assets.operator_company_id` puede conservar una fotografía de compatibilidad incluso cuando la relación temporal ya finalizó. La fuente de verdad vigente/histórica sigue siendo `area_company_relations`.
- F5.1 es una migración destructiva de inicio limpio y no tiene `down()` reconstructivo; su rollback es el backup PRE.
## Observación no funcional: metadata de package-lock
Los `package.json` actuales declaran las versiones de producto vigentes, pero la metadata `name/version` de la raíz de ambos `package-lock.json` todavía conserva `0.20.0-2` de un corte anterior.
Esto no cambia el grafo de dependencias ni el runtime y `npm ci` continúa siendo la barrera de instalación. Se deja explícitamente registrado para no ocultarlo.
**Tratamiento correcto:** regenerar cada lockfile con npm (`npm install --package-lock-only`) cuando se haga el próximo corte controlado de metadata/dependencias y verificar el diff completo. No editar manualmente sólo las primeras líneas del lockfile.
## Criterio de aceptación de esta auditoría
Antes de mergear esta rama deben pasar nuevamente:
- API typecheck + tests + build.
- WEB typecheck + contrato + build.
- migraciones sobre PostGIS limpio.
- preflight aislado equivalente al VPS.
- build de imágenes productivas.
- Android assemble + unit tests cuando el workflow se dispare por el PR.
La rama de documentación/consistencia no debe promoverse a `deploy` durante esta revisión. El despliegue queda deliberadamente pausado.
+131
View File
@@ -0,0 +1,131 @@
# F6.1 · Modelo canónico de Inventarios
Este documento es la referencia funcional y técnica del modelo de Inventarios de DH Inspección V2. Ante una contradicción con documentación histórica, prevalecen este documento, las migraciones vigentes y los contratos automatizados de F6.1.
## 1. Jerarquía física
La estructura territorial y física es única:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
Reglas:
- `Departamento` es raíz.
- `Área` requiere un Departamento padre.
- `Yacimiento` requiere un Área padre.
- `Instalación` requiere un Yacimiento padre.
- `Subinstalación` requiere una Instalación padre.
- La base de datos rechaza combinaciones de padre/hijo que no respeten esta secuencia.
- El campo `parent_id` expresa exclusivamente esta pertenencia física.
## 2. Empresa no pertenece al árbol físico
`Empresa` es un maestro independiente. No es padre de Departamento, Área, Yacimiento, Instalación ni Subinstalación.
La Operadora vigente de un Área se expresa mediante `area_company_relations`, con `relation_role = 'OPERATOR'` y vigencia temporal. Esta relación es la fuente de verdad para saber qué Empresa opera un Área en una fecha determinada.
`assets.operator_company_id` puede existir como fotografía de compatibilidad del contexto actual, pero no debe utilizarse para reconstruir la historia ni para reparentar Inventario.
**Cambiar de Operadora nunca mueve la jerarquía física.** Se finaliza la relación temporal anterior y se crea la nueva relación Empresa ⇄ Área.
## 3. Contexto transversal de Área
Los elementos bajo un Área conservan `operational_area_id` como contexto transversal para consultas, planificación y trazabilidad. El Yacimiento forma parte del alcance físico, pero no sustituye al Área como contexto operativo de una Inspección.
Por lo tanto, la planificación de una Inspección trabaja con:
**Área + Operadora vigente + Inspector responsable + fecha/hora de inicio**.
## 4. Clasificación técnica
Los niveles territoriales no usan clasificación técnica:
- Departamento: sin familia técnica.
- Área: sin familia técnica.
- Yacimiento: sin familia técnica.
Los niveles físicos sí la requieren:
- Instalación: una familia de nivel `INSTALLATION`.
- Subinstalación: una familia de nivel `SUBINSTALLATION` compatible con la familia de la Instalación padre.
La compatibilidad se almacena en `inventory_family_parent_rules` y se valida también en PostgreSQL. Una misma familia de Subinstalación puede ser compatible con varias familias de Instalación.
Los campos técnicos específicos de cada familia se definen en `inventory_family_attribute_definitions` y sus valores por Inventario en `asset_inventory_attribute_values`.
## 5. Hallazgos
Un Hallazgo puede asignarse directamente a:
- Yacimiento.
- Instalación.
- Subinstalación.
No se asignan Hallazgos directos a Departamento ni Área.
`OTROS / Agregar otro` debe estar siempre disponible. Permite describir un Hallazgo que no exista todavía en el catálogo aplicable y dejarlo registrado para revisión posterior.
Para Yacimiento, la aplicabilidad del catálogo puede resolverse por tipo de Inventario y por excepciones del objeto concreto. Yacimiento no necesita una familia técnica para recibir Hallazgos.
Instalación y Subinstalación deben estar clasificadas antes de recibir un Hallazgo.
## 6. Inventario nacido en campo
La APK puede incorporar Inventario encontrado durante una Inspección. Un registro creado en campo conserva su trazabilidad de origen y, antes de permitir Hallazgos sobre él, debe tener como mínimo:
- GPS capturado para la creación.
- al menos una fotografía de campo.
- clasificación técnica cuando se trate de Instalación o Subinstalación.
La excepción transitoria que permite crear un registro `FIELD_SURVEY + DRAFT` sin familia existe únicamente para completar la transacción de alta; no habilita Hallazgos sin clasificación.
## 7. Carga manual recomendada
El orden seguro para construir una estructura desde cero es:
1. Crear las Empresas necesarias como maestros independientes.
2. Crear Departamento.
3. Crear Área dentro del Departamento.
4. Registrar la Operadora vigente del Área, si ya se conoce.
5. Crear Yacimiento dentro del Área.
6. Crear Instalación dentro del Yacimiento y seleccionar su familia técnica.
7. Crear Subinstalación dentro de la Instalación y seleccionar una familia compatible.
La importación de archivos no debe definir la arquitectura del modelo. Los datos externos se adaptan a este contrato; el contrato no se deforma para adaptarse a cada planilla.
## 8. Historia y consulta temporal
DH conserva dos conceptos separados:
- historia física/contextual del Inventario;
- historia temporal de la relación Área ⇄ Empresa.
Los cambios relevantes generan versiones y eventos de auditoría. Las consultas históricas deben utilizar la vigencia correspondiente a la fecha consultada, no sólo la fotografía actual de las columnas de `assets`.
## 9. Barreras de integridad
La consistencia no depende sólo de la interfaz. Se protege en varias capas:
- DTO y servicios de API validan niveles y padres.
- PostgreSQL valida la jerarquía canónica.
- PostgreSQL valida nivel y compatibilidad de familias técnicas.
- la planificación valida una relación Operadora ⇄ Área vigente.
- los Hallazgos validan nivel permitido, clasificación técnica y pertenencia a la Inspección/Acta.
- CI ejecuta la cadena completa de migraciones sobre PostGIS limpio y los contratos transversales.
## 10. Corte limpio F5.1
`F51CleanManualInventory1790087400000` fue un corte intencional de inicio limpio. Eliminó datos operativos/importados de desarrollo y dejó los maestros técnicos necesarios para reconstruir manualmente una estructura confiable.
Es una migración deliberadamente no reversible mediante `migration:revert`. Su recuperación depende del backup PRE del despliegue. No debe copiarse este patrón para migraciones normales sin una decisión explícita y una estrategia de restauración probada.
## 11. Regla para cambios futuros
Cualquier modificación del modelo debe mantener simultáneamente:
1. la jerarquía física canónica;
2. Empresa fuera del árbol;
3. relación temporal Área ⇄ Empresa como fuente de verdad operativa;
4. compatibilidad técnica de familias;
5. trazabilidad histórica;
6. protección en base de datos y tests, no sólo en la UI.
+5
View File
@@ -0,0 +1,5 @@
# F6 · Modelo de Inventarios
Este archivo se conserva por compatibilidad con referencias históricas.
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
+5
View File
@@ -0,0 +1,5 @@
# F6 · Modelo de Inventarios
Este archivo se conserva por compatibilidad con referencias históricas.
La documentación canónica y mantenida está en [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
+370
View File
@@ -0,0 +1,370 @@
# Manual del Programador · DH Inspección V2
**Baseline documental:** F6.1
**API:** 0.29.0-1
**WEB:** 0.23.0-1
**Android:** 0.15.0
Este manual describe la arquitectura, las reglas que no deben romperse y el procedimiento seguro para modificar DH Inspección V2. No reemplaza los tests ni las restricciones de base de datos: los tres forman juntos el contrato del sistema.
---
## 1. Estructura del repositorio
- `api-v3/`: API NestJS + TypeORM + PostgreSQL/PostGIS.
- `web-v2/`: aplicación WEB React/Vite.
- `android-app/`: aplicación Android Kotlin/Compose.
- `scripts/`: despliegue, preflight y utilidades operativas.
- `docs/`: documentación funcional y técnica viva.
- `.github/workflows/`: CI de API/WEB/Docker y build/test de Android.
El backend expone `/api/v3`. Los cambios de dominio deben resolverse primero en API/base de datos; WEB y Android consumen ese contrato y no deben inventar reglas paralelas.
---
## 2. Invariantes de dominio
### 2.1 Inventario físico
La jerarquía canónica es:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
`parent_id` expresa exclusivamente esa jerarquía. No agregue Empresa dentro de este árbol.
### 2.2 Empresa y Operadora
`Empresa` es un maestro independiente.
La relación operativa vigente se modela en `area_company_relations`:
- Área ↔ Empresa.
- `relation_role = 'OPERATOR'` para la Operadora.
- `valid_from` / `valid_until` para vigencia.
Esta tabla es la fuente temporal de verdad. `assets.operator_company_id` es una fotografía de compatibilidad; nunca debe usarse como sustituto del historial.
Cambiar Operadora no mueve Yacimientos, Instalaciones ni Subinstalaciones.
### 2.3 Clasificación técnica
- Departamento: sin familia.
- Área: sin familia.
- Yacimiento: sin familia.
- Instalación: requiere familia `INSTALLATION`.
- Subinstalación: requiere familia `SUBINSTALLATION` compatible con la Instalación padre.
La compatibilidad está protegida por PostgreSQL, no sólo por la UI.
### 2.4 Hallazgos
Un Hallazgo directo sólo puede asignarse a:
- Yacimiento.
- Instalación.
- Subinstalación.
Departamento y Área no reciben Hallazgos directos.
`OTROS / Agregar otro` debe permanecer disponible siempre. En Yacimiento no se exige familia técnica. En Instalación/Subinstalación sí.
Si un elemento fue creado en campo, antes de admitir un Hallazgo debe tener GPS de creación y al menos una fotografía; Instalación/Subinstalación además deben quedar clasificadas.
### 2.5 Inspecciones
Lifecycle operativo:
`DRAFT → PLANNED → IN_PROGRESS → CLOSED`
La planificación requiere:
- Área.
- Operadora vigente del Área.
- Inspector responsable habilitado.
- fecha/hora prevista de inicio.
- checklist generado.
La APK puede abrir una Inspección, pero no posee un lifecycle alternativo: el endpoint móvil crea la visita y utiliza los mismos servicios `plan()` y `start()` que el resto del sistema.
### 2.6 Actas
Una Inspección puede tener **múltiples Actas**.
Reglas críticas:
- sólo puede existir un `DRAFT` simultáneo por Inspección;
- un Acta en `DRAFT` es editable;
- al finalizar se bloquea y queda inmutable;
- el sellado conserva integridad y firmas;
- no existe reapertura normal de un Acta bloqueada a borrador;
- para cerrar la Inspección todas las Actas no canceladas deben estar `SEALED`;
- el Informe pertenece al Acta, no a la Inspección completa.
La unicidad del borrador está protegida por el índice parcial `uq_inspection_acts_one_draft_per_visit`.
### 2.7 Informe, GEDO y vencimientos
Flujo conceptual:
**Acta → INF → oficialización GEDO/IF**
GEDO oficializa el Informe, pero **no equivale por sí solo a la notificación administrativa que inicia el plazo No urgente**.
Existe una compatibilidad histórica importante: el valor físico `GEDO_DATE` se conserva en el enum/base, mientras el significado funcional vigente es `NOTIFICATION_DATE`. No cambie el valor persistido en una refactorización sin una migración explícita y pruebas de compatibilidad.
---
## 3. Modelo de Inventarios
La referencia completa es [`F6_INVENTORY_MODEL.md`](./F6_INVENTORY_MODEL.md).
Puntos técnicos centrales:
- `assets.parent_id`: pertenencia física.
- `assets.operational_area_id`: contexto transversal de Área.
- `area_company_relations`: historia de Operadora.
- `inventory_families`: clasificaciones técnicas.
- `inventory_family_parent_rules`: compatibilidad Subinstalación ↔ Instalación.
- `inventory_family_attribute_definitions`: campos técnicos por familia.
- `asset_inventory_attribute_values`: valores técnicos por objeto.
- `asset_context_history` y versiones: trazabilidad contextual/histórica.
No se debe rediseñar el esquema para coincidir con una planilla importada. Los archivos externos se normalizan al modelo canónico.
---
## 4. Migraciones
### Regla principal
**Nunca modifique una migración ya aplicada.**
Si cambia esquema, constraints, seeds o reglas persistentes:
1. cree una migración nueva;
2. haga `up()` determinista;
3. agregue verificación al final cuando la regla sea crítica;
4. implemente `down()` cuando la operación sea razonablemente reversible;
5. agregue/actualice tests de contrato;
6. pruebe la cadena completa sobre PostGIS limpio.
### F5.1 es una excepción histórica
`F51CleanManualInventory1790087400000` fue un corte destructivo intencional para reiniciar los datos de dominio. Su `down()` no intenta reconstruir información eliminada: el rollback depende del backup PRE.
No repita este patrón para una migración normal sin una decisión explícita, backup validado y procedimiento de restauración.
### Guards importantes de base de datos
PostgreSQL protege, entre otras cosas:
- jerarquía Departamento → Área → Yacimiento → Instalación → Subinstalación;
- familia correcta según el nivel físico;
- compatibilidad de familia de Subinstalación con su Instalación padre;
- un solo Acta `DRAFT` por Inspección.
Las validaciones de API mejoran la experiencia, pero no reemplazan estos guards.
---
## 5. API
### Principios
- Reutilice servicios canónicos; no duplique lifecycle en controllers móviles.
- Las mutaciones relevantes deben producir auditoría.
- Los errores de dominio deben tener `code` estable y mensaje legible.
- Los estados históricos/inmutables no deben reabrirse por conveniencia de UI.
- Para relaciones temporales, valide la vigencia en la fecha correspondiente, no sólo `valid_until IS NULL` cuando el caso exige reconstrucción histórica.
### Áreas a revisar antes de tocar un flujo
- Inventario: `src/asset-master/`.
- Inspecciones: `src/inspection-visits/`.
- Actas: `src/inspection-acts/`, `src/inspection-closing/`, `src/act-administration/`.
- Hallazgos: `src/inspection-findings/` y gate móvil en `src/inspection-visits/field-findings.service.ts`.
- Informes/GEDO: `src/inspection-reports/`.
- Verificaciones: `src/inspection-verifications/`.
- Entidades/migraciones: `src/database/`.
---
## 6. WEB
La WEB es el frente principal de administración, planificación y seguimiento. Las rutas protegidas se declaran en `web-v2/src/app/App.tsx` y usan permisos para controlar acceso.
Módulos principales:
- Dashboard.
- Mapa.
- Inventarios y dossier.
- Revisión de Inventario nacido en campo.
- Inspecciones y preparación de campo.
- Actas.
- Hallazgos y planificación de verificaciones.
- Seguimiento de Actas.
- Informes.
- Historial y consulta temporal.
- Administración de usuarios, roles, auditoría, tipos y catálogo.
No esconda una regla sólo en la WEB: toda restricción de integridad debe existir también en API/DB.
---
## 7. Android
La APK está orientada a ejecución en campo.
Reglas que deben mantenerse:
- sólo un usuario con capacidad de ejecución puede iniciar/operar una Inspección;
- `Abrir inspección` selecciona Área y Operadora vigente;
- la nueva visita queda autoasignada al inspector actual;
- se genera checklist antes de iniciar;
- el backend recorre DRAFT → PLANNED → IN_PROGRESS con el lifecycle canónico;
- Inventario nuevo en campo captura evidencia mínima antes de Hallazgos;
- Hallazgos respetan los mismos niveles y catálogo que la API.
Al cambiar contratos móviles, actualice tanto Kotlin como los tests de contrato montados por el preflight de API.
---
## 8. Permisos y seguridad
El sistema utiliza permisos finos; las pantallas no deben asumir permisos por nombre visual del rol. La fuente efectiva es la matriz rol-permiso de base de datos.
Buenas prácticas:
- proteja controllers con permisos explícitos;
- no confíe en que una ruta esté oculta en la WEB;
- preserve la política especial del inspector móvil;
- mantenga auditadas las mutaciones administrativas;
- no exponga secretos ni credenciales en repositorio, logs o documentación.
---
## 9. Tests y CI
La barrera principal está en `.github/workflows/ci.yml`.
### API
Ejecuta:
```bash
npm ci
npm run typecheck
npm test
npm run build
```
### WEB
Ejecuta:
```bash
npm ci
npm run typecheck
bash ../scripts/check-f3-1-web-contract.sh
npm run build
```
### Docker / contratos
Valida:
- sintaxis de scripts shell;
- paridad del preflight de deploy;
- `docker compose config`;
- cadena de migraciones sobre PostGIS limpio;
- estado limpio F5.1 y preservación de maestros técnicos;
- segunda ejecución sin migraciones pendientes;
- preflight API aislado equivalente al VPS;
- build de imágenes productivas.
### Android
El workflow específico de Android debe completar:
- `assembleDebug`;
- unit tests;
- publicación del artefacto APK.
### Contrato transversal F6.1
`api-v3/test/unit/f6-1-project-invariants.test.ts` fija las reglas que más fácilmente podrían degradarse por refactors: jerarquía, contexto Área+Operadora, multi-Acta, Hallazgos, apertura móvil y separación GEDO/vencimiento.
---
## 10. Versionado
Cuando una entrega cambia versión, revise como mínimo:
- `api-v3/package.json`;
- `api-v3/src/version.ts`;
- `web-v2/package.json`;
- `android-app/app/build.gradle.kts` si cambia APK;
- tests que fijen metadata de versión;
- metadata raíz de `package-lock.json` regenerándola con npm cuando corresponda.
Los lockfiles son archivos generados: no los edite manualmente en forma parcial. Use npm para reconciliarlos.
---
## 11. Flujo Git y despliegue
Secuencia recomendada:
1. crear rama desde `main` estable;
2. implementar código + migración + tests + documentación;
3. abrir PR;
4. exigir API + WEB + Docker/migraciones + Android verdes cuando aplique;
5. mergear a `main`;
6. exigir nuevamente CI verde sobre el SHA de `main`;
7. recién entonces promover el SHA exacto a `deploy`;
8. esperar el watcher/deploy;
9. comprobar SHA objetivo, health, versiones y backup generado.
Nunca despliegue un SHA distinto al que pasó la barrera. Nunca use `deploy` para “probar” una rama incompleta.
Si una migración es de una sola vía, el rollback operativo es el backup PRE validado, no un `migration:revert` improvisado.
---
## 12. Checklist antes de aprobar un cambio
- ¿Respeta la jerarquía canónica?
- ¿Empresa sigue fuera del árbol físico?
- ¿Una modificación de Operadora conserva historia temporal?
- ¿La regla importante está protegida también en API/DB?
- ¿Hallazgos siguen limitados a Yacimiento/Instalación/Subinstalación?
- ¿OTROS sigue disponible?
- ¿Una Inspección puede tener varias Actas y sólo un DRAFT simultáneo?
- ¿Actas finalizadas siguen inmutables?
- ¿Informe sigue perteneciendo al Acta?
- ¿GEDO sigue separado del inicio automático del plazo No urgente?
- ¿WEB y Android consumen el mismo contrato?
- ¿Se agregó un test para la nueva regla o regresión?
- ¿CI completa está verde?
- ¿Documentación viva quedó actualizada?
---
## 13. Terminología canónica
Use consistentemente:
- **Inventario**, no “Activo” en la interfaz de usuario cuando se refiere al módulo funcional.
- **Departamento**.
- **Área**.
- **Yacimiento**.
- **Instalación**.
- **Subinstalación**.
- **Empresa / Operadora** como maestro/rol operativo, no como padre físico.
- **Inspección** para la visita operativa.
- **Acta** para el documento de campo; una Inspección puede contener varias.
- **Hallazgo** para la observación fiscalizable asociada a Inventario.
- **Informe / INF** por Acta.
- **GEDO / IF** para la oficialización documental, separada de la notificación administrativa cuando corresponda.
Si una etiqueta histórica contradice estos términos, corrija la etiqueta; no cambie el modelo para conservar una denominación obsoleta.
+353
View File
@@ -0,0 +1,353 @@
# Manual de Usuario · DH Inspección V2
**Versión funcional de referencia:** F6.1
Este manual explica el uso cotidiano del sistema. Las opciones visibles dependen de los permisos asignados a cada usuario, por lo que un Inspector, Supervisor, Director o Administrador puede ver acciones distintas.
---
## 1. Conceptos principales
DH Inspección V2 organiza el trabajo alrededor de cuatro elementos:
1. **Inventario:** dónde se inspecciona.
2. **Inspección:** la visita de trabajo.
3. **Acta:** documento generado durante la Inspección; una Inspección puede tener varias Actas.
4. **Hallazgo:** observación asociada a un elemento concreto del Inventario.
Además, cada Acta puede continuar con su **Informe (INF)**, oficialización documental y seguimiento posterior.
---
## 2. Ingreso al sistema
1. Abra la WEB de DH Inspección V2.
2. Ingrese usuario y contraseña.
3. El menú mostrará solamente los módulos habilitados por sus permisos.
Si una opción no aparece o el sistema informa que no tiene autorización, consulte al Administrador antes de intentar resolverlo creando datos alternativos.
---
## 3. Cómo se organiza el Inventario
La estructura física correcta es siempre:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
La **Empresa** no forma parte de esa cadena. Se administra por separado y se vincula al Área cuando actúa como Operadora.
Ejemplo:
- Departamento: Malargüe
- Área: Área A
- Yacimiento: Yacimiento Norte
- Instalación: Planta de Tratamiento
- Subinstalación: Separador 01
Una Empresa puede cambiar con el tiempo sin modificar esta estructura física.
---
## 4. Carga manual de Inventario
Para construir una estructura confiable desde cero, use este orden:
1. Cree las **Empresas** necesarias.
2. Cree el **Departamento**.
3. Cree el **Área** dentro del Departamento.
4. Registre la **Operadora vigente** del Área si corresponde.
5. Cree el **Yacimiento** dentro del Área.
6. Cree la **Instalación** dentro del Yacimiento.
7. Seleccione la clasificación técnica de la Instalación.
8. Cree la **Subinstalación** dentro de la Instalación.
9. Seleccione una clasificación técnica compatible para la Subinstalación.
El sistema no permite saltear niveles. Por ejemplo, una Instalación no puede depender directamente de un Área.
### Empresa y Operadora
Cuando una Empresa opera un Área, registre la relación operativa correspondiente. Si cambia la Operadora:
1. finalice la relación vigente anterior;
2. registre la nueva Operadora;
3. no mueva Yacimientos, Instalaciones ni Subinstalaciones.
El historial queda conservado para consultas temporales.
---
## 5. Consultar Inventario
Desde **Inventarios** puede navegar la estructura territorial y abrir el dossier de cada registro.
Utilice el breadcrumb para volver hacia niveles superiores. El dossier concentra, según la información disponible:
- datos actuales;
- ubicación y clasificación;
- fotografías;
- historial de cambios;
- Hallazgos;
- verificaciones;
- Actas e Informes relacionados.
Cuando necesite saber cómo estaba un elemento en una fecha anterior, use la **Consulta temporal** en lugar de interpretar sólo sus datos actuales.
---
## 6. Crear una Inspección desde la WEB
En **Inspecciones → Nueva Inspección**:
1. seleccione el **Área**;
2. seleccione una **Operadora vigente** para esa Área;
3. seleccione el **Inspector responsable**;
4. indique la fecha y hora prevista de inicio;
5. confirme la creación.
Al crearse la Inspección, el sistema genera su checklist de antecedentes. Antes de planificar, la información obligatoria debe estar completa.
Una Inspección planificada se inicia operativamente desde la APK por un Inspector habilitado y asignado.
---
## 7. Abrir una Inspección directamente desde la APK
La APK también permite comenzar una Inspección que no haya sido preparada previamente en la WEB.
Desde la pantalla principal:
1. pulse **Abrir inspección**;
2. elija el **Área**;
3. elija la **Operadora vigente**;
4. pulse **Abrir inspección ahora**.
El sistema:
- crea la Inspección;
- la asigna al Inspector que inició sesión;
- genera su checklist;
- la planifica;
- la deja **En curso**.
No elija una Empresa por aproximación: la APK muestra la relación operativa válida del Área.
---
## 8. Trabajar con Inventario durante una Inspección
Dentro de una Inspección en curso puede seleccionar Inventario existente del Área y agregarlo al trabajo de campo/Acta correspondiente.
Los niveles relevantes para Hallazgos son:
- Yacimiento;
- Instalación;
- Subinstalación.
### Inventario encontrado en campo
Si encuentra una Instalación o Subinstalación que todavía no existe, la APK puede darla de alta dentro de la jerarquía correspondiente.
Antes de poder registrar Hallazgos sobre un elemento nuevo de campo, debe quedar como mínimo con:
- GPS de creación;
- al menos una fotografía;
- clasificación técnica si es Instalación o Subinstalación.
Esto evita que un Hallazgo quede asociado a un objeto sin identificación suficiente.
---
## 9. Actas: una Inspección puede tener varias
Una misma Inspección puede generar **varias Actas**.
La regla práctica es:
- puede haber varias Actas históricas dentro de la Inspección;
- sólo puede existir **un Acta en borrador al mismo tiempo**.
Mientras un Acta está en borrador puede completar su contenido y agregar Hallazgos. Una vez finalizada/bloqueada deja de ser editable como un borrador normal.
Para continuar con una nueva Acta dentro de la misma Inspección, finalice correctamente la actual antes de abrir la siguiente.
---
## 10. Registrar Hallazgos
Los Hallazgos se asignan directamente a:
- **Yacimiento**;
- **Instalación**;
- **Subinstalación**.
No se crean Hallazgos directos sobre Departamento ni Área.
### Hallazgo de catálogo
Si el sistema muestra un Hallazgo aplicable al elemento seleccionado:
1. elija el Hallazgo del catálogo;
2. complete la descripción y datos requeridos;
3. guarde.
### OTROS / Agregar otro
La opción **OTROS** debe estar siempre disponible.
Úsela cuando el Hallazgo observado no exista en la lista disponible. Describa claramente qué se encontró para que luego pueda revisarse y normalizarse en oficina.
Después de guardar un Hallazgo puede utilizar **Agregar otro** para continuar cargando Hallazgos sobre el mismo Inventario.
### En Yacimiento
Un Yacimiento puede recibir Hallazgos aunque no tenga clasificación técnica. Cuando exista catálogo configurado para ese tipo, el sistema puede ofrecerlo; en todos los casos conserva la opción OTROS.
---
## 11. Finalizar y sellar un Acta
Antes de finalizar un Acta revise especialmente:
- Inventario incluido;
- Hallazgos;
- observaciones;
- firmas y datos requeridos por el flujo.
Al finalizar, el sistema bloquea el contenido y genera controles de integridad. Esto significa que **el Acta deja de ser editable como borrador**.
No finalice un Acta para “probar” cómo queda. Primero revise todo su contenido.
Cuando el flujo de firmas y sellado termina, el Acta queda `SELLADA` y preparada para continuar con su documentación posterior.
---
## 12. Cerrar una Inspección
Para cerrar una Inspección en curso deben cumplirse las condiciones operativas, entre ellas:
- existir al menos un Acta;
- todas las Actas no canceladas deben estar selladas;
- si la Inspección contiene verificaciones, sus resultados deben estar registrados.
Si el sistema impide el cierre, revise el mensaje mostrado: normalmente identifica el Acta o la verificación pendiente.
---
## 13. Informes por Acta
El Informe se genera y administra **por Acta**.
Por eso, si una Inspección tuvo tres Actas, el seguimiento documental no debe tratarla como si existiera un único Informe global.
Desde **Informes** puede consultar el estado del INF y continuar el flujo habilitado para su rol.
---
## 14. GEDO / IF y plazo No urgente
Registrar el identificador y PDF oficial de GEDO/IF **oficializa el Informe**, pero por sí solo no significa que haya comenzado automáticamente el plazo administrativo de una Acta No urgente.
El vencimiento debe activarse a partir del evento administrativo de notificación que corresponda al procedimiento vigente.
Si ve un Informe oficializado sin fecha de vencimiento activa, no lo corrija cargando una fecha ficticia de GEDO como notificación.
---
## 15. Seguimiento de Hallazgos
Después del trabajo de campo, un Hallazgo mantiene su historia. El seguimiento puede incluir:
- respuesta de la Empresa;
- documentos y evidencias;
- fecha de control;
- planificación de una verificación;
- resultado de verificación;
- nueva fecha si continúa pendiente;
- cierre administrativo.
No reescriba el Hallazgo original para reflejar novedades posteriores. Use los eventos de seguimiento para conservar la trazabilidad.
---
## 16. Verificaciones
Cuando un Hallazgo requiere control posterior:
1. defina la fecha de verificación cuando corresponda;
2. planifique la visita de verificación;
3. ejecute la verificación;
4. registre el resultado y evidencia;
5. si no está resuelto, reprograme según el flujo;
6. si está resuelto y la visita quedó cerrada, continúe con el cierre administrativo del Hallazgo.
El historial de verificaciones no debe borrarse para reemplazarlo por el último resultado.
---
## 17. Historial y Consulta temporal
Use **Historial** para revisar cambios y eventos registrados.
Use **Consulta temporal** cuando la pregunta sea, por ejemplo:
- “¿qué Empresa operaba esta Área en esa fecha?”;
- “¿dónde estaba ubicado este elemento?”;
- “¿qué estado tenía el Inventario en ese momento?”.
La vista actual y la vista histórica cumplen funciones distintas.
---
## 18. Revisión de Inventario nacido en campo
Los registros creados por Inspectores en la APK pueden requerir revisión posterior en oficina.
Al revisar:
- confirme nombre/código;
- confirme la ubicación jerárquica;
- verifique clasificación técnica;
- revise GPS/fotografías;
- normalice información pendiente sin perder el origen de campo.
No cree un duplicado sólo porque el nombre no coincide exactamente; primero confirme si se trata del mismo objeto.
---
## 19. Mensajes frecuentes
### “La Operadora seleccionada no tiene una relación operativa vigente con el Área”
Revise la relación Empresa ⇄ Área y su vigencia. No cambie la jerarquía física para resolverlo.
### “La Instalación/Subinstalación debe tener una clasificación técnica”
Complete la familia técnica del elemento antes de registrar el Hallazgo.
### “El Inventario creado en campo debe tener GPS y al menos una foto”
Complete la captura de campo requerida y vuelva a intentar.
### “Todas las Actas deben estar SELLADAS antes de cerrar la Inspección”
Abra la Inspección y revise cuáles Actas todavía están en borrador, bloqueadas o pendientes de sellado.
### “La inspección no tiene un Acta borrador abierta”
Cree/abra un Acta borrador para esa Inspección antes de cargar nuevos Hallazgos.
---
## 20. Buenas prácticas
- Cargue la estructura de Inventario en orden jerárquico.
- No use Empresa como padre físico.
- No duplique Inventario ante la primera diferencia de nombre.
- Use OTROS cuando el Hallazgo no exista en catálogo.
- Revise un Acta antes de finalizarla: luego queda inmutable.
- Use seguimiento y verificaciones para agregar historia; no reescriba hechos anteriores.
- Use Consulta temporal para preguntas históricas.
- Respete los mensajes de integridad del sistema en lugar de forzar datos ficticios.
Ante una duda funcional que pueda modificar jerarquía, Actas, Hallazgos o vencimientos, consulte antes de cargar datos masivamente.
+128
View File
@@ -0,0 +1,128 @@
# DH Inspección V2 · corte de presentación F6.1
Este documento define el alcance verificable del corte F6.1 y el recorrido recomendado para una presentación funcional. No reemplaza al Manual de Usuario ni al Manual del Programador.
## Versiones del corte
- API: `0.29.0-1`.
- WEB: `0.23.0-1`.
- Android: `0.15.0` (`versionCode 22`).
- Modelo funcional: **F6.1 · contexto operativo ÁreaOperadora consolidado**.
La versión visible en el footer WEB debe coincidir con `web-v2/package.json`; este contrato queda protegido por tests.
## Matriz de aceptación funcional
### Inventario
- Jerarquía física vigente: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa permanece fuera del árbol físico.
- La Operadora se resuelve por relación temporal vigente con el Área.
- Instalación y Subinstalación admiten familia **Otro / no catalogado** cuando corresponda.
- Un alta de campo conserva inspección, actor, fecha, contexto, GPS y evidencia exigida por el flujo.
> El issue histórico F3.1 describía un árbol que comenzaba en Área. Esa definición fue supersedida por F5.1/F6, que incorporó Departamento como raíz territorial autorizada.
### Merge cronológico de Inventario
- Sólo se fusionan Instalaciones/Subinstalaciones compatibles.
- El registro duplicado no se elimina: queda fusionado/inactivo y conserva identidad histórica.
- Las referencias históricas emitidas no se reescriben.
- El dossier canónico agrega cronológicamente alias y eventos, mostrando el Inventario original cuando corresponde.
- WEB muestra el destino canónico.
- Android, al conciliar un alta nacida en campo, selecciona inmediatamente el canónico y confirma qué código se conserva.
### Hallazgos y catálogo
- `OTROS` permanece disponible en el resolver de Hallazgos.
- La fusión de catálogo mueve aplicabilidad futura al canónico sin reescribir Hallazgos ya emitidos.
- Las instancias históricas conservan el modelo append-only y la trazabilidad de auditoría.
### Inspecciones y Android
- Una inspección planificada puede iniciarse desde Android por un Inspector autorizado y asignado.
- El inicio registra `actualStartedAt`, actor y auditoría del servidor.
- El contexto operativo es Área + Operadora vigente.
- Una Inspección puede tener múltiples Actas, con un único borrador simultáneo.
- El cierre exige Actas selladas y verificaciones requeridas completas.
### Perfil del Inspector
- Nombre y apellido obligatorios.
- Datos administrativos disponibles: DNI, email, teléfono, cargo/función y legajo/matrícula.
- El email es obligatorio para rol Inspector.
### Entrega documental
- El Acta PDF se prepara para Empresa, Oficina e Inspector principal.
- El INF Word se prepara también para el Inspector principal cuando existe.
- El outbox conserva destinatario, estado, intentos, error y fecha de envío; los pendientes son reintentables.
- La entrega depende de SMTP y destinatarios configurados: si faltan, el registro queda en un estado de espera auditable en lugar de perderse.
### Relevamientos
- `Relevamientos` no existe como módulo/ruta/entrada de menú activa.
- La captura en campo vive dentro de Inspecciones + Inventario de campo.
## Seguridad de dependencias del corte
La candidata mantiene una barrera separada para dependencias productivas de API y WEB mediante `npm audit --omit=dev --audit-level=high`.
Para F6.1 se fijaron las resoluciones parcheadas que eliminan los advisories detectados durante el cierre:
- API: `multer 2.3.0` y `qs 6.16.0`.
- WEB: `maplibre-gl 6.4.1`.
Las dependencias de runtime con vulnerabilidades altas o críticas bloquean el corte aunque typecheck, tests y builds estén verdes.
## Barrera técnica obligatoria
Un SHA sólo es candidato de presentación cuando pasan:
1. API typecheck, tests y build.
2. WEB typecheck y build.
3. auditoría de dependencias productivas de API y WEB, sin advisories altos/críticos.
4. migraciones sobre PostGIS limpio.
5. arranque real de API.
6. preflight aislado equivalente al entorno Docker.
7. build de imágenes productivas.
8. Android lint.
9. Android unit tests reales.
10. Android `assembleDebug`.
11. Android `assembleRelease`.
12. APK debug con SHA-256 y metadata del SHA exacto.
## Recorrido sugerido de demo
1. **Login WEB** y Dashboard.
2. **Inventarios**: navegar Departamento → Área → Yacimiento → Instalación → Subinstalación y abrir un dossier.
3. Mostrar la **cronología/dossier**, documentos, fotos y Hallazgos.
4. Mostrar, si existe un caso preparado, una **fusión cronológica** y el aviso de registro canónico.
5. **Usuarios**: abrir un Inspector y mostrar perfil, email documental y roles.
6. **Inspecciones**: crear/abrir una planificación con Área + Operadora, Inspector y checklist.
7. **Android**: ingresar con Inspector, abrir o seleccionar la Inspección e iniciarla.
8. En Android, abrir **Inventario de campo**, seleccionar un registro o crear uno nuevo con GPS y foto. Para una familia no catalogada, usar **Otro / no catalogado**.
9. Crear un **Acta**, registrar un **Hallazgo** (incluyendo `OTROS` si se quiere demostrar el fallback), adjuntar evidencia y sellar el Acta.
10. Cerrar la Inspección cuando todas las Actas estén selladas.
11. En WEB, mostrar **Actas / Informes / Entrega documental** y el estado auditable de los envíos.
## Preparación del entorno de presentación
Antes de una demo con envío real de correo, verificar en Administración:
- email institucional de Oficina;
- email de la Empresa involucrada;
- email del Inspector principal;
- SMTP configurado y operativo.
Para una demo sin correo saliente, el resto del flujo puede demostrarse y la bandeja de entrega debe reflejar el estado de espera correspondiente; no se debe presentar un envío como exitoso si el transporte no está configurado.
## Evidencia del corte
El SHA presentado debe conservarse junto con:
- resultado verde de GitHub Actions;
- auditoría productiva verde;
- metadata de versiones;
- APK debug de la candidata y su SHA-256;
- si se distribuye APK productiva, firma histórica verificada y SHA-256 del artefacto firmado.
+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)"
+8 -2
View File
@@ -247,16 +247,22 @@ echo "========== HEALTH =========="
HEALTH_OK=0 HEALTH_OK=0
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then if curl -fsS --max-time 5 http://127.0.0.1:3101/api/v3/health > "$BACKUP/health.json" 2>/dev/null; then
if grep -F '"status":"ok"' "$BACKUP/health.json" >/dev/null; then if grep -Fq '"status":"ok"' "$BACKUP/health.json" \
&& grep -Fq "\"version\":\"$EXPECTED_API_VERSION\"" "$BACKUP/health.json" \
&& grep -Fq '"database":"ok"' "$BACKUP/health.json"; then
HEALTH_OK=1 HEALTH_OK=1
break break
fi fi
CURRENT_API_VERSION="$(node -e 'try { const h = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.stdout.write(String(h.version || "unknown")); } catch { process.stdout.write("invalid"); }' "$BACKUP/health.json")"
echo "API respondió pero aún no es la candidata (actual=$CURRENT_API_VERSION, esperada=$EXPECTED_API_VERSION). Reintentando..."
fi fi
sleep 2 sleep 2
done done
if [ "$HEALTH_OK" -ne 1 ]; then if [ "$HEALTH_OK" -ne 1 ]; then
echo "ERROR: API no pasó healthcheck." echo "ERROR: API candidata no pasó healthcheck/version/database dentro del plazo."
[ ! -f "$BACKUP/health.json" ] || cat "$BACKUP/health.json"
docker compose logs --tail=180 api docker compose logs --tail=180 api
false false
fi fi
+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
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.21.0-1", "version": "0.23.0-1",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -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"
+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 />} />
+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,7 +7,6 @@ import {
changeAssetContext, changeAssetContext,
listAssetContextHistory, listAssetContextHistory,
listAssetParentOptions, listAssetParentOptions,
listCompaniesForArea,
listOperationalAreas, listOperationalAreas,
} from '../../lib/api'; } from '../../lib/api';
import type { import type {
@@ -29,7 +28,6 @@ function contextLabel(item: AssetContextHistoryItem) {
const parts = [ const parts = [
item.parent ? `Padre: ${item.parent.name}` : 'Sin padre', item.parent ? `Padre: ${item.parent.name}` : 'Sin padre',
item.operationalArea ? `Área: ${item.operationalArea.name}` : null, item.operationalArea ? `Área: ${item.operationalArea.name}` : null,
item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null,
].filter(Boolean); ].filter(Boolean);
return parts.join(' · '); return parts.join(' · ');
} }
@@ -55,9 +53,7 @@ export function AssetContextHistoryPanel({
const [parentSearch, setParentSearch] = useState(''); const [parentSearch, setParentSearch] = useState('');
const [parents, setParents] = useState<AssetListItem[]>([]); const [parents, setParents] = useState<AssetListItem[]>([]);
const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? ''); const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? '');
const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? '');
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]); const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow()); const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow());
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
@@ -94,29 +90,14 @@ export function AssetContextHistoryPanel({
setAreas(items); setAreas(items);
if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) { if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) {
setOperationalAreaId(''); setOperationalAreaId('');
setOperatorCompanyId('');
} }
}) })
.catch((requestError) => setError(errorMessage(requestError))); .catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, parentId]); }, [editing, genericContext, parentId]);
useEffect(() => {
if (!editing || !genericContext || !operationalAreaId) {
setCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId)
.then((items) => {
setCompanies(items);
if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId('');
})
.catch((requestError) => setError(errorMessage(requestError)));
}, [editing, genericContext, operationalAreaId]);
const beginEdit = () => { const beginEdit = () => {
setParentId(asset.parent?.id ?? ''); setParentId(asset.parent?.id ?? '');
setOperationalAreaId(asset.operationalArea?.id ?? ''); setOperationalAreaId(asset.operationalArea?.id ?? '');
setOperatorCompanyId(asset.operatorCompany?.id ?? '');
setEffectiveAt(localDateTimeNow()); setEffectiveAt(localDateTimeNow());
setReason(''); setReason('');
setError(''); setError('');
@@ -133,13 +114,12 @@ export function AssetContextHistoryPanel({
const saved = await changeAssetContext(asset.id, { const saved = await changeAssetContext(asset.id, {
parentId: parentId || null, parentId: parentId || null,
operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null, operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null,
operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null,
effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined, effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined,
reason, reason,
}); });
onChanged(saved); onChanged(saved);
setEditing(false); setEditing(false);
setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.'); setSuccess('Contexto físico actualizado. La asignación anterior quedó preservada en el historial.');
loadHistory(); loadHistory();
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)); setError(errorMessage(requestError));
@@ -150,30 +130,29 @@ export function AssetContextHistoryPanel({
return <article className="panel asset-history-panel"> return <article className="panel asset-history-panel">
<div className="panel-heading"> <div className="panel-heading">
<div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía, Área y Operadora</h2></div> <div><span className="eyebrow">CONTEXTO TEMPORAL</span><h2>Jerarquía y Área</h2></div>
{canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto</button>} {canManage && !editing && <button type="button" className="button secondary" onClick={beginEdit}><Icon name="edit" />Cambiar contexto físico</button>}
</div> </div>
<p className="section-copy">Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.</p> <p className="section-copy">Los cambios físicos no reemplazan la historia. La Operadora se administra por separado en las relaciones temporales del Área.</p>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>} {success && <Alert type="success">{success}</Alert>}
{current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto vigente.</strong> {contextLabel(current)}</p></div>} {current && <div className="temporal-notice"><Icon name="layers" /><p><strong>Contexto físico vigente.</strong> {contextLabel(current)}</p></div>}
{editing && <form className="form-section" onSubmit={save}> {editing && <form className="form-section" onSubmit={save}>
<div><h3>Cambiar contexto vigente</h3><p className="section-copy">Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.</p></div> <div><h3>Cambiar contexto físico vigente</h3><p className="section-copy">Indicá la nueva ubicación dentro del Inventario y el motivo. La relación física anterior se cierra automáticamente.</p></div>
<div className="form-grid"> <div className="form-grid">
<div className="field parent-picker"><span>Registro padre</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}><option value="">{type.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect></div> <div className="field parent-picker"><span>Registro padre</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}><option value="">{type.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect></div>
<label className="field"><span>Vigente desde</span><input type="datetime-local" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} required /><small>Puede registrarse una vigencia pasada si corresponde a un cambio ya ocurrido.</small></label> <label className="field"><span>Vigente desde</span><input type="datetime-local" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} required /><small>Puede registrarse una vigencia pasada si corresponde a un cambio ya ocurrido.</small></label>
</div> </div>
{genericContext && <div className="form-grid"> {genericContext && <div className="form-grid">
<label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label> <label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => setOperationalAreaId(event.target.value)} required><option value="">Seleccionar Área</option>{areas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect><small>La Operadora vigente se resuelve desde la relación temporal del Área.</small></label>
<label className="field"><span>Operadora</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} required disabled={!operationalAreaId}><option value="">{operationalAreaId ? 'Seleccionar Operadora…' : 'Primero seleccioná un Área'}</option>{companies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>
</div>} </div>}
<label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: transferencia operativa, corrección documental, reubicación física…" /></label> <label className="field"><span>Motivo del cambio</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={5} maxLength={2000} rows={3} required placeholder="Ej.: corrección documental, reubicación física, ajuste de jerarquía…" /></label>
<div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio'}</button></div> <div className="form-actions"><button type="button" className="button secondary" onClick={() => setEditing(false)} disabled={saving}>Cancelar</button><button className="button primary" disabled={saving || reason.trim().length < 5}><Icon name="check" />{saving ? 'Registrando…' : 'Registrar cambio físico'}</button></div>
</form>} </form>}
<div className="form-section"><div><h3>Historial de contexto</h3><p className="section-copy">Se muestra la secuencia completa de relaciones conocidas del elemento.</p></div> <div className="form-section"><div><h3>Historial de contexto físico</h3><p className="section-copy">Se muestra la secuencia completa de ubicaciones conocidas del elemento.</p></div>
{loading ? <LoadingBlock label="Cargando contexto…" /> : history.length === 0 ? <div className="inline-empty">Todavía no hay contexto histórico registrado.</div> : <div className="asset-timeline">{history.map((item) => <div className="timeline-entry" key={item.id}><span className={`timeline-dot ${item.isCurrent ? 'current' : ''}`} /><span><strong>{item.isCurrent ? 'Vigente' : 'Histórico'} · v{item.assetVersionNumber}</strong><small>{formatDate(item.validFrom)} {item.validUntil ? formatDate(item.validUntil) : 'actualidad'}{item.creator ? ` · ${item.creator.username}` : ''}</small><span>{contextLabel(item)}</span><small>{item.changeReason}</small></span></div>)}</div>} {loading ? <LoadingBlock label="Cargando contexto…" /> : history.length === 0 ? <div className="inline-empty">Todavía no hay contexto histórico registrado.</div> : <div className="asset-timeline">{history.map((item) => <div className="timeline-entry" key={item.id}><span className={`timeline-dot ${item.isCurrent ? 'current' : ''}`} /><span><strong>{item.isCurrent ? 'Vigente' : 'Histórico'} · v{item.assetVersionNumber}</strong><small>{formatDate(item.validFrom)} {item.validUntil ? formatDate(item.validUntil) : 'actualidad'}{item.creator ? ` · ${item.creator.username}` : ''}</small><span>{contextLabel(item)}</span><small>{item.changeReason}</small></span></div>)}</div>}
</div> </div>
</article>; </article>;
@@ -1,59 +1,98 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
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 { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api'; import {
import type { FindingCatalogAssetSelection } from '../../lib/api'; getInventoryFamilyFindings,
getInventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import type {
InventoryFamilyFinding,
InventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import { AssetTechnicalDataPanel } from './AssetTechnicalDataPanel';
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) { export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null); const { hasPermission } = useAuth();
const [enabled, setEnabled] = useState<Set<string>>(new Set()); const canEditTechnical = hasPermission('assets.update');
const [reason, setReason] = useState(''); const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [items, setItems] = useState<InventoryFamilyFinding[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [unsupported, setUnsupported] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => { useEffect(() => {
setSelection(loaded); let active = true;
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id))); setLoading(true);
}); setUnsupported(false);
setError('');
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]); getInventoryTechnicalValues(assetId)
.then(async (loaded) => {
const findings = await getInventoryFamilyFindings(loaded.family.id);
if (!active) return;
setTechnical(loaded);
setItems(findings.items);
})
.catch((requestError) => {
if (!active) return;
const message = errorMessage(requestError);
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
else setError(message);
})
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [assetId]);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase(); const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName] if (!needle) return items;
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? []; return items.filter((item) => [item.title, item.code, item.categoryName, item.legalBasis ?? '']
}, [selection, search]); .some((value) => value.toLocaleLowerCase().includes(needle)));
}, [items, search]);
const toggle = (id: string) => setEnabled((current) => { if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>;
const next = new Set(current); if (error) return <Alert>{error}</Alert>;
if (next.has(id)) next.delete(id); else next.add(id); if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
return next; <div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div>
});
const save = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason('');
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en Catálogo de hallazgos.</p></div>}
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar excepciones'}</button></div></>}
</section>; </section>;
return <div className="asset-tab-stack">
<AssetTechnicalDataPanel assetId={assetId} canEdit={canEditTechnical} />
<section className="panel asset-finding-catalog-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">HALLAZGOS APLICABLES</span>
<h2>{technical.family.name}</h2>
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
</div>
<span className="count-pill">{items.length} asociados</span>
</div>
{canManage && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
</div>}
{items.length > 0 && <label className="search-field">
<Icon name="search" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`Buscar entre los ${items.length} Hallazgos asociados…`} />
</label>}
{items.length === 0
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
: visible.length === 0
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
<span className="asset-symbol"><Icon name="alert" size={15} /></span>
<span>
<strong>{item.title}</strong>
<small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small>
{item.legalBasis && <small>{item.legalBasis}</small>}
</span>
</div>)}</div>}
</section>
</div>;
} }
@@ -6,11 +6,13 @@ import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api'; import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api'; import type { AssetLineageItem } from '../../lib/api';
import { import {
listInventoryAreas, listInventoryCompanies,
listInventoryDepartments,
listInventoryChildren, listInventoryChildren,
} from '../../lib/inventoryBrowserApi'; } from '../../lib/inventoryBrowserApi';
import type { import type {
InventoryBrowserArea, InventoryBrowserCompany,
InventoryBrowserDepartment,
InventoryBrowserItem, InventoryBrowserItem,
InventoryQuery, InventoryQuery,
} from '../../lib/inventoryBrowserApi'; } from '../../lib/inventoryBrowserApi';
@@ -26,28 +28,30 @@ function navigationHref(base: URLSearchParams, parentId?: string) {
return `/inventarios${params.size ? `?${params}` : ''}`; return `/inventarios${params.size ? `?${params}` : ''}`;
} }
function AreaCard({ area, href }: { area: InventoryBrowserArea; 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>
<span className="asset-browser-item-main"> <span className="asset-browser-item-main">
<strong>{area.name}</strong> <strong>{department.name}</strong>
<small> <small>{department.code} · {department.areaCount} Área{department.areaCount === 1 ? '' : 's'}</small>
{area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'}
{area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'}
</small>
</span>
<span className="asset-browser-item-status">
<strong>{area.inventoryCount}</strong>
<small>instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'}</small>
</span> </span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(department.informationStatus)}`}>{assetStatusLabel(department.informationStatus)}</span></span>
<Icon name="chevron" size={16} />
</Link>;
}
function CompanyCard({ company }: { company: InventoryBrowserCompany }) {
return <Link className="asset-browser-item" to={`/inventarios/${company.id}`}>
<span className="asset-browser-item-icon"><Icon name="users" size={17} /></span>
<span className="asset-browser-item-main"><strong>{company.name}</strong><small>{company.code} · {company.areaCount} Área{company.areaCount === 1 ? '' : 's'} operada{company.areaCount === 1 ? '' : 's'}</small></span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(company.informationStatus)}`}>{assetStatusLabel(company.informationStatus)}</span></span>
<Icon name="chevron" size={16} /> <Icon name="chevron" size={16} />
</Link>; </Link>;
} }
function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) { function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) {
const structural = !item.isInventoryInstance;
return <Link className="asset-browser-item" to={href}> return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name={structural ? 'map' : 'layers'} size={17} /></span> <span className="asset-browser-item-icon"><Icon name={['departamento','area','yacimiento'].includes(item.type.code.toLowerCase()) ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-main"> <span className="asset-browser-item-main">
<strong>{item.name}</strong> <strong>{item.name}</strong>
<small> <small>
@@ -57,9 +61,10 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin
</small> </small>
</span> </span>
<span className="asset-browser-item-status"> <span className="asset-browser-item-status">
{structural <span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
? <><span className="tag">Contexto</span><small>{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'}</small></> {item.inventoryFamily
: <><span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span><small>{assetOperationalStatusLabel(item.operationalStatus)}</small></>} ? <small>{item.findingCount} Hallazgo{item.findingCount === 1 ? '' : 's'} asociado{item.findingCount === 1 ? '' : 's'}</small>
: <small>{item.childrenCount} registro{item.childrenCount === 1 ? '' : 's'} inferior{item.childrenCount === 1 ? '' : 'es'}</small>}
</span> </span>
<Icon name="chevron" size={16} /> <Icon name="chevron" size={16} />
</Link>; </Link>;
@@ -67,6 +72,7 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin
function nextLevelLabel(typeCode: string | undefined) { function nextLevelLabel(typeCode: string | undefined) {
switch (typeCode?.toLowerCase()) { switch (typeCode?.toLowerCase()) {
case 'departamento': return 'Áreas';
case 'area': return 'Yacimientos'; case 'area': return 'Yacimientos';
case 'yacimiento': return 'Instalaciones'; case 'yacimiento': return 'Instalaciones';
case 'instalacion': return 'Subinstalaciones'; case 'instalacion': return 'Subinstalaciones';
@@ -79,7 +85,8 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const parentId = searchParams.get('parentId') ?? ''; const parentId = searchParams.get('parentId') ?? '';
const [areas, setAreas] = useState<InventoryBrowserArea[]>([]); const [departments, setDepartments] = useState<InventoryBrowserDepartment[]>([]);
const [companies, setCompanies] = useState<InventoryBrowserCompany[]>([]);
const [children, setChildren] = useState<InventoryBrowserItem[]>([]); const [children, setChildren] = useState<InventoryBrowserItem[]>([]);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]); const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
@@ -88,66 +95,53 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true); setError(''); setDepartments([]); setCompanies([]); setChildren([]); setLineage([]); setHasMore(false);
setError('');
setAreas([]);
setChildren([]);
setLineage([]);
setHasMore(false);
const run = async () => { const run = async () => {
if (!parentId) { if (!parentId) {
const response = await listInventoryAreas({ const [loadedDepartments, loadedCompanies] = await Promise.all([
search: filters.search, listInventoryDepartments({ search: filters.search, operatorCompanyId: filters.operatorCompanyId }),
operationalAreaId: filters.operationalAreaId, listInventoryCompanies({ search: filters.search }),
operatorCompanyId: filters.operatorCompanyId, ]);
}); if (active) { setDepartments(loadedDepartments.data); setCompanies(loadedCompanies.data); }
if (active) setAreas(response.data);
return; return;
} }
const [loadedLineage, response] = await Promise.all([ const [loadedLineage, response] = await Promise.all([
getAssetLineage(parentId), getAssetLineage(parentId),
listInventoryChildren(parentId, { search: filters.search }), listInventoryChildren(parentId, { search: filters.search }),
]); ]);
if (!active) return; if (!active) return;
setLineage(loadedLineage.filter((item) => ['area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase()))); setLineage(loadedLineage.filter((item) => ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data); setChildren(response.data); setHasMore(response.meta.hasMore);
setHasMore(response.meta.hasMore);
}; };
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
run()
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
return () => { active = false; }; return () => { active = false; };
}, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]); }, [parentId, filters.search, filters.operatorCompanyId]);
if (loading) return <LoadingBlock label="Cargando inventarios…" />; if (loading) return <LoadingBlock label="Cargando Inventarios…" />;
if (!parentId) { if (!parentId) {
const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0);
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> <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>
<span className="eyebrow">ESTRUCTURA TERRITORIAL</span> <div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
<h2>Áreas</h2>
<p>Las Áreas y Yacimientos son contexto de navegación. El Inventario real comienza en las Instalaciones/Subinstalaciones efectivamente registradas.</p>
</div>
<div className="asset-browser-current-actions">
<span className="count-pill">{realTotal} Inventario real</span>
<span className="count-pill">{areas.length} Áreas</span>
</div>
</div> </div>
{areas.length === 0 {departments.length === 0
? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá el contexto seleccionado." /> ? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{areas.map((area) => <AreaCard key={area.id} area={area} href={navigationHref(searchParams, area.id)} />)}</div>} : <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-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Área</strong><small>Ancla 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>Yacimiento</strong><small>Contexto dentro del Área</small></div><i></i> <div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>Inventario real</small></div><i></i> <div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>Inventario real</small></div> <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> </div>
<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>
{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>}
</section>
</div>; </div>;
} }
@@ -156,10 +150,7 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
<Link to="/inventarios">Inventarios</Link> <Link to="/inventarios">Inventarios</Link>
{lineage.map((item,index) => { {lineage.map((item,index) => {
const isLast=index===lineage.length-1; const isLast=index===lineage.length-1;
return <span className="asset-browser-crumb-part" key={item.id}> return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}</span>;
<span></span>
{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}
</span>;
})} })}
</nav>; </nav>;
@@ -168,31 +159,17 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>} {hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>}
<div className="asset-browser-current-heading"> <div className="asset-browser-current-heading">
<div> <div><span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span><h2>{current?.name ?? 'Nivel de Inventario'}</h2><p>{current?.code ?? ''}</p></div>
<span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span>
<h2>{current?.name ?? 'Nivel de Inventario'}</h2>
<p>{current?.code ?? ''}</p>
</div>
<div className="asset-browser-current-actions"> <div className="asset-browser-current-actions">
{current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>} {current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha{['instalacion','subinstalacion'].includes(current.type.code.toLowerCase()) ? ' y Hallazgos' : ''}</Link>}
{canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>} {canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>}
</div> </div>
</div> </div>
<section className="asset-browser-group"> <section className="asset-browser-group">
<div className="asset-browser-group-heading"> <div className="asset-browser-group-heading"><div><h3>{nextLevelLabel(current?.type.code)}</h3><p>Jerarquía: Departamento Área Yacimiento Instalación Subinstalación.</p></div><span>{children.length}</span></div>
<div><h3>{nextLevelLabel(current?.type.code)}</h3><p>La jerarquía permitida es Área Yacimiento Instalación Subinstalación.</p></div>
<span>{children.length}</span>
</div>
{children.length === 0 {children.length === 0
? <EmptyState ? <EmptyState title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'} text={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Abrí la ficha para ver su clasificación y Hallazgos asociados.' : 'Agregá el primer registro de este nivel o revisá la búsqueda actual.'} />
title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'}
text={current?.type.code.toLowerCase() === 'yacimiento'
? 'Todavía no hay Instalaciones reales registradas en este Yacimiento.'
: current?.type.code.toLowerCase() === 'instalacion'
? 'Todavía no hay Subinstalaciones registradas en esta Instalación.'
: 'No hay registros que coincidan con la búsqueda actual.'}
/>
: <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>} : <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
</section> </section>
</div>; </div>;
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { SearchableSelect } from '../../components/SearchableSelect';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInventoryTechnicalValues, replaceInventoryTechnicalValues } from '../../lib/inventoryStructureApi';
import type { InventoryFamilyAttribute, InventoryTechnicalValues } from '../../lib/inventoryStructureApi';
function localDateTime(value:unknown){
if(typeof value!=='string'||!value)return '';
const date=new Date(value); if(Number.isNaN(date.getTime()))return value.slice(0,16);
const local=new Date(date.getTime()-date.getTimezoneOffset()*60000); return local.toISOString().slice(0,16);
}
function displayValue(definition:InventoryFamilyAttribute,value:unknown){
if(value===undefined||value===null||value==='')return 'Sin completar';
if(definition.dataType==='BOOLEAN')return value?'Sí':'No';
if(definition.dataType==='DATETIME'&&typeof value==='string')return new Date(value).toLocaleString('es-AR');
return `${String(value)}${definition.unit?` ${definition.unit}`:''}`;
}
export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdit:boolean}){
const [data,setData]=useState<InventoryTechnicalValues|null>(null);
const [values,setValues]=useState<Record<string,unknown>>({});
const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false);
const [unsupported,setUnsupported]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
useEffect(()=>{
let active=true; setLoading(true); setUnsupported(false); setError('');
getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{
const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??''];
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
return()=>{active=false};
},[assetId]);
if(loading)return <div className="form-section"><LoadingBlock label="Cargando datos técnicos…" /></div>;
if(unsupported)return null;
if(error&&!data)return <div className="form-section"><Alert>{error}</Alert></div>;
if(!data)return null;
const activeDefinitions=data.definitions.filter((item)=>item.isActive);
const save=async()=>{
setSaving(true);setError('');setSuccess('');
try{
const normalized:Record<string,unknown>={};
for(const definition of activeDefinitions){const raw=values[definition.id];if(raw===''||raw===null||raw===undefined)continue;
if(definition.dataType==='NUMBER')normalized[definition.id]=Number(raw);
else if(definition.dataType==='BOOLEAN')normalized[definition.id]=Boolean(raw);
else if(definition.dataType==='DATETIME')normalized[definition.id]=new Date(String(raw)).toISOString();
else normalized[definition.id]=raw;
}
const saved=await replaceInventoryTechnicalValues(assetId,normalized);setData(saved);setSuccess('Datos técnicos guardados.');
}catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)}
};
return <div className="form-section">
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.</p></div>
{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
{activeDefinitions.length===0?<div className="inline-empty">Esta clasificación todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
const value=values[definition.id];const label=<span>{definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?<em>obligatorio</em>:<em>opcional</em>}</span>;
if(definition.dataType==='BOOLEAN')return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.checked}))}/><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>;
if(definition.dataType==='SELECT')return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value??'')} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option)=><option key={option} value={option}>{option}</option>)}</SearchableSelect></label>;
const inputType=definition.dataType==='NUMBER'?'number':definition.dataType==='DATE'?'date':definition.dataType==='DATETIME'?'datetime-local':'text';
return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value??'')} step={definition.dataType==='NUMBER'?'any':undefined} required={definition.isRequired} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))}/></label>;
})}</div>:<div className="attribute-list">{activeDefinitions.map((definition)=><div className="attribute-card" key={definition.id}><span className="asset-symbol"><Icon name="layers" size={15}/></span><span><strong>{definition.name}</strong><small>{definition.code}</small></span><span>{displayValue(definition,data.values[definition.id])}</span></div>)}</div>}
{canEdit&&activeDefinitions.length>0&&<div className="form-actions"><button type="button" className="button primary" disabled={saving} onClick={()=>void save()}><Icon name="check" />{saving?'Guardando…':'Guardar datos técnicos'}</button></div>}
</div>;
}
@@ -1,5 +1,6 @@
import { SearchableSelect } from '../../components/SearchableSelect'; import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
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 { getFindingCatalogAdmin } from '../../lib/api'; import { getFindingCatalogAdmin } from '../../lib/api';
@@ -11,122 +12,80 @@ import {
import type { InventoryFamily } from '../../lib/inventoryStructureApi'; import type { InventoryFamily } from '../../lib/inventoryStructureApi';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] }; const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type ViewMode = 'ASSOCIATED' | 'ALL';
function familyContext(family: InventoryFamily) {
if (family.level === 'INSTALLATION') return `Instalación · ${family.name}`;
const parents = family.parentFamilies.map((parent) => parent.name).join(', ');
return `Subinstalación · ${family.name}${parents ? ` · compatible con ${parents}` : ''}`;
}
export function FindingCatalogTypeApplicabilityPanel() { export function FindingCatalogTypeApplicabilityPanel() {
const [searchParams] = useSearchParams();
const requestedFamilyId = searchParams.get('familyId') ?? '';
const [families, setFamilies] = useState<InventoryFamily[]>([]); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG); const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState(''); const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set()); const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario'); const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('ASSOCIATED');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
const selectedFamily = families.find((family) => family.id === familyId) ?? null; const selectedFamily = families.find((family) => family.id === familyId) ?? null;
const savedIds = useMemo(() => new Set(selectedFamily?.findingItemIds ?? []), [selectedFamily]);
useEffect(() => { useEffect(() => {
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()]) Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedFamilies, loadedCatalog]) => { .then(([loadedFamilies, loadedCatalog]) => {
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false); const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setFamilies(activeFamilies); setFamilies(activeFamilies); setCatalog(loadedCatalog);
setCatalog(loadedCatalog); setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) ? requestedFamilyId : activeFamilies[0]?.id ?? '');
setFamilyId(activeFamilies[0]?.id ?? '');
}) })
.catch((requestError) => setError(errorMessage(requestError))) .catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
.finally(() => setLoading(false)); }, [requestedFamilyId]);
}, []);
useEffect(() => { useEffect(() => {
const selected = families.find((family) => family.id === familyId); const selected = families.find((family) => family.id === familyId);
setEnabled(new Set(selected?.findingItemIds ?? [])); setEnabled(new Set(selected?.findingItemIds ?? [])); setViewMode('ASSOCIATED'); setSearch(''); setSuccess(''); setError('');
setSuccess('');
setError('');
}, [familyId, families]); }, [familyId, families]);
const activeCategoryIds = useMemo(() => new Set( const activeCategoryIds = useMemo(() => new Set(catalog.categories.filter((category) => category.isActive).map((category) => category.id)), [catalog.categories]);
catalog.categories.filter((category) => category.isActive).map((category) => category.id), const categoryName = useMemo(() => new Map(catalog.categories.map((category) => [category.id, category.name])), [catalog.categories]);
), [catalog.categories]); const available = useMemo(() => {
const categoryName = useMemo(() => new Map(
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase('es-AR'); const needle = search.trim().toLocaleLowerCase('es-AR');
return catalog.items.filter((item) => return catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)
item.isActive && (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? ''].some((value) => value.toLocaleLowerCase('es-AR').includes(needle))));
&& activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
);
}, [catalog.items, search, activeCategoryIds, categoryName]); }, [catalog.items, search, activeCategoryIds, categoryName]);
const visible = useMemo(() => viewMode === 'ASSOCIATED' ? available.filter((item) => savedIds.has(item.id)) : available, [available, savedIds, viewMode]);
const toggle = (id: string) => setEnabled((current) => { const toggle = (id: string) => setEnabled((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const next = new Set(current); const selectVisible = () => setEnabled((current) => { const next = new Set(current); visible.forEach((item) => next.add(item.id)); return next; });
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const save = async () => { const save = async () => {
if (!selectedFamily) return; if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
try { try {
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { itemIds: [...enabled], reason });
itemIds: [...enabled], const itemIds = saved.items.map((item) => item.id); setEnabled(new Set(itemIds));
reason, setFamilies((current) => current.map((family) => family.id === selectedFamily.id ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length } : family));
}); setViewMode('ASSOCIATED'); setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
const itemIds = saved.items.map((item) => item.id); } catch (requestError) { setError(errorMessage(requestError)); }
setEnabled(new Set(itemIds)); finally { setSaving(false); }
setFamilies((current) => current.map((family) => family.id === selectedFamily.id
? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}; };
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>; if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>; if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel"> return <section className="panel finding-applicability-panel">
<div className="panel-heading"> <div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR CLASIFICACIÓN</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir una clasificación se muestran sólo sus Hallazgos asociados. Todos para vincular se usa exclusivamente para modificar la relación.</p></div><span className="count-pill">{savedIds.size} vinculados</span></div>
<div>
<span className="eyebrow">APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN</span>
<h2>Qué Hallazgos verá el inspector</h2>
<p className="section-copy">Los Hallazgos se vinculan a la clasificación concreta del elemento, no a una función. La opción OTROS permanece siempre disponible en la APK.</p>
</div>
<span className="count-pill">{enabled.size} vinculados</span>
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar"> <div className="form-grid finding-applicability-toolbar"><label className="field"><span>Clasificación de Inventario</span><SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>{families.map((family) => <option value={family.id} key={family.id}>{familyContext(family)}</option>)}</SearchableSelect></label><label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label></div>
<label className="field"> {selectedFamily && <div className="temporal-notice"><Icon name="layers" /><p><strong>{selectedFamily.name}:</strong> {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p></div>}
<span>Clasificación de Inventario</span> <div className="quick-view-row" style={{ marginBottom: 12 }}><button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button><button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button></div>
<SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}> {viewMode === 'ASSOCIATED' && visible.length === 0 ? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div> : <>{viewMode === 'ALL' && <div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>}<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></div>)}</div></>}
{families.map((family) => <option value={family.id} key={family.id}> {viewMode === 'ALL' && <><label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div></>}
{family.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'} · {family.parentFamilyName ? `${family.parentFamilyName}` : ''}{family.name}
</option>)}
</SearchableSelect>
</label>
<label className="field"><span>Buscar Hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selectedFamily && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Actualmente tiene {selectedFamily.findingCount ?? enabled.size} Hallazgo{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'} asociado{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'}.</p>
</div>}
<div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={() => setEnabled(new Set(visible.map((item) => item.id)))}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
</div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>)}</div>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: Hallazgos aplicables a esta Subinstalación según criterio técnico…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar Hallazgos vinculados'}</button></div>
</section>; </section>;
} }
-1
View File
@@ -16,7 +16,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[] = [
+39 -1
View File
@@ -6,6 +6,28 @@ import type {
PageMeta, PageMeta,
} from './api'; } from './api';
export interface InventoryBrowserDepartment {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
areaCount: number;
childrenCount: number;
}
export interface InventoryBrowserCompany {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
areaCount: number;
}
export interface InventoryBrowserArea { export interface InventoryBrowserArea {
id: string; id: string;
code: string; code: string;
@@ -31,6 +53,7 @@ export interface InventoryBrowserItem {
isInventoryInstance: boolean; isInventoryInstance: boolean;
inventoryFamily: { id: string; code: string; name: string; level: string } | null; inventoryFamily: { id: string; code: string; name: string; level: string } | null;
childrenCount: number; childrenCount: number;
findingCount: number;
hasGeometry: boolean; hasGeometry: boolean;
updatedAt: string; updatedAt: string;
} }
@@ -85,12 +108,27 @@ function queryString(params: InventoryQuery): string {
return query.size ? `?${query}` : ''; return query.size ? `?${query}` : '';
} }
export function listRealInventory(params: InventoryQuery = {}) { export function listInventory(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>( return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>(
`/inventory-browser/items${queryString(params)}`, `/inventory-browser/items${queryString(params)}`,
); );
} }
// Compatibility alias while old call sites are removed.
export const listRealInventory = listInventory;
export function listInventoryDepartments(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserDepartment[]; meta: { count: number } }>(
`/inventory-browser/departments${queryString(params)}`,
);
}
export function listInventoryCompanies(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserCompany[]; meta: { count: number } }>(
`/inventory-browser/companies${queryString(params)}`,
);
}
export function listInventoryAreas(params: InventoryQuery = {}) { export function listInventoryAreas(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>( return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>(
`/inventory-browser/areas${queryString(params)}`, `/inventory-browser/areas${queryString(params)}`,
+1 -2
View File
@@ -70,7 +70,7 @@ export async function searchInventoryMergeCandidates(
source: AssetDetail, source: AssetDetail,
search = '', search = '',
): Promise<AssetListItem[]> { ): Promise<AssetListItem[]> {
if (!source.parent?.id || !source.operationalArea?.id || !source.operatorCompany?.id) return []; if (!source.parent?.id || !source.operationalArea?.id) return [];
const page = await listAssets({ const page = await listAssets({
page: 1, page: 1,
pageSize: 80, pageSize: 80,
@@ -78,7 +78,6 @@ export async function searchInventoryMergeCandidates(
typeId: source.type.id, typeId: source.type.id,
parentId: source.parent.id, parentId: source.parent.id,
operationalAreaId: source.operationalArea.id, operationalAreaId: source.operationalArea.id,
operatorCompanyId: source.operatorCompany.id,
}); });
return page.data.filter((candidate) => return page.data.filter((candidate) =>
candidate.id !== source.id && candidate.informationStatus !== 'INACTIVE', candidate.id !== source.id && candidate.informationStatus !== 'INACTIVE',
+41 -148
View File
@@ -1,163 +1,56 @@
import { apiRequest } from './api'; import { apiRequest } from './api';
export type InventoryStructureKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; export type InventoryStructureKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export type InventoryFamilyAttributeDataType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'SELECT';
export interface InventoryFamilyParent { id:string; code:string; name:string; }
export interface InventoryFamily { export interface InventoryFamily {
id: string; id:string; code:string; name:string; level:'INSTALLATION'|'SUBINSTALLATION'; legacyTypeCode?:string|null;
code: string; informationLabels:string[]; sourceReference?:string|null; isActive?:boolean; parentFamilyIds:string[];
name: string; parentFamilies:InventoryFamilyParent[]; assetCount?:number; findingCount?:number; findingItemIds?:string[]; technicalAttributeCount?:number;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode?: string | null;
informationLabels: string[];
sourceReference?: string | null;
isActive?: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount?: number;
findingCount?: number;
findingItemIds?: string[];
} }
export interface InventoryFamilyAttribute {
export interface InventoryStructureLevel { id:string; inventoryFamilyId:string; code:string; name:string; dataType:InventoryFamilyAttributeDataType;
kind: InventoryStructureKind; isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
label: string;
type: { id: string; code: string; name: string };
parentKind: InventoryStructureKind | null;
requiresFamily: boolean;
} }
export interface InventoryFamilyAttributes { family:InventoryFamily; items:InventoryFamilyAttribute[]; count:number; }
export interface InventoryStructureOptions { export interface InventoryTechnicalValues {
independentMasters: InventoryStructureLevel[]; assetId:string;
levels: InventoryStructureLevel[]; family:{id:string;code:string;name:string;level:string};
installationFamilies: InventoryFamily[]; definitions:InventoryFamilyAttribute[];
subinstallationFamilies: InventoryFamily[]; values:Record<string,unknown>;
} }
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
export interface InventoryStructureParent { export interface InventoryStructureParent {
id: string; id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
code: string; inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
name: string; parent:null|{id:string;code:string;name:string};
commonName: string | null;
type: { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
};
parent: null | { id: string; code: string; name: string };
} }
export interface InventoryFamilyFinding { export interface InventoryFamilyFinding {
id: string; id:string; code:string; sourceNumber:number; title:string; legalBasis:string|null; glossary:string|null;
code: string; suggestedSeverity:number|null; categoryId:string; categoryCode:string; categoryName:string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
suggestedSeverity: number | null;
categoryId: string;
categoryCode: string;
categoryName: string;
} }
export interface InventoryFamilyFindings { family:InventoryFamily; items:InventoryFamilyFinding[]; count:number; }
export interface InventoryFamilyFindings {
family: {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
items: InventoryFamilyFinding[];
count: number;
}
export interface CreatedInventoryStructure { export interface CreatedInventoryStructure {
id: string; id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
code: string; type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
name: string; inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
commonName: string | null;
description: string | null;
informationStatus: string;
operationalStatus: string;
type: { id: string; code: string; name: string };
parent: null | { id: string; code: string; name: string };
operationalArea?: null | { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
} }
export function getInventoryStructureOptions() { export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
return apiRequest<InventoryStructureOptions>('/inventory-structure'); export async function listInventoryStructureParents(kind:InventoryStructureKind,search=''){
} const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') {
const query = new URLSearchParams();
if (search.trim()) query.set('search', search.trim());
const suffix = query.size ? `?${query}` : '';
return (await apiRequest<{ data: InventoryStructureParent[] }>(
`/inventory-structure/parents/${kind}${suffix}`,
)).data;
}
export function getInventoryFamilyFindings(familyId: string) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
}
export async function listInventoryFamiliesAdmin() {
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
}
export function createInventoryFamily(input: {
level: 'INSTALLATION' | 'SUBINSTALLATION';
name: string;
parentFamilyId?: string | null;
informationLabels?: string[];
}) {
return apiRequest<InventoryFamily>('/inventory-families', {
method: 'POST', body: JSON.stringify(input),
});
}
export function updateInventoryFamily(familyId: string, input: {
name?: string;
parentFamilyId?: string | null;
informationLabels?: string[];
isActive?: boolean;
}) {
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
method: 'PATCH', body: JSON.stringify(input),
});
}
export function replaceInventoryFamilyFindings(familyId: string, input: {
itemIds: string[];
reason: string;
}) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`, {
method: 'PUT', body: JSON.stringify(input),
});
}
export function createInventoryStructure(input: {
kind: InventoryStructureKind;
code?: string | null;
name: string;
commonName?: string | null;
parentId?: string | null;
familyId?: string | null;
description?: string | null;
}) {
return apiRequest<CreatedInventoryStructure>('/inventory-structure', {
method: 'POST',
body: JSON.stringify(input),
});
} }
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
export function replaceInventoryTechnicalValues(assetId:string,values:Record<string,unknown>){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`,{method:'PUT',body:JSON.stringify({values})});}
export async function listInventoryFamiliesAdmin(){return (await apiRequest<{data:InventoryFamily[]}>('/inventory-families/admin')).data;}
export function createInventoryFamily(input:{level:'INSTALLATION'|'SUBINSTALLATION';name:string;parentFamilyIds?:string[];informationLabels?:string[]}){return apiRequest<InventoryFamily>('/inventory-families',{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamily(familyId:string,input:{name?:string;parentFamilyIds?:string[];informationLabels?:string[];isActive?:boolean}){return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
+25 -11
View File
@@ -144,7 +144,6 @@ export function AssetEditorPage() {
const parentType = types.find((type) => type.id === parent.type.id); const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id); if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id); else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
if (parent.operatorCompany) setOperatorCompanyId(parent.operatorCompany.id);
} }
}).catch((requestError) => setError(errorMessage(requestError))); }).catch((requestError) => setError(errorMessage(requestError)));
}, [editing, contextParentId, types]); }, [editing, contextParentId, types]);
@@ -158,21 +157,36 @@ export function AssetEditorPage() {
useEffect(() => { useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) { if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]); setOperationalAreas([]);
if (!parentId) { setOperationalAreaId(''); setOperatorCompanyId(''); } if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
return; return;
} }
listOperationalAreas(parentId).then((loadedAreas) => { listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas); setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) { setOperationalAreaId(''); setOperatorCompanyId(''); } if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
}).catch((requestError) => setError(errorMessage(requestError))); }).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId]); }, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
useEffect(() => { useEffect(() => {
if (!canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') { setOperationalCompanies([]); return; } if (editing || !canReadRelations || !operationalAreaId || selectedType?.operationalRole !== 'GENERIC') {
listCompaniesForArea(operationalAreaId).then(setOperationalCompanies).catch((requestError) => setError(errorMessage(requestError))); setOperationalCompanies([]);
}, [canReadRelations, operationalAreaId, selectedType?.operationalRole]); return;
}
listCompaniesForArea(operationalAreaId).then((loadedCompanies) => {
setOperationalCompanies(loadedCompanies);
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
? current
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
}).catch((requestError) => {
setOperationalCompanies([]);
setOperatorCompanyId('');
setError(errorMessage(requestError));
});
}, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); }; const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value })); const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const save = async (event: FormEvent) => { const save = async (event: FormEvent) => {
@@ -182,7 +196,7 @@ export function AssetEditorPage() {
const attributes = normalizeAttributeValues(definitions, attributeValues); const attributes = normalizeAttributeValues(definitions, attributeValues);
let saved: AssetDetail; let saved: AssetDetail;
if (editing && id) { if (editing && id) {
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, operatorCompanyId: canDirectContextEdit ? operatorCompanyId || null : undefined, description: description.trim() || null, attributes }); if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, description: description.trim() || null, attributes });
else if (asset) saved = asset; else return; else if (asset) saved = asset; else return;
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status); if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus); if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
@@ -229,9 +243,9 @@ export function AssetEditorPage() {
<form className="panel form-panel" onSubmit={save}> <form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div> <div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. El contexto ÁreaOperadora se completa a partir de esa ubicación cuando es posible. Los registros ya consolidados cambian de contexto desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div> <div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área. Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>Área y operadora</h2><p className="section-copy">Sólo necesitás revisar estos campos. La jerarquía física sigue siendo independiente.</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect></label><label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !canDirectContextEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label></div>{operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto confirmado.</strong> El registro pertenece a esta Área y tiene una única Operadora activa.</p></div>}</div>} {selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2><p className="section-copy">{editing ? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.' : 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); if (!editing) setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect>{editing && <small>La Operadora se resuelve por la relación ÁreaEmpresa vigente.</small>}</label>{!editing && <label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>}</div>{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p></div>}{editing && asset?.operatorCompany && <div className="temporal-notice"><Icon name="layers" /><p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p></div>}</div>}
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div> <div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>} {canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
+156 -100
View File
@@ -7,6 +7,7 @@ import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { import {
createAssetAttribute, createAssetAttribute,
getFindingCatalogAdmin,
listAssetTypes, listAssetTypes,
updateAssetAttribute, updateAssetAttribute,
} from '../lib/api'; } from '../lib/api';
@@ -14,13 +15,22 @@ import type {
AssetAttributeDataType, AssetAttributeDataType,
AssetAttributeDefinition, AssetAttributeDefinition,
AssetType, AssetType,
FindingAdminCatalog,
} from '../lib/api'; } from '../lib/api';
import { import {
createInventoryFamily, createInventoryFamily,
createInventoryFamilyAttribute,
getInventoryFamilyAttributes,
listInventoryFamiliesAdmin, listInventoryFamiliesAdmin,
replaceInventoryFamilyFindings,
updateInventoryFamily, updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi'; } from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' }, { value: 'TEXT', label: 'Texto' },
@@ -30,16 +40,20 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
{ value: 'DATETIME', label: 'Fecha y hora' }, { value: 'DATETIME', label: 'Fecha y hora' },
{ value: 'SELECT', label: 'Lista de opciones' }, { value: 'SELECT', label: 'Lista de opciones' },
]; ];
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type CanonicalKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyEditor = InventoryFamily | 'new' | null; type FamilyEditor = InventoryFamily | 'new' | null;
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
type TechnicalEditor = InventoryFamilyAttribute | 'new' | null;
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [ const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' }, { kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
{ kind: 'AREA', label: 'Área', description: 'Raíz territorial de la estructura física.' }, { kind: 'DEPARTAMENTO', label: 'Departamento', description: 'Raíz territorial de la estructura física.' },
{ kind: 'AREA', label: 'Área', description: 'Pertenece obligatoriamente a un Departamento.' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' }, { kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia física dentro de un Yacimiento y con clasificación técnica.' }, { kind: 'INSTALACION', label: 'Instalación', description: 'Instancia dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia física dentro de una Instalación y con clasificación técnica.' }, { kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia dentro de una Instalación y con clasificación técnica.' },
]; ];
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null { function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
@@ -48,11 +62,9 @@ function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | nul
const code = kind.toLowerCase(); const code = kind.toLowerCase();
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null; return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
} }
function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) {
function attributeTypeLabel(value: AssetAttributeDataType) {
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value; return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
} }
function attributeCodeFromName(value: string) { function attributeCodeFromName(value: string) {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim() return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80); .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
@@ -61,9 +73,12 @@ function attributeCodeFromName(value: string) {
export function AssetTypesPage() { export function AssetTypesPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage'); const canManage = hasPermission('asset_types.manage');
const canManageFindings = hasPermission('finding_catalog.manage');
const [types, setTypes] = useState<AssetType[]>([]); const [types, setTypes] = useState<AssetType[]>([]);
const [families, setFamilies] = useState<InventoryFamily[]>([]); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION'); const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION');
const [selectedInstallationFamilyId, setSelectedInstallationFamilyId] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -72,8 +87,22 @@ export function AssetTypesPage() {
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null); const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION'); const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
const [familyName, setFamilyName] = useState(''); const [familyName, setFamilyName] = useState('');
const [familyParentId, setFamilyParentId] = useState(''); const [familyParentIds, setFamilyParentIds] = useState<Set<string>>(new Set());
const [familyActive, setFamilyActive] = useState(true); const [familyActive, setFamilyActive] = useState(true);
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
const [technicalAttributes, setTechnicalAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [technicalLoading, setTechnicalLoading] = useState(false);
const [technicalEditor, setTechnicalEditor] = useState<TechnicalEditor>(null);
const [technicalCode, setTechnicalCode] = useState('');
const [technicalName, setTechnicalName] = useState('');
const [technicalType, setTechnicalType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [technicalRequired, setTechnicalRequired] = useState(false);
const [technicalActive, setTechnicalActive] = useState(true);
const [technicalUnit, setTechnicalUnit] = useState('');
const [technicalOptions, setTechnicalOptions] = useState('');
const [technicalOrder, setTechnicalOrder] = useState(0);
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null); const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
const [attributeCode, setAttributeCode] = useState(''); const [attributeCode, setAttributeCode] = useState('');
@@ -86,14 +115,11 @@ export function AssetTypesPage() {
const [attributeOrder, setAttributeOrder] = useState(0); const [attributeOrder, setAttributeOrder] = useState(0);
const load = async () => { const load = async () => {
const [loadedTypes, loadedFamilies] = await Promise.all([ const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([
listAssetTypes(), listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(),
listInventoryFamiliesAdmin(),
]); ]);
setTypes(loadedTypes); setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog);
setFamilies(loadedFamilies);
}; };
useEffect(() => { useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []); }, []);
@@ -101,37 +127,102 @@ export function AssetTypesPage() {
const selectedType = canonicalType(types, selectedKind); const selectedType = canonicalType(types, selectedKind);
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]); const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]); const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)),
), [subinstallationFamilies, selectedInstallationFamilyId]);
const activeFindingCategoryIds = useMemo(() => new Set(
findingCatalog.categories.filter((category) => category.isActive).map((category) => category.id),
), [findingCatalog.categories]);
const findingCategoryNames = useMemo(() => new Map(
findingCatalog.categories.map((category) => [category.id, category.name]),
), [findingCatalog.categories]);
const visibleFamilyFindings = useMemo(() => {
const needle = familyFindingSearch.trim().toLocaleLowerCase('es-AR');
return findingCatalog.items.filter((item) => {
if (!item.isActive || !activeFindingCategoryIds.has(item.categoryId)) return false;
if (familyFindingMode === 'ASSOCIATED' && !familyFindingIds.has(item.id)) return false;
if (!needle) return true;
return [item.title, item.code, findingCategoryNames.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle));
});
}, [findingCatalog.items, activeFindingCategoryIds, familyFindingMode, familyFindingIds, familyFindingSearch, findingCategoryNames]);
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => { const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); setFamilyParentId(''); setFamilyActive(true); setFamilyEditor('new'); setFamilyLevel(level); setFamilyName('');
setError(''); setSuccess(''); setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : []));
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess('');
}; };
const openFamily = (family: InventoryFamily) => { const openFamily = async (family: InventoryFamily) => {
if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name); setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false); setFamilyParentIds(new Set(family.parentFamilyIds)); setFamilyActive(family.isActive !== false);
setError(''); setSuccess(''); setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
setTechnicalEditor(null); setTechnicalLoading(true); setError(''); setSuccess('');
try { setTechnicalAttributes((await getInventoryFamilyAttributes(family.id)).items); }
catch (requestError) { setError(errorMessage(requestError)); setTechnicalAttributes([]); }
finally { setTechnicalLoading(false); }
}; };
const toggleParentCompatibility = (id: string) => setFamilyParentIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const saveFamily = async (event: FormEvent) => { const saveFamily = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess(''); event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try { try {
if (familyEditor === 'new') { if (familyEditor === 'new') {
await createInventoryFamily({ const created = await createInventoryFamily({
level: familyLevel, level: familyLevel, name: familyName.trim(),
name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
}); });
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`); setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
} else if (familyEditor) { await load(); await openFamily(created); return;
}
if (familyEditor) {
await updateInventoryFamily(familyEditor.id, { await updateInventoryFamily(familyEditor.id, {
name: familyName.trim(), name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
isActive: familyActive, isActive: familyActive,
}); });
setSuccess('Clasificación actualizada.'); if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, {
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
});
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
} }
await load(); } catch (requestError) { setError(errorMessage(requestError)); }
setFamilyEditor(null); finally { setSaving(false); }
};
const openTechnicalAttribute = (attribute: InventoryFamilyAttribute | 'new') => {
setTechnicalEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') {
setTechnicalCode(''); setTechnicalName(''); setTechnicalType('TEXT'); setTechnicalRequired(false);
setTechnicalActive(true); setTechnicalUnit(''); setTechnicalOptions(''); setTechnicalOrder(technicalAttributes.length);
} else {
setTechnicalCode(attribute.code); setTechnicalName(attribute.name); setTechnicalType(attribute.dataType);
setTechnicalRequired(attribute.isRequired); setTechnicalActive(attribute.isActive); setTechnicalUnit(attribute.unit ?? '');
setTechnicalOptions(attribute.options?.join('\n') ?? ''); setTechnicalOrder(attribute.sortOrder);
}
};
const saveTechnicalAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!familyEditor || familyEditor === 'new' || !technicalEditor) return;
setSaving(true); setError(''); setSuccess('');
const options = technicalOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try {
if (technicalEditor === 'new') await createInventoryFamilyAttribute(familyEditor.id, {
code: technicalCode, name: technicalName.trim(), dataType: technicalType, isRequired: technicalRequired,
unit: technicalUnit || null, ...(technicalType === 'SELECT' ? { options } : {}), sortOrder: technicalOrder,
});
else await updateInventoryFamilyAttribute(familyEditor.id,technicalEditor.id,{
name:technicalName.trim(),dataType:technicalType,isRequired:technicalRequired,isActive:technicalActive,
unit:technicalUnit || null,options:technicalType==='SELECT' ? options : null,sortOrder:technicalOrder,
});
setTechnicalAttributes((await getInventoryFamilyAttributes(familyEditor.id)).items);
setTechnicalEditor(null); setSuccess('Campo técnico actualizado.'); await load();
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
@@ -140,98 +231,63 @@ export function AssetTypesPage() {
setAttributeEditor(attribute); setError(''); setSuccess(''); setAttributeEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') { if (attribute === 'new') {
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false); setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeOrder(selectedType?.attributes.length ?? 0);
setAttributeOrder(selectedType?.attributes.length ?? 0);
} else { } else {
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType); setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
setAttributeOrder(attribute.sortOrder);
} }
}; };
const saveAttribute = async (event: FormEvent) => { const saveAttribute = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault(); if (!selectedType || !attributeEditor) return;
if (!selectedType || !attributeEditor) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean); const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try { try {
if (attributeEditor === 'new') { if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, {
await createAssetAttribute(selectedType.id, { code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired,
code: attributeCode, unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder,
name: attributeName, });
dataType: attributeType, else await updateAssetAttribute(selectedType.id, attributeEditor.id, {
isRequired: attributeRequired, name: attributeName,dataType: attributeType,isRequired: attributeRequired,isActive: attributeActive,
unit: attributeUnit || null, unit: attributeUnit || null,options: attributeType === 'SELECT' ? options : null,sortOrder: attributeOrder,
...(attributeType === 'SELECT' ? { options } : {}), });
sortOrder: attributeOrder, await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.');
});
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
} else {
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
name: attributeName,
dataType: attributeType,
isRequired: attributeRequired,
isActive: attributeActive,
unit: attributeUnit || null,
options: attributeType === 'SELECT' ? options : null,
sortOrder: attributeOrder,
});
setSuccess('Columna actualizada.');
}
await load();
setAttributeEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />; if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page"> return <section className="inventory-config-page">
<div className="page-heading"> <div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
<div>
<span className="eyebrow">ADMINISTRACIÓN</span>
<h1>Configuración de Inventarios</h1>
<p>Administrá la estructura, los tipos de Instalación/Subinstalación y las columnas que se completan en oficina o desde la APK.</p>
</div>
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}> <div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><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>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>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Empresa no es padre del Área. La Operadora/Concesionaria se vincula al Área con vigencia temporal.</p></div></div>
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Área</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>inventario real</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Puede cambiar la Operadora de un Área sin mover ni reescribir Yacimientos, Instalaciones o Subinstalaciones.</p></div>
</div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}> <div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel"> <article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Precargados desde final_modelov2.xlsx y ampliables por Hidrocarburos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div> <article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.sourceReference?.startsWith('F5:final_modelov2.xlsx') ? 'Precargado desde fuente autorizada' : 'Agregado por Hidrocarburos'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Cada tipo queda asociado a un tipo de Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
</article>
</div> </div>
<article className="panel" style={{ marginTop: 18 }}> <article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
<div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
<div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
<p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
</article>
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo separado, relación clara</h2><p className="section-copy">Los Hallazgos se crean y editan en su catálogo, y allí se vinculan al tipo de Instalación/Subinstalación correspondiente. OTROS permanece siempre disponible.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div> {familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label>
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>}
{familyEditor !== 'new' && <>
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas, sin borrar registros históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo'}</button></div></form></div>} <div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>}
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link>
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
</>}
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva datos históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>} {attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>}
</section>; </section>;
} }
+23 -38
View File
@@ -22,7 +22,7 @@ import type {
AssetType, AssetType,
PageMeta, PageMeta,
} from '../lib/api'; } from '../lib/api';
import { listRealInventory } from '../lib/inventoryBrowserApi'; import { listInventory } from '../lib/inventoryBrowserApi';
import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi'; import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
@@ -60,24 +60,19 @@ export function AssetsPage() {
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus; const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
useEffect(() => { useEffect(() => {
listAssetTypes().then((data) => setTypes(data.filter((type) => ['instalacion','subinstalacion'].includes(type.code.toLowerCase())))).catch(() => undefined); listAssetTypes().then((data) => setTypes(data.filter((type) =>
type.operationalRole === 'COMPANY'
|| ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(type.code.toLowerCase()),
))).catch(() => undefined);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (view !== 'list') return; if (view !== 'list') return;
setLoading(true); setLoading(true); setError('');
setError(''); listInventory({
listRealInventory({ page, pageSize: 25, search, typeId, status,
page,
pageSize: 25,
search,
typeId,
status,
operationalStatus: effectiveOperationalStatus, operationalStatus: effectiveOperationalStatus,
operationalAreaId, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
operatorCompanyId,
needsValidation,
hasGeometry,
}) })
.then((response) => { setAssets(response.data); setMeta(response.meta); }) .then((response) => { setAssets(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError))) .catch((requestError) => setError(errorMessage(requestError)))
@@ -85,36 +80,26 @@ export function AssetsPage() {
}, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]); }, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const filters: InventoryQuery = useMemo(() => ({ const filters: InventoryQuery = useMemo(() => ({
search, search, typeId, status, operationalStatus: effectiveOperationalStatus,
typeId, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
status,
operationalStatus: effectiveOperationalStatus,
operationalAreaId,
operatorCompanyId,
needsValidation,
hasGeometry,
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]); }), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const update = (changes: Record<string, string | null>) => { const update = (changes: Record<string, string | null>) => {
const next = new URLSearchParams(urlParams); const next = new URLSearchParams(urlParams);
Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key)); Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key));
next.delete('page'); next.delete('page'); setUrlParams(next);
setUrlParams(next);
}; };
const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); }; const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); };
const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null }); const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null });
const clearFilters = () => { const clearFilters = () => {
const next = new URLSearchParams(); const next = new URLSearchParams();
if (view === 'list') next.set('view', 'list'); if (view === 'list') next.set('view', 'list');
const parentId=urlParams.get('parentId'); const parentId=urlParams.get('parentId'); if (parentId) next.set('parentId',parentId);
if (parentId) next.set('parentId',parentId); setDraftSearch(''); setUrlParams(next);
setDraftSearch('');
setUrlParams(next);
}; };
const setPage = (value: number) => { const setPage = (value: number) => {
const next = new URLSearchParams(urlParams); const next = new URLSearchParams(urlParams);
value > 1 ? next.set('page', String(value)) : next.delete('page'); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
setUrlParams(next);
}; };
const advancedActive = Boolean(typeId || status || rawOperationalStatus); const advancedActive = Boolean(typeId || status || rawOperationalStatus);
@@ -123,7 +108,7 @@ export function AssetsPage() {
<div> <div>
<span className="eyebrow">INVENTARIOS</span> <span className="eyebrow">INVENTARIOS</span>
<h1>Inventarios</h1> <h1>Inventarios</h1>
<p>Instancias reales registradas en campo, organizadas por Área Yacimiento Instalación Subinstalación.</p> <p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p>
</div> </div>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate> <PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div> </div>
@@ -133,7 +118,7 @@ export function AssetsPage() {
<div className="asset-center-controls"> <div className="asset-center-controls">
<form className="asset-center-search" onSubmit={applySearch}> <form className="asset-center-search" onSubmit={applySearch}>
<Icon name="search" /> <Icon name="search" />
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar Inventario, código o identificación…" /> <input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por nombre, código o identificación…" />
<button className="button primary" type="submit">Buscar</button> <button className="button primary" type="submit">Buscar</button>
</form> </form>
<div className="quick-view-row" aria-label="Vistas rápidas"> <div className="quick-view-row" aria-label="Vistas rápidas">
@@ -141,7 +126,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="">Instalaciones y Subinstalaciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label> <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>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>
@@ -152,16 +137,16 @@ export function AssetsPage() {
{view === 'hierarchy' {view === 'hierarchy'
? <AssetHierarchyView filters={filters} /> ? <AssetHierarchyView filters={filters} />
: loading : loading
? <LoadingBlock label="Cargando Inventario real…" /> ? <LoadingBlock label="Cargando Inventarios…" />
: assets.length === 0 : assets.length === 0
? <EmptyState title="Todavía no hay Inventario real" text="Las Áreas y Yacimientos precargados son contexto. Las Instalaciones/Subinstalaciones aparecerán aquí cuando sean registradas realmente." /> ? <EmptyState title="Todavía no hay registros" text="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} instancia{meta.total === 1 ? '' : 's'} real{meta.total === 1 ? '' : 'es'}</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>Inventario</th><th>Nivel</th><th>Área / Operadora vigente</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>Nivel</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>
<td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : <span className="muted">Sin Área</span>}</td> <td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : asset.operatorCompany ? <span>{asset.operatorCompany.name}</span> : <span className="muted">{asset.parent?.name ?? 'Raíz / maestro independiente'}</span>}</td>
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td> <td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
<td>{formatDate(asset.updatedAt)}</td> <td>{formatDate(asset.updatedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td> <td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></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 -2
View File
@@ -86,7 +86,6 @@ export function FieldDiscoveriesPage() {
pageSize: 12, pageSize: 12,
search: matchSearch.trim(), search: matchSearch.trim(),
operationalAreaId: matchFor.asset.operationalAreaId ?? undefined, operationalAreaId: matchFor.asset.operationalAreaId ?? undefined,
operatorCompanyId: matchFor.asset.operatorCompanyId ?? undefined,
}); });
setCandidates(response.data.filter((candidate) => candidate.id !== matchFor.asset.id && candidate.informationStatus !== 'INACTIVE')); setCandidates(response.data.filter((candidate) => candidate.id !== matchFor.asset.id && candidate.informationStatus !== 'INACTIVE'));
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
@@ -147,7 +146,7 @@ export function FieldDiscoveriesPage() {
</div>} </div>}
{matchFor && <div className="panel field-discovery-match-panel"> {matchFor && <div className="panel field-discovery-match-panel">
<div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Empresa y Área. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div> <div className="panel-heading"><div><span className="eyebrow">CONCILIAR</span><h2>Buscar registro existente</h2><p className="section-copy">Buscá dentro de la misma Área física. El alta de campo se archivará, pero su historial no se elimina.</p></div><button className="button text" onClick={() => setMatchFor(null)}>Cerrar</button></div>
<form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form> <form className="asset-center-search" onSubmit={searchMatches}><Icon name="search" /><input value={matchSearch} onChange={(event) => setMatchSearch(event.target.value)} placeholder="Nombre, sobrenombre o código existente…" /><button className="button primary" disabled={matchLoading}>{matchLoading ? 'Buscando…' : 'Buscar'}</button></form>
{candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>} {candidates.length > 0 && <div className="table-scroll"><table><thead><tr><th>Registro existente</th><th>Tipo</th><th /></tr></thead><tbody>{candidates.map((candidate) => <tr key={candidate.id}><td><strong>{candidate.name}</strong><small className="cell-subtext">{candidate.code}{candidate.commonName ? ` · ${candidate.commonName}` : ''}</small></td><td>{candidate.type.name}</td><td className="action-cell"><button className="button primary compact" onClick={() => chooseMatch(candidate)}>Usar esta coincidencia</button></td></tr>)}</tbody></table></div>}
</div>} </div>}

Some files were not shown because too many files have changed in this diff Show More