Compare commits

...
Author SHA1 Message Date
admin 5986474a35 ci: validate authoritative inventory model 2026-09-11 01:28:46 -03:00
admin f33c2b8df8 fix(api): scope checklist by Yacimiento ancestry 2026-09-11 01:20:31 -03:00
admin 4d7f5f7df4 test: resolve planning Company from Yacimientos 2026-09-11 01:16:39 -03:00
admin 6e0adb7a07 test: align operational context with Yacimiento ownership 2026-09-11 01:16:28 -03:00
admin 90098d1fd5 test: resolve planning operators from authoritative Yacimientos 2026-09-11 01:16:06 -03:00
admin 3c838942c4 test: plan inspections from authoritative Yacimiento context 2026-09-11 01:15:50 -03:00
admin 548d00662d test: align manual inventory contracts with authoritative model 2026-09-11 01:15:35 -03:00
admin 729b90bfbf fix(api): keep Yacimiento relationships out of generic edits 2026-09-11 01:15:15 -03:00
admin 1b5cdbf57b fix(web): make authoritative level selection non-null 2026-09-11 01:08:44 -03:00
admin a98dfb955d fix(api): validate inspection operator against Yacimiento 2026-09-11 01:06:59 -03:00
admin 99f068b416 fix(api): derive planning operators from Yacimientos 2026-09-11 01:06:36 -03:00
admin 718e08be13 feat(web): capture Yacimiento company and concession 2026-09-11 01:06:18 -03:00
admin b88147d376 feat(web): expose Yacimiento company and concession options 2026-09-11 01:05:56 -03:00
admin 439809abc7 feat(api): make Yacimiento own company and concession 2026-09-11 01:05:42 -03:00
admin 98b9af7344 feat(api): require Yacimiento company and concession relationships 2026-09-11 01:05:00 -03:00
admin 18e16c2ec2 feat(web): use authoritative inventory configuration page 2026-09-11 01:02:38 -03:00
admin cc75c76860 feat(web): present authoritative inventory relationships 2026-09-11 01:02:15 -03:00
admin 474e8d4310 test: lock authoritative inventory SQL model 2026-09-11 00:59:56 -03:00
admin 06923d2849 feat(api): make Yacimiento company and concession editable 2026-09-11 00:59:28 -03:00
admin e5c71a0c16 feat(api): accept Yacimiento concession type 2026-09-11 00:59:18 -03:00
admin c1f51eb81f feat(api): expose Yacimiento concession relationship 2026-09-11 00:58:56 -03:00
admin 68d1af63ff feat(db): apply authoritative inventory relationships 2026-09-11 00:58:36 -03:00
admin fe6be39302 fix: compose authoritative inventory payload safely 2026-09-11 00:51:51 -03:00
admin 72a4f70177 feat: add authoritative inventory payload chunk 5 2026-09-11 00:50:59 -03:00
admin a98c9020b0 feat: add authoritative inventory payload chunk 4 2026-09-11 00:50:40 -03:00
admin be543de927 feat: add authoritative inventory payload chunk 3 2026-09-11 00:50:17 -03:00
admin d2e82719f6 feat: add authoritative inventory payload chunk 2 2026-09-11 00:49:58 -03:00
admin fb6b2388c3 feat: add authoritative inventory payload chunk 1 2026-09-11 00:49:37 -03:00
admin 583fd58e1c feat: add authoritative inventory reference data 2026-09-11 00:48:57 -03:00
admin 67928b48f8 chore: revert accidental placeholder 2026-09-11 00:45:08 -03:00
admin 0e959708cf noop 2026-09-11 00:44:56 -03:00
admin 8bee2c852a chore(db): reset total de datos vivos
Deja únicamente el usuario admin y la estructura mínima del producto; elimina todos los datos vivos para la carga definitiva.
2026-09-11 00:29:21 -03:00
admin 0a9cfdffb1 chore(db): add full live data reset 2026-09-11 00:21:41 -03:00
admin dde3f32021 chore(db): limpiar jerarquía operativa para nueva carga
Limpia Departamento, Área, Yacimiento, Instalación y Subinstalación en el entorno vivo, preservando usuarios, Empresas/Operadoras y catálogos estructurales.
2026-09-11 00:09:29 -03:00
admin 7ae79af595 chore(db): scope hierarchy reset to live production 2026-09-11 00:00:23 -03:00
admin f2754d87ed chore(db): reset operational hierarchy data 2026-09-10 23:59:32 -03:00
admin dea9358451 fix(android): Acta → Hallazgo → Inventario en campo
Simplifica el flujo operativo móvil, corrige el alta desde el Yacimiento congelado, permite crear Actas antes de seleccionar Inventario y precarga Marca, Modelo, Capacidad, Número de serie y Función en Instalaciones/Subinstalaciones. Android 0.16.0.
2026-09-10 23:13:06 -03:00
admin 9fffafc447 Merge pull request #42 from enlineawork/fix/inspection-context-preventive-scope
fix: congelar contexto de inspección y corregir preventivos
2026-09-10 21:46:06 -03:00
admin 466fb29089 test(api): align inventory search contract with preventive scope 2026-09-10 21:38:16 -03:00
admin b23d7736c0 test(api): cover immutable context and scoped preventive inventory 2026-09-10 21:34:55 -03:00
admin 5b1593ed78 test(api): reject inspection context changes after creation 2026-09-10 21:34:42 -03:00
admin ff7706497d test(api): align F6.1 with immutable inspection context 2026-09-10 21:34:28 -03:00
admin cb002c60f9 fix(web): freeze inspection context and scope preventive selector 2026-09-10 21:34:09 -03:00
admin 74b91d0c8a feat(web): load preventive inventory from frozen scope 2026-09-10 21:33:01 -03:00
admin 53c8f2b4ab feat(db): enforce immutable inspection context 2026-09-10 21:32:49 -03:00
admin b7baea652b fix(api): freeze inspection context after creation 2026-09-10 21:32:39 -03:00
admin 9e6d9e45cb feat(api): register preventive candidate endpoint 2026-09-10 21:32:29 -03:00
admin 82b587ea6a feat(api): expose scoped preventive candidates 2026-09-10 21:32:18 -03:00
admin 88e7e8de65 feat(api): scope preventive candidates to inspection yacimiento 2026-09-10 21:32:12 -03:00
admin b265769047 Merge pull request #41 from enlineawork/fix/field-briefing-planned-date
fix: corregir fecha del briefing de campo
2026-09-10 21:07:28 -03:00
admin 9a40f5cbd0 test(api): cover pg Date in field briefing 2026-09-10 20:59:53 -03:00
admin ce579d1667 fix(api): normalize field briefing planned date 2026-09-10 20:59:42 -03:00
admin bc6d4604e3 Merge pull request #40 from enlineawork/fix/csrf-layout-document-pdf-projection
fix: CSRF de planificación, shell simple y proyección PDF
2026-09-10 20:31:17 -03:00
admin 5c69892815 test: keep isolated F6.2 contract independent from docs 2026-09-10 20:26:31 -03:00
admin c1eeb5f078 test: protect CSRF fix, simplified shell and PDF projection 2026-09-10 20:20:47 -03:00
admin 324a73d35c docs: define provisional PDF contract for Actas and INF 2026-09-10 20:20:33 -03:00
admin 97f34a471f docs: align document model with multi-Act inspections and provisional PDFs 2026-09-10 20:20:16 -03:00
admin a5d954b4c7 feat: show provisional Informe PDF projection 2026-09-10 20:19:59 -03:00
admin a80ef4477c feat: show provisional Acta PDF projection 2026-09-10 20:19:28 -03:00
admin b21810f166 feat: expose provisional PDF data contract for Actas and Informes 2026-09-10 20:18:44 -03:00
admin 64486f40f6 ux: stop persisting hidden operational filters 2026-09-10 20:16:59 -03:00
admin a407bf46c5 ux: remove duplicated global operational context bar 2026-09-10 20:16:40 -03:00
admin bfb7eed608 fix: use authenticated CSRF-aware request for inspection planning 2026-09-10 20:16:21 -03:00
admin c74fd80620 Merge pull request #39 from enlineawork/feat/structured-inventory-navigation
feat: navegación estructurada de Inventarios
2026-09-10 20:00:28 -03:00
admin 9bd1adbdb5 fix: keep inventory item type presentation generic 2026-09-10 19:54:47 -03:00
admin cc8d650aa4 feat: present inventory levels as dedicated sections 2026-09-10 19:54:19 -03:00
admin 9d0d1f33ce feat: add structured inventory navigation 2026-09-10 19:53:52 -03:00
admin e5f1615665 F6.1 hotfix · Operadora inmediata y cancelación con motivo (#38)
Corrige la UX de planificación para precargar fecha/hora, resolver y autoseleccionar la Operadora vigente del Área, y expone cancelación auditada con motivo obligatorio en DRAFT/PLANNED/IN_PROGRESS, manteniendo el bloqueo por Acta sellada.
2026-09-10 18:26:23 -03:00
admin 7be5d89204 F6.1 hotfix · jerarquía de Inspecciones y scope Yacimiento (#37)
Corrige creación de Inspección, separa Departamento→Área→Yacimiento, elimina el UPDATE oculto de checklist en lectura, integra Preparación para campo como etapa de Inspección, limita operaciones Android al Yacimiento, preserva scope en edición, auto-incluye Inventarios accionables, valida Operadora a plannedStartAt y recompila Android 0.15.2/vc24 con el ícono exacto de presentación.
2026-09-10 16:06:48 -03:00
admin ce2a85a26a F6.1 · reset y precarga territorial de presentación (#36)
Precarga únicamente la fuente aprobada Tablas de yacimiento y areas(1).xlsx con jerarquía Departamento → Área → Yacimiento, concesiones y Operadoras. Corrige además el CHECK legacy F4 para permitir contexto físico de Área sin snapshot obligatorio de Empresa. El reset preserva usuarios/RBAC y catálogos/configuración técnica, y valida cardinalidades exactas antes de completar.
2026-09-09 21:16:29 -03:00
admin 20ffa9e4b3 F6.1 · APK 0.15.1 con launcher Mendoza Gobierno (#35)
Prepara la APK de presentación con launcher propio basado en el escudo institucional de Gobierno de Mendoza, versionName 0.15.1 / versionCode 23, contratos de metadata actualizados y sin cambios funcionales de backend o WEB.
2026-09-09 20:22:53 -03:00
admin 509bc0cde1 F6.1 · cierre de presentación y barrera de release (#34)
Cierra F6.1 como candidata verificable de presentación: metadata WEB alineada, contratos transversales de aceptación, Android CI/RC endurecida con lint/tests/builds/checksum, correcciones de carreras de permisos GPS y cámara opcional, auditoría de dependencias productivas y parches de runtime, más documentación de release y recorrido de demo.
2026-09-09 18:35:28 -03:00
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
145 changed files with 8798 additions and 1862 deletions
+95 -20
View File
@@ -1,35 +1,36 @@
name: Android APK
# F5: genera una APK debug verificable contra la API productiva F5.
name: Android CI / RC
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
on:
push:
branches:
- 'main'
- 'release/f5-android-test'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
- 'release/f6-1*'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches:
- 'main'
paths:
- 'android-app/**'
- 'api-v3/src/auth/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build-debug-apk:
android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 35
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -51,18 +52,92 @@ jobs:
with:
gradle-version: '8.13'
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Validate mobile security and identity contract
run: |
set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Unit tests
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK
- name: Require real unit-test results
run: |
set -Eeuo pipefail
test_dir="android-app/app/build/test-results/testDebugUnitTest"
test -d "$test_dir"
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
test "$total" -gt 0
echo "Android unit tests discovered: $total"
- name: Assemble debug APK
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
- name: Package RC artifact and checksum
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.1"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F5-0.13.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
if-no-files-found: error
retention-days: 30
- 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
+179 -67
View File
@@ -69,7 +69,7 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations on clean PostGIS
- name: Rehearse migrations and real API startup on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
@@ -80,12 +80,9 @@ jobs:
docker compose --env-file .env.example up -d db
# The historical production reset is a one-shot operational migration,
# not a bootstrap migration: it requires production data/configuration
# that cannot exist at its timestamp in a database rebuilt from zero.
# 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.
# Historical production reset is a one-shot migration that expects the
# production admin. Prove the clean chain reaches that exact guard,
# mark only that historical reset as applied, then continue the chain.
bootstrap_log="$(mktemp)"
set +e
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 \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
reset_rows integer;
DECLARE reset_rows integer;
BEGIN
SELECT COUNT(*) INTO reset_rows
FROM typeorm_migrations
WHERE name = 'ResetProductionOperationalData1788652800000';
IF reset_rows <> 0 THEN
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
END IF;
INSERT INTO typeorm_migrations ("timestamp", name)
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
END $$;
@@ -122,17 +116,35 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate
# The completed chain must end in the exact authoritative SQL model:
# 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de
# concesión, 14/109 technical families, 181 Hallazgos and their exact
# 48 + 880 contextual mappings.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
f5_migrations integer;
real_inventory integer;
source_areas integer;
source_yacimientos integer;
domain_assets integer;
audits integer;
applicability integer;
legacy_territory_sources integer;
legacy_presentation_sources integer;
authoritative_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
concession_types integer;
departments integer;
areas integer;
yacimientos integer;
companies integer;
invalid_yacimientos integer;
department_types integer;
area_department_rules integer;
yacimiento_area_rules integer;
installation_yacimiento_rules integer;
subinstallation_installation_rules integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
@@ -141,81 +153,181 @@ jobs:
'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000'
'F5AuthoritativeInventoryCatalog1790087300000',
'F51CleanManualInventory1790087400000'
);
IF f5_migrations <> 5 THEN
RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations;
IF f5_migrations <> 6 THEN
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
END IF;
SELECT COUNT(*) INTO real_inventory
FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE';
IF real_inventory <> 0 THEN
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 314 THEN
RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets;
END IF;
SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'),
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')
INTO source_areas,source_yacimientos
FROM source_documents document
JOIN asset_source_documents link ON link.document_id=document.id
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE document.document_number='DH-F5-TERRITORY';
IF source_areas <> 64 OR source_yacimientos <> 230 THEN
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 928 THEN
RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO legacy_territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF legacy_territory_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources;
END IF;
SELECT COUNT(*) INTO legacy_presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF legacy_presentation_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources;
END IF;
SELECT COUNT(*) INTO authoritative_sources
FROM source_documents
WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911');
IF authoritative_sources <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
WHERE is_active=true AND source_reference LIKE 'modelo_relacional:%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations;
RAISE EXCEPTION 'Authoritative technical families must be 14 installations and 109 subinstallations, got % and %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings;
WHERE lower(category.code)='authmodel' AND item.is_active=true;
IF source_findings <> 181 THEN
RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings;
END IF;
SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true;
IF concession_types <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types;
END IF;
SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'),
COUNT(*) FILTER (WHERE lower(type.code)='area'),
COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'),
COUNT(*) FILTER (WHERE type.operational_role='COMPANY')
INTO departments,areas,yacimientos,companies
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id;
IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN
RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
JOIN assets area ON area.id=yacimiento.parent_id
WHERE lower(type.code)='yacimiento'
AND (
yacimiento.operational_area_id IS DISTINCT FROM area.id
OR yacimiento.operator_company_id IS NULL
OR yacimiento.concession_type_id IS NULL
);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos;
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;
SELECT COUNT(*) INTO yacimiento_area_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)='yacimiento' AND lower(parent.code)='area';
IF yacimiento_area_rules <> 1 THEN
RAISE EXCEPTION 'Expected Yacimiento → Área canonical rule, got %', yacimiento_area_rules;
END IF;
SELECT COUNT(*) INTO installation_yacimiento_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)='instalacion' AND lower(parent.code)='yacimiento';
IF installation_yacimiento_rules <> 1 THEN
RAISE EXCEPTION 'Expected Instalación → Yacimiento canonical rule, got %', installation_yacimiento_rules;
END IF;
SELECT COUNT(*) INTO subinstallation_installation_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)='subinstalacion' AND lower(parent.code)='instalacion';
IF subinstallation_installation_rules <> 1 THEN
RAISE EXCEPTION 'Expected Subinstalación → Instalación canonical rule, got %', subinstallation_installation_rules;
END IF;
END $$;
SQL
# Prove the five F5 migrations are actually reversible on a clean state.
for _ in 1 2 3 4 5; do
docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert
# Destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed
# chain is idempotent and has no pending migration on a second run.
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: authoritative 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
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE f5_migrations integer; instance_column integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name LIKE 'F5%1790087%';
IF f5_migrations <> 0 THEN
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations;
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
if [ "$api_ready" -ne 1 ]; then
echo 'ERROR: production API image did not become healthy.' >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
grep -Fq '"status":"ok"' /tmp/dhv2-health.json || {
echo 'ERROR: /api/v3/health did not report status ok.' >&2
cat /tmp/dhv2-health.json >&2
exit 1
}
- name: Isolated builder test preflight
run: |
set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
@@ -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.
## Estructura
## Componentes
- `api-v3/`: API v3.
- `web-v2/`: aplicación web.
- `scripts/`: utilidades operativas y de despliegue.
- `docs/`: documentación técnica.
- `api-v3/`: API NestJS/TypeORM y contratos de dominio.
- `web-v2/`: aplicación WEB React/Vite.
- `android-app/`: aplicación Android para trabajo de campo.
- `scripts/`: utilidades operativas, preflight y despliegue.
- `docs/`: documentación funcional y técnica.
## Documentación vigente
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
- [Contrato de release Android F6.1](android-app/RELEASE.md)
## Contratos que no deben romperse
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
+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"
minSdk = 26
targetSdk = 36
versionCode = 20
versionName = "0.13.0"
versionCode = 25
versionName = "0.16.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -3,11 +3,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion"
@@ -49,6 +49,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
@@ -96,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList()
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
@@ -112,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
@@ -121,6 +125,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeVisitView() {
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
@@ -132,6 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return
launchBusy {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
@@ -162,10 +168,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return
@@ -173,7 +175,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset.id,
asset?.id,
currentVisit.code,
urgency,
)
@@ -182,32 +184,42 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (selectedFieldAsset?.capture?.readyForFinding == true) {
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return
val currentVisit = visit ?: return
launchBusy {
inventory = repository.fieldInventory(id, search, parentId).data
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
}
}
fun loadFieldTypes(parentId: String? = null) {
val id = visit?.id ?: return
val currentVisit = visit ?: return
launchBusy {
fieldTypes = repository.fieldTypes(id, parentId).data
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: 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?.scopeAsset?.id ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
@@ -236,7 +248,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
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
}
launchBusy {
@@ -254,7 +266,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
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."
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
}
}
@@ -278,7 +290,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
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."
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
@@ -311,7 +323,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else {
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
reloadCurrentInventory(visitId)
val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
@@ -154,6 +154,7 @@ data class VisitDetail(
val code: String,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create(
visitId: String,
assetId: String,
assetId: String? = null,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOf(assetId),
assetIds = listOfNotNull(assetId),
),
)
}
@@ -0,0 +1,103 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
data class MobilePlanningAsset(
val id: String,
val code: String,
val name: String,
)
data class MobilePlanningAssetResponse(
val data: List<MobilePlanningAsset> = emptyList(),
)
data class OpenMobileInspectionRequest(
val operationalAreaId: String,
val operatorCompanyId: String,
)
private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas")
suspend fun areas(
@Header("Authorization") authorization: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("areaId") areaId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open")
suspend fun open(
@Header("Authorization") authorization: String,
@Body request: OpenMobileInspectionRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}")
}
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId)
}
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
api.open(
"Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -86,7 +86,10 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
@@ -119,6 +122,9 @@ private fun F3VisitOverview(
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
@@ -138,10 +144,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
Button(
onClick = { model.startVisit() },
onClick = { model.startVisit(); onActs() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -150,12 +156,12 @@ private fun F3VisitOverview(
}
if (visit.status == "IN_PROGRESS") {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -163,7 +169,7 @@ private fun F3VisitOverview(
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -209,7 +215,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
var parentLabel by rememberSaveable(visit.id) {
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -323,10 +331,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Volver") }
OutlinedButton(onClick = onBack) { Text("Acta") }
Column(horizontalAlignment = Alignment.End) {
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall)
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -410,170 +418,182 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre o código") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Alta en campo", fontWeight = FontWeight.Bold)
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación o subinstalación") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.weight(1f),
) { Text("Buscar") }
OutlinedButton(
onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
}
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
}
if (showCreate) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = "Área de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Área") }
}
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(12.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") }
}
if (model.fieldTypes.isEmpty()) {
Text("Este nivel no admite más hijos estructurales.")
} else {
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo disponible para esta ubicación.")
} else {
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
}
}
if (selectedType?.familyRequired == true) {
Text("Familia técnica", fontWeight = FontWeight.Bold)
Text(
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
if (selectedType?.familyRequired == true) {
Text("Clasificación", fontWeight = FontWeight.Bold)
Text(
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como no catalogado para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") }
}
}
}
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
Text(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(model.inventory, key = { it.id }) { item ->
items(
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
@@ -611,10 +631,10 @@ private fun F3CaptureCard(
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else {
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -631,8 +651,8 @@ private fun F3InventoryCard(
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
val canHaveChild = typeCode == "instalacion"
val childLabel = "+ Agregar subinstalación"
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -646,12 +666,12 @@ private fun F3InventoryCard(
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
}
}
if (canHaveChild) {
@@ -727,16 +747,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -389,13 +389,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -92,7 +92,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> DhApp(model)
else -> MobileHomeScreen(model)
}
}
}
@@ -126,13 +126,14 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
@@ -156,15 +157,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold)
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) {
Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Text("Urgencia", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
@@ -183,20 +184,15 @@ fun MobileActsScreen(
},
style = MaterialTheme.typography.bodySmall,
)
val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button(
onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
Text(
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.createAct(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
@@ -220,7 +216,21 @@ fun MobileActsScreen(
when (selected.status) {
"DRAFT" -> {
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -259,12 +269,7 @@ fun MobileActsScreen(
}
HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button(
onClick = { model.prepareSelectedAct() },
@@ -431,13 +436,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
@@ -0,0 +1,231 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
) {
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Launcher de presentación F6.1 inspirado en el escudo institucional publicado
por Gobierno de Mendoza en mendoza.gov.ar. Se mantiene como vector local para
evitar el ícono genérico del sistema y no depender de assets remotos.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- fondo institucional -->
<path
android:fillColor="#0B5FA5"
android:pathData="M54,4 A50,50 0,1 0,54 104 A50,50 0,1 0,54 4" />
<!-- escudo exterior -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,13 C72,13 84,22 87,38 L83,65 C80,81 68,93 54,99 C40,93 28,81 25,65 L21,38 C24,22 36,13 54,13 Z" />
<!-- campo superior celeste -->
<path
android:fillColor="#22A9E0"
android:pathData="M27,38 C30,27 39,21 54,21 C69,21 78,27 81,38 L78,59 L30,59 Z" />
<!-- campo inferior dorado -->
<path
android:fillColor="#C6A66A"
android:pathData="M30,63 L78,63 L76,68 C73,80 65,89 54,94 C43,89 35,80 32,68 Z" />
<!-- gorro frigio simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M47,31 C52,26 61,25 67,28 C64,31 63,35 64,39 C58,37 52,37 46,40 C44,36 44,33 47,31 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,39 L57,39 L56,48 L52,48 Z" />
<!-- racimo de uva simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M53,69 C56,69 58,71 58,74 C58,77 56,79 53,79 C50,79 48,77 48,74 C48,71 50,69 53,69 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M45,73 C48,73 50,75 50,78 C50,81 48,83 45,83 C42,83 40,81 40,78 C40,75 42,73 45,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M61,73 C64,73 66,75 66,78 C66,81 64,83 61,83 C58,83 56,81 56,78 C56,75 58,73 61,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,80 C56,80 58,82 58,85 C58,88 56,90 53,90 C50,90 48,88 48,85 C48,82 50,80 53,80 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M52,67 C54,63 58,61 62,61 C60,65 57,68 53,70 Z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,20 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(25, BuildConfig.VERSION_CODE)
assertEquals("0.16.0-debug", BuildConfig.VERSION_NAME)
}
@Test
fun fieldBuildTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.20.0-2",
"version": "0.29.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.20.0-2",
"version": "0.29.0-1",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.26.0-1",
"version": "0.29.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -42,5 +42,9 @@
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
}
}
@@ -36,6 +36,20 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[];
}
function toIsoDate(value: unknown): string {
const parsed = value instanceof Date
? value
: value == null
? new Date()
: new Date(String(value));
if (Number.isNaN(parsed.getTime())) {
throw new Error('La fecha planificada de la inspección no es válida');
}
return parsed.toISOString().slice(0, 10);
}
@Injectable()
export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {}
@@ -67,7 +81,9 @@ export class FieldBriefingService {
});
}
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
@@ -26,6 +26,9 @@ import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
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 { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
@@ -39,6 +42,7 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController,
InventoryMergeController,
FieldInventoryMergeController,
@@ -56,6 +60,8 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
MergedInventoryDossierService,
@@ -170,20 +170,9 @@ export class AssetOperationalRelationsService {
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
// F5/F6: changing the Area operator is a temporal relation event only.
// Existing Inventory keeps its creation/historical operator snapshot and
// physical hierarchy unchanged. Runtime ownership must resolve this row.
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -192,7 +181,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
: undefined,
}, manager);
return created;
@@ -223,8 +212,8 @@ export class AssetOperationalRelationsService {
});
}
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory.
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(`
UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP,
@@ -137,21 +137,41 @@ export class AssetTemporalService {
if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at);
row.asOf = asOf;
// Physical context is reconstructed from the Inventory history, while the
// operator is resolved independently from the temporal Area↔Empresa ledger.
// This keeps historical operator changes from rewriting the Inventory.
const [context] = (await this.dataSource.query(`
SELECT
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',operator_company.id,'code',operator_company.code,'name',operator_company.name
) END AS "operatorCompany"
FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN assets company ON company.id=history.operator_company_id
LEFT JOIN LATERAL (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id=history.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
LIMIT 1
) operator_relation ON true
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id
WHERE history.asset_id=$1
AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC
LIMIT 1
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
`, [assetId, asOf])) as Array<{
parent: Record<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | null;
}>;
if (context && row.snapshot) {
row.snapshot = {
...row.snapshot,
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto {
@IsOptional()
@@ -10,8 +10,13 @@ export class ChangeAssetContextDto {
@IsUUID('4')
operationalAreaId?: string | null;
/**
* Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física
* del Inventario de la Operadora temporal del Área, por lo que este valor no
* puede modificarse desde un cambio de contexto del Inventario.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@@ -54,6 +54,10 @@ export class CreateAssetDto {
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -9,8 +9,12 @@ import {
MinLength,
} from 'class-validator';
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
// La Empresa y el Tipo de concesión se relacionan directamente con el Yacimiento.
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
'AREA',
'YACIMIENTO',
'INSTALACION',
@@ -49,6 +53,14 @@ export class CreateInventoryStructureDto {
@IsUUID('4')
familyId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@@ -22,8 +22,10 @@ export class CreateInventoryFamilyDto {
name!: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@@ -42,8 +44,10 @@ export class UpdateInventoryFamilyDto {
name?: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@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 { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto {
@@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto {
@IsUUID('4')
operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
* filtrar por el snapshot histórico de Empresa del Inventario.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string;
@IsOptional()
@@ -1,6 +1,7 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmpty,
IsEnum,
IsInt,
IsOptional,
@@ -66,7 +67,12 @@ export class ListAssetsQueryDto {
@IsUUID('4')
operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada con consumidores históricos.
* F5/F6 no permite buscar ownership actual por el snapshot de Empresa del
* Inventario; la navegación Área↔Operadora usa area_company_relations.
*/
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
operatorCompanyId?: string;
}
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsObject,
IsOptional,
IsString,
@@ -48,9 +49,13 @@ export class UpdateAssetDto {
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Tipo de concesión se administra en el Yacimiento, no desde la edición genérica del Inventario' })
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -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);
}
@Get('departments')
departments(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.departments(query);
}
@Get('companies')
companies(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.companies(query);
}
@Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query);
@@ -16,9 +16,9 @@ export class InventoryBrowserService {
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
'asset.is_inventory_instance=true',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
"(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))",
];
const add = (value: unknown): string => {
params.push(value);
@@ -36,15 +36,21 @@ export class InventoryBrowserService {
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 === 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) {
const company = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=asset.operational_area_id
AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
conditions.push(`(
asset.id=${company}::uuid
OR EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.id)
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)
FROM area_company_relations relation
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.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
@@ -84,7 +91,8 @@ export class InventoryBrowserService {
) AS "operatorCompany",
asset.information_status AS "informationStatus",
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",
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",
@@ -104,7 +112,14 @@ export class InventoryBrowserService {
LIMIT 1
) geometry_type ON true
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}
`,params);
@@ -119,28 +134,30 @@ export class InventoryBrowserService {
};
}
async areas(query: InventoryBrowserQueryDto) {
async departments(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
"lower(type.code)='departamento'",
"department.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})`);
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) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id
SELECT 1
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.relation_role='OPERATOR'
AND relation.valid_until IS NULL
@@ -149,41 +166,86 @@ export class InventoryBrowserService {
const data = await this.dataSource.query(`
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,
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.is_inventory_instance=true
AND 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
department.information_status AS "informationStatus",
department.operational_status AS "operationalStatus",
(SELECT COUNT(*)::integer FROM assets area
JOIN asset_types atype ON atype.id=area.asset_type_id
WHERE area.parent_id=department.id AND lower(atype.code)='area'
AND area.information_status<>'INACTIVE') AS "areaCount",
(SELECT COUNT(*)::integer FROM assets child
WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount"
FROM assets department
JOIN asset_types type ON type.id=department.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY department.name,department.code
`, params);
return { data, meta: { count: data.length } };
}
async companies(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='COMPANY'",
"company.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`);
}
const data = await this.dataSource.query(`
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 ')}
ORDER BY area.name,area.code
`, params);
return { data, meta: { count: data.length } };
}
@@ -199,9 +261,6 @@ export class InventoryBrowserService {
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
@@ -223,20 +282,12 @@ export class InventoryBrowserService {
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level
) END AS "inventoryFamily",
(
SELECT COUNT(*)::integer
FROM assets child
WHERE child.parent_id=asset.id
AND child.information_status<>'INACTIVE'
AND (
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",
(SELECT COUNT(*)::integer FROM assets child
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount",
CASE WHEN family.id IS NULL THEN 0 ELSE (
SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=family.id
) END AS "findingCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt"
FROM assets asset
@@ -263,10 +314,10 @@ export class InventoryBrowserService {
if (!parent) {
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({
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;
@@ -274,6 +325,7 @@ export class InventoryBrowserService {
private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) {
case 'departamento': return 'area';
case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion';
@@ -17,6 +17,10 @@ import {
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import {
CreateInventoryFamilyAttributeDto,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families')
@@ -50,6 +54,35 @@ export class InventoryFamilyCatalogController {
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')
@RequirePermissions('finding_catalog.manage')
replaceFindings(
@@ -15,7 +15,13 @@ import type {
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} 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 = {
id: string;
code: string;
@@ -24,12 +30,24 @@ type FamilyRow = {
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
assetCount: number;
findingCount: number;
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()
@@ -40,27 +58,7 @@ export class InventoryFamilyCatalogService {
) {}
async listAdmin() {
const data = await this.dataSource.query(`
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[];
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
return { data };
}
@@ -80,27 +78,28 @@ export class InventoryFamilyCatalogService {
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(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
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 [inserted] = (await manager.query(`
INSERT INTO inventory_families(
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
`,[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 (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
await this.replaceParents(manager, inserted.id, parentIds);
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
@@ -108,7 +107,7 @@ export class InventoryFamilyCatalogService {
entityType: 'inventory_family',
entityId: inserted.id,
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);
return created;
});
@@ -122,10 +121,13 @@ export class InventoryFamilyCatalogService {
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
const nextParentIds = dto.parentFamilyIds === undefined
? before.parentFamilyIds
: dto.parentFamilyIds;
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(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
@@ -139,15 +141,7 @@ export class InventoryFamilyCatalogService {
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (before.level==='SUBINSTALLATION') {
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]);
}
}
if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
const after = await this.family(familyId,false,manager);
await this.audit.record({
...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(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
@@ -178,12 +251,10 @@ export class InventoryFamilyCatalogService {
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
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) {
throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
if (Number(count?.total ?? 0)!==uniqueIds.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
const beforeIds=family.findingItemIds;
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({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',
entityId:familyId,
beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
entityType:'inventory_family_findings',entityId:familyId,
beforeData:{itemIds:beforeIds},afterData:{itemIds:after.findingItemIds},
metadata:{operation:'INVENTORY_FAMILY_FINDINGS_REPLACED',reason:dto.reason},
},manager);
return this.findingsWithManager(manager,familyId);
});
@@ -217,77 +286,150 @@ export class InventoryFamilyCatalogService {
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
WHERE mapping.inventory_family_id=$1::uuid AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
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(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const rows=(await manager.query(`
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 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
const lockClause=lock ? 'FOR UPDATE OF family' : '';
const rows=(await manager.query(this.familySelect(`
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
${lockClause}
`),[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParent(
private async validateParents(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null,
requestedIds:string[],
ownId:string|null,
):Promise<string|null> {
):Promise<string[]> {
const ids=[...new Set(requestedIds)];
if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({
if (ids.length) throw new BadRequestException({
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',
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({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
});
const rows=(await manager.query(`
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
const [count]=(await manager.query(`
SELECT COUNT(*)::integer AS total FROM inventory_families
WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
`,[ids])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
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[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim();
if (!clean) continue;
const clean=raw.trim(); if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
@@ -296,4 +438,8 @@ export class InventoryFamilyCatalogService {
});
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,8 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type SimpleOptionRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
code: string;
@@ -27,9 +29,8 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
};
type ParentRow = {
id: string;
@@ -37,9 +38,19 @@ type ParentRow = {
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
@@ -47,7 +58,8 @@ const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, stri
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
AREA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
@@ -70,22 +82,24 @@ export class InventoryStructureService {
SELECT id,code,name
FROM asset_types
WHERE (
lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
OR operational_role='COMPANY'
) AND is_active=true
ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='area' THEN 1
WHEN lower(code)='yacimiento' THEN 2
WHEN lower(code)='instalacion' THEN 3
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
WHEN lower(code)='departamento' THEN 1
WHEN lower(code)='area' THEN 2
WHEN lower(code)='yacimiento' THEN 3
WHEN lower(code)='instalacion' THEN 4
WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END
`)) as StructureTypeRow[];
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 yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
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({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
@@ -96,24 +110,47 @@ export class InventoryStructureService {
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
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
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
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
const companies = (await this.dataSource.query(`
SELECT asset.id,asset.code,COALESCE(profile.legal_name,asset.name) AS name
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id AND type.operational_role='COMPANY' AND type.is_active=true
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
WHERE asset.information_status<>'INACTIVE'
ORDER BY COALESCE(profile.legal_name,asset.name),asset.code
`)) as SimpleOptionRow[];
const concessionTypes = (await this.dataSource.query(`
SELECT id,code,name
FROM concession_types
WHERE is_active=true
ORDER BY CASE lower(name) WHEN 'explotación' THEN 1 WHEN 'exploración' THEN 2 ELSE 9 END,name,code
`)) as SimpleOptionRow[];
return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
],
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: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
],
companies,
concessionTypes,
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
};
@@ -121,7 +158,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) {
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({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre',
@@ -153,7 +190,7 @@ export class InventoryStructureService {
AND asset.information_status<>'INACTIVE'
${searchSql}
ORDER BY asset.name,asset.code
LIMIT 80
LIMIT 100
`, parameters);
return { data: rows };
}
@@ -168,23 +205,43 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const yacimientoContext = await this.requireYacimientoContext(manager, dto);
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 operatorCompanyId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.companyId ?? null
: dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION'
? parent?.operatorCompanyId ?? null
: null;
const concessionTypeId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.concessionTypeId ?? null
: null;
if ((dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') && !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_COMPANY_MISSING',
message: 'El Yacimiento de origen no tiene una Empresa relacionada válida',
});
}
const inserted = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
family?.id ?? null,
generatedCode,
dto.name,
@@ -193,9 +250,13 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
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}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
@@ -208,52 +269,52 @@ export class InventoryStructureService {
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`,[id,dto.name,principal.userId]);
}
if (dto.kind === 'YACIMIENTO' && operationalAreaId && operatorCompanyId && concessionTypeId) {
await this.ensureCompatibilityProjection(
manager,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
yacimientoContext?.concessionName ?? 'Concesión',
);
}
const versionNumber = await this.history.capture(
manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
manager,id,AssetVersionChangeType.CREATED,principal,request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [
id,
parent?.id ?? null,
operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
versionNumber,
request.requestId,
principal.userId,
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
: dto.kind === 'EMPRESA'
? 'Alta manual de Empresa independiente'
: 'Alta manual de estructura de Inventario',
versionNumber,request.requestId,principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',
entityId: id,
entityType: 'asset',entityId: id,
afterData: created as unknown as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false,
versionNumber,inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
concessionTypeId,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro con ese código',
});
}
if (isUniqueViolation(error)) throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
});
throw error;
}
}
@@ -264,12 +325,9 @@ export class InventoryStructureService {
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
message: `El nivel ${kind} no está configurado`,
});
}
if (!rows[0]) throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
});
return rows[0];
}
@@ -280,25 +338,23 @@ export class InventoryStructureService {
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) {
throw new BadRequestException({
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Área es un registro raíz y no puede tener padre',
});
}
if (parentId) throw new BadRequestException({
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Departamento es un registro raíz y no puede tener padre',
});
return null;
}
if (!parentId) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
}
if (!parentId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId"
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
@@ -306,17 +362,72 @@ export class InventoryStructureService {
`, [parentId])) as ParentRow[];
const parent = rows[0];
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({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
return parent;
}
private async requireYacimientoContext(
manager: EntityManager,
dto: CreateInventoryStructureDto,
): Promise<YacimientoContext | null> {
if (dto.kind !== 'YACIMIENTO') {
if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_NOT_ALLOWED',
message: 'Empresa relacionada y Tipo de concesión sólo corresponden al Yacimiento',
});
}
return null;
}
if (!dto.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
message: 'Seleccioná la Empresa relacionada del Yacimiento',
});
}
return parent;
if (!dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_REQUIRED',
message: 'Seleccioná el Tipo de concesión del Yacimiento',
});
}
const companyRows = (await manager.query(`
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND type.operational_role='COMPANY'
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
FOR KEY SHARE
`,[dto.operatorCompanyId])) as IdRow[];
if (!companyRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_INVALID',
message: 'La Empresa relacionada seleccionada no es válida',
});
const concessionRows = (await manager.query(`
SELECT id,name
FROM concession_types
WHERE id=$1::uuid AND is_active=true
FOR KEY SHARE
`,[dto.concessionTypeId])) as Array<{id:string;name:string}>;
if (!concessionRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_INVALID',
message: 'El Tipo de concesión seleccionado no es válido',
});
return {
companyId: dto.operatorCompanyId,
concessionTypeId: dto.concessionTypeId,
concessionName: concessionRows[0].name,
};
}
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
if (parent.operationalAreaId) return parent.operationalAreaId;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
@@ -330,12 +441,10 @@ export class InventoryStructureService {
LIMIT 1
`,[parent.id])) as IdRow[];
const areaId=rows[0]?.id;
if (!areaId) {
throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
}
if (!areaId) throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
return areaId;
}
@@ -349,49 +458,93 @@ export class InventoryStructureService {
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
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;
}
if (!familyId) throw new BadRequestException({
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(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
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
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
LIMIT 1
`, [familyId])) as FamilyRow[];
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({
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) {
throw new BadRequestException({
if (kind === 'SUBINSTALACION') {
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',
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;
}
private async ensureCompatibilityProjection(
manager: EntityManager,
areaId: string,
companyId: string,
concessionTypeId: string,
concessionName: string,
): Promise<void> {
const companyProjection = await manager.query(`
SELECT id FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_to IS NULL
LIMIT 1
`,[areaId,companyId]);
if (!companyProjection[0]) {
await manager.query(`
INSERT INTO area_company_relations(area_id,company_id,relation_role,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,'Proyección derivada de Yacimiento')
`,[areaId,companyId]);
}
const rightType = concessionName.toLocaleLowerCase('es-AR').includes('explor')
? 'EXPLORATION_PERMIT'
: 'EXPLOITATION_CONCESSION';
const rightProjection = await manager.query(`
SELECT id FROM area_legal_rights
WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND status='ACTIVE'
LIMIT 1
`,[areaId,rightType]);
if (!rightProjection[0]) {
await manager.query(`
INSERT INTO area_legal_rights(area_id,right_type,name,status,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4)
`,[areaId,rightType,`${concessionName} · proyección de Yacimiento`,`Tipo de concesión canónico: ${concessionTypeId}`]);
}
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const readable = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'REGISTRO';
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
: kind === 'AREA' ? 'AREA'
: kind === 'YACIMIENTO' ? 'YAC'
: kind === 'INSTALACION' ? 'INST'
: 'SUB';
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
@@ -402,6 +555,8 @@ export class InventoryStructureService {
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN concession.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',concession.id,'code',concession.code,'name',concession.name) END AS "concessionType",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
@@ -411,11 +566,12 @@ export class InventoryStructureService {
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN concession_types concession ON concession.id=asset.concession_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
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}`});
}
}
@@ -33,6 +33,7 @@ export enum AssetDataOrigin {
@Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus'])
@@ -54,6 +55,9 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
concessionTypeId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@@ -30,6 +30,8 @@ export enum InspectionDeadlineBasis {
GEDO_DATE = 'GEDO_DATE',
}
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
@Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -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]);
}
}
@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F62FreezeInspectionContext1790098200000 implements MigrationInterface {
name = 'F62FreezeInspectionContext1790098200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id
OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id
OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING
ERRCODE='23514',
MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación';
END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_context_immutable
BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id
ON inspection_visits
FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()');
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface {
name = 'F63MobileFieldCommonAttributes1790099100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH target_types AS (
SELECT id
FROM asset_types
WHERE lower(code) IN ('instalacion','subinstalacion')
), fields(code,name,sort_order) AS (
VALUES
('campo_marca','Marca',10),
('campo_modelo','Modelo',20),
('campo_capacidad','Capacidad',30),
('campo_numero_serie','Número de serie',40),
('campo_funcion','Función',50)
)
INSERT INTO asset_attribute_definitions (
asset_type_id, code, name, data_type, is_required, is_active, sort_order
)
SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type,
false, true, fields.sort_order
FROM target_types target
CROSS JOIN fields
WHERE NOT EXISTS (
SELECT 1
FROM asset_attribute_definitions existing
WHERE existing.asset_type_id=target.id
AND lower(existing.code)=lower(fields.code)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM asset_attribute_values value
USING asset_attribute_definitions definition, asset_types type
WHERE value.definition_id=definition.id
AND definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
await queryRunner.query(`
DELETE FROM asset_attribute_definitions definition
USING asset_types type
WHERE definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
}
}
@@ -0,0 +1,240 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const TARGET_CODES = [
'departamento',
'area',
'yacimiento',
'instalacion',
'subinstalacion',
] as const;
const DELETE_ORDER = [
'subinstalacion',
'instalacion',
'yacimiento',
'area',
'departamento',
] as const;
type ProtectedSnapshot = {
users: string;
companies: string;
companyProfiles: string;
assetTypes: string;
assetAttributes: string;
inventoryFamilies: string;
familyAttributes: string;
findingCategories: string;
findingItems: string;
};
export class ResetOperationalHierarchyData1790099200000 implements MigrationInterface {
name = 'ResetOperationalHierarchyData1790099200000';
public async up(queryRunner: QueryRunner): Promise<void> {
// This is a one-time live-data cleanup, not a new canonical empty seed.
// Fresh CI/bootstrap databases intentionally have no admin account while
// replaying the historical migration chain, so they must retain the F6.1
// presentation seed used by hierarchy/planning contract tests.
const adminRows = (await queryRunner.query(`
SELECT id
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[hierarchy-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Hierarchy reset aborted: expected exactly one live admin account, found ${adminRows.length}`,
);
}
const targetTypes = (await queryRunner.query(
`
SELECT lower(code) AS code
FROM asset_types
WHERE lower(code)=ANY($1::text[])
ORDER BY lower(code)
`,
[[...TARGET_CODES]],
)) as Array<{ code: string }>;
const found = new Set(targetTypes.map((row) => row.code));
const missing = TARGET_CODES.filter((code) => !found.has(code));
if (missing.length > 0) {
throw new Error(`Hierarchy reset aborted: missing asset types ${missing.join(', ')}`);
}
const before = await this.protectedSnapshot(queryRunner);
await queryRunner.query(
`
CREATE TEMP TABLE reset_target_assets ON COMMIT DROP AS
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
`,
[[...TARGET_CODES]],
);
await queryRunner.query(`CREATE UNIQUE INDEX reset_target_assets_pk ON reset_target_assets(id)`);
const [targetCount] = (await queryRunner.query(
`SELECT COUNT(*)::integer AS total FROM reset_target_assets`,
)) as Array<{ total: number }>;
// An Inspection freezes Area/Yacimiento/Operadora from creation and several
// inspection tables hold RESTRICT references to the hierarchy. Keeping a
// transaction that points to deleted territory would be invalid, so the
// complete disposable inspection graph is cleared first.
await queryRunner.query('TRUNCATE TABLE inspection_visits CASCADE');
// Legacy administrative departments are also presentation/operational data.
// Current F6 Departments live in assets, but this prevents old rows from
// resurfacing through compatibility paths.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
// Legal-right participants depend on area_legal_rights rather than directly
// on assets. Remove them before the generic direct-FK cleanup below.
await queryRunner.query(`
DELETE FROM area_legal_right_organizations organization
USING area_legal_rights legal_right
WHERE organization.right_id=legal_right.id
AND legal_right.area_id IN (SELECT id FROM reset_target_assets)
`);
// Clean every table that directly references one of the hierarchy assets.
// This deliberately discovers the current schema instead of maintaining a
// fragile hand-written list as new dossier/history tables are added.
await queryRunner.query(`
DO $$
DECLARE dependency record;
BEGIN
FOR dependency IN
SELECT
namespace.nspname AS schema_name,
relation.relname AS table_name,
attribute.attname AS column_name
FROM pg_constraint constraint_row
JOIN pg_class relation ON relation.oid=constraint_row.conrelid
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
JOIN LATERAL unnest(constraint_row.conkey) WITH ORDINALITY local_key(attnum,ordinality)
ON true
JOIN LATERAL unnest(constraint_row.confkey) WITH ORDINALITY referenced_key(attnum,ordinality)
ON referenced_key.ordinality=local_key.ordinality
JOIN pg_attribute attribute
ON attribute.attrelid=constraint_row.conrelid
AND attribute.attnum=local_key.attnum
JOIN pg_attribute referenced_attribute
ON referenced_attribute.attrelid=constraint_row.confrelid
AND referenced_attribute.attnum=referenced_key.attnum
WHERE constraint_row.contype='f'
AND constraint_row.confrelid='assets'::regclass
AND constraint_row.conrelid<>'assets'::regclass
AND array_length(constraint_row.conkey,1)=1
AND referenced_attribute.attname='id'
ORDER BY namespace.nspname,relation.relname,attribute.attname
LOOP
EXECUTE format(
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM reset_target_assets)',
dependency.schema_name,
dependency.table_name,
dependency.column_name
);
END LOOP;
END $$;
`);
// parent_id is RESTRICT, therefore physical hierarchy rows are deleted from
// the leaves upward. Company/Operator assets are intentionally not targets.
for (const code of DELETE_ORDER) {
await queryRunner.query(
`
DELETE FROM assets asset
USING asset_types type
WHERE asset.asset_type_id=type.id
AND lower(type.code)=$1
`,
[code],
);
}
const after = await this.protectedSnapshot(queryRunner);
for (const key of Object.keys(before) as Array<keyof ProtectedSnapshot>) {
if (before[key] !== after[key]) {
throw new Error(
`Hierarchy reset verification failed: protected ${key} changed (${before[key]} -> ${after[key]})`,
);
}
}
const [verification] = (await queryRunner.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
) AS hierarchy_assets,
(SELECT COUNT(*)::integer FROM administrative_departments) AS administrative_departments,
(SELECT COUNT(*)::integer FROM inspection_visits) AS inspection_visits
`, [[...TARGET_CODES]])) as Array<{
hierarchy_assets: number;
administrative_departments: number;
inspection_visits: number;
}>;
if (
!verification
|| Number(verification.hierarchy_assets) !== 0
|| Number(verification.administrative_departments) !== 0
|| Number(verification.inspection_visits) !== 0
) {
throw new Error(`Hierarchy reset verification failed: ${JSON.stringify(verification ?? {})}`);
}
// eslint-disable-next-line no-console
console.log(
`[hierarchy-reset] removed ${Number(targetCount?.total ?? 0)} Departamento/Área/Yacimiento/Instalación/Subinstalación assets; inspections and legacy departments cleared; users=${after.users}; companies=${after.companies} preserved`,
);
}
public async down(): Promise<void> {
throw new Error(
'ResetOperationalHierarchyData is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
private async protectedSnapshot(queryRunner: QueryRunner): Promise<ProtectedSnapshot> {
const [snapshot] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::text FROM users) AS "users",
(
SELECT COUNT(*)::text
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companies",
(
SELECT COUNT(*)::text
FROM organization_profiles profile
JOIN assets asset ON asset.id=profile.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companyProfiles",
(SELECT COUNT(*)::text FROM asset_types) AS "assetTypes",
(SELECT COUNT(*)::text FROM asset_attribute_definitions) AS "assetAttributes",
(SELECT COUNT(*)::text FROM inventory_families) AS "inventoryFamilies",
(SELECT COUNT(*)::text FROM inventory_family_attribute_definitions) AS "familyAttributes",
(SELECT COUNT(*)::text FROM finding_categories) AS "findingCategories",
(SELECT COUNT(*)::text FROM finding_catalog_items) AS "findingItems"
`)) as ProtectedSnapshot[];
if (!snapshot) throw new Error('Hierarchy reset aborted: could not snapshot protected masters');
return snapshot;
}
}
@@ -0,0 +1,173 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* One-time full live-data reset requested before loading the definitive source files.
*
* The database is left with only the single `admin` user and the minimum product
* scaffolding required to keep authentication/authorization and the core asset
* model functional. Every business, operational, imported, catalog, history,
* document, inspection and tenant/company row is removed.
*
* Fresh migration replays/CI do not have the live `admin` account at this point,
* so this migration intentionally no-ops there.
*/
export class FullLiveDataReset1790103000000 implements MigrationInterface {
name = 'FullLiveDataReset1790103000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const adminRows = (await queryRunner.query(`
SELECT id, username
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string; username: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[full-live-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Full live reset aborted: expected exactly one username admin, found ${adminRows.length}`,
);
}
const adminId = adminRows[0].id;
// These are product/schema scaffolding, not customer/business data.
// Everything else in public is disposable live data for this reset.
const structuralTables = [
'roles',
'permissions',
'role_permissions',
'asset_types',
'asset_attribute_definitions',
'asset_type_parent_rules',
] as const;
const preservedTables = new Set<string>([
'typeorm_migrations',
'users',
'user_roles',
...structuralTables,
]);
const structuralCounts = new Map<string, string>();
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
structuralCounts.set(table, rows[0]?.total ?? '0');
}
const adminRolesBefore = (await queryRunner.query(
`SELECT count(*)::text AS total FROM user_roles WHERE user_id=$1`,
[adminId],
)) as Array<{ total: string }>;
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
if (adminRoleCount === '0') {
throw new Error('Full live reset aborted: admin has no assigned role');
}
const tableRows = (await queryRunner.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema='public'
AND table_type='BASE TABLE'
ORDER BY table_name
`)) as Array<{ table_name: string }>;
const disposableTables = tableRows
.map((row) => row.table_name)
.filter((table) => !preservedTables.has(table));
if (disposableTables.length > 0) {
const quoted = disposableTables
.map((table) => `"${table.replace(/"/g, '""')}"`)
.join(', ');
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
}
// Keep only the owner's administrator account. user_roles for other users
// are removed through their FK cascade.
await queryRunner.query(`DELETE FROM users WHERE id<>$1`, [adminId]);
// Invalidate any previous login state and unlock the preserved account.
await queryRunner.query(
`
UPDATE users
SET failed_login_attempts=0,
locked_until=NULL,
last_login_at=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`,
[adminId],
);
const finalUsers = (await queryRunner.query(`
SELECT
count(*)::text AS total,
count(*) FILTER (WHERE lower(btrim(username))='admin')::text AS admins
FROM users
`)) as Array<{ total: string; admins: string }>;
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
throw new Error('Full live reset verification failed: users table is not admin-only');
}
const finalAdminRoles = (await queryRunner.query(
`
SELECT
count(*) FILTER (WHERE user_id=$1)::text AS total,
count(*) FILTER (WHERE user_id<>$1)::text AS foreign_users
FROM user_roles
`,
[adminId],
)) as Array<{ total: string; foreign_users: string }>;
if (
finalAdminRoles[0]?.total !== adminRoleCount ||
finalAdminRoles[0]?.foreign_users !== '0'
) {
throw new Error('Full live reset verification failed: admin role assignments changed');
}
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
const before = structuralCounts.get(table) ?? '0';
if (rows[0]?.total !== before) {
throw new Error(
`Full live reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
);
}
}
for (const table of disposableTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
if (rows[0]?.total !== '0') {
throw new Error(`Full live reset verification failed: ${table} is not empty`);
}
}
// eslint-disable-next-line no-console
console.log(
`[full-live-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} product tables; cleared ${disposableTables.length} data tables`,
);
}
public async down(): Promise<void> {
throw new Error(
'FullLiveDataReset is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
}
@@ -0,0 +1,380 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { AUTHORITATIVE_INVENTORY_MODEL as SOURCE } from '../../reference-data/authoritative-inventory-model';
type IdRow = { id: string };
const TERRITORY_DOC = 'DH-AUTH-TERRITORY-20260911';
const INVENTORY_DOC = 'DH-AUTH-INVENTORY-20260911';
const FINDING_CATEGORY_CODE = 'AUTHMODEL';
function sourceCode(prefix: string, id: number): string {
return `${prefix}-${String(id).padStart(4, '0')}`;
}
export class AuthoritativeInventoryRelationships1790106600000 implements MigrationInterface {
name = 'AuthoritativeInventoryRelationships1790106600000';
public async up(q: QueryRunner): Promise<void> {
this.assertSource();
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 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 fieldType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
const installationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='instalacion' AND is_active=true LIMIT 1`);
const subinstallationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='subinstalacion' AND is_active=true LIMIT 1`);
await q.query(`
CREATE TABLE IF NOT EXISTS concession_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL UNIQUE,
name varchar(120) NOT NULL UNIQUE,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_concession_types_name CHECK (length(btrim(name)) >= 3)
)
`);
await q.query(`ALTER TABLE assets ADD COLUMN IF NOT EXISTS concession_type_id uuid`);
await q.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='fk_assets_concession_type') THEN
ALTER TABLE assets ADD CONSTRAINT fk_assets_concession_type
FOREIGN KEY (concession_type_id) REFERENCES concession_types(id) ON DELETE RESTRICT;
END IF;
END $$
`);
await q.query(`CREATE INDEX IF NOT EXISTS idx_assets_concession_type_id ON assets(concession_type_id)`);
await q.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
GRANT SELECT ON concession_types TO dhv2_app;
END IF;
END $$
`);
await q.query(`DELETE FROM asset_type_parent_rules WHERE child_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
await q.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) VALUES
($1::uuid,$2::uuid),($3::uuid,$1::uuid),($4::uuid,$3::uuid),($5::uuid,$4::uuid)
ON CONFLICT DO NOTHING
`, [areaType, departmentType, fieldType, installationType, subinstallationType]);
await q.query(`UPDATE asset_types SET can_be_root=true,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid)`, [companyType, departmentType]);
await q.query(`UPDATE asset_types SET can_be_root=false,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
await q.query(`UPDATE asset_types SET description='Área territorial. Sólo posee Nombre y pertenece obligatoriamente a un Departamento.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [areaType]);
await q.query(`UPDATE asset_types SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [fieldType]);
await q.query(`UPDATE asset_types SET description='Instalación física. Pertenece obligatoriamente a un Yacimiento y utiliza una clasificación técnica.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [installationType]);
await q.query(`UPDATE asset_types SET description='Subinstalación física. Pertenece obligatoriamente a una Instalación y utiliza una clasificación técnica compatible.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [subinstallationType]);
await q.query(`DELETE FROM asset_attribute_definitions WHERE asset_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid)`, [companyType, departmentType, areaType, fieldType, installationType, subinstallationType]);
await this.insertCommonAttributes(q, installationType, [
['campo_marca','Marca',10],
['tipo_instalacion','Tipo de instalación',20],
['campo_modelo','Modelo',30],
['campo_capacidad','Capacidad',40],
['campo_numero_serie','Número de serie',50],
['campo_funcion','Función',60],
]);
await this.insertCommonAttributes(q, subinstallationType, [
['campo_marca','Marca',10],
['campo_modelo','Modelo',20],
['campo_capacidad','Capacidad',30],
['campo_numero_serie','Número de serie',40],
['campo_funcion','Función',50],
]);
await q.query(`
DO $$ DECLARE trigger_name text; BEGIN
FOR trigger_name IN
SELECT trigger_row.tgname
FROM pg_trigger trigger_row
JOIN pg_proc function_row ON function_row.oid=trigger_row.tgfoid
WHERE trigger_row.tgrelid='assets'::regclass
AND NOT trigger_row.tgisinternal
AND function_row.proname='enforce_asset_operational_context'
LOOP
EXECUTE format('DROP TRIGGER %I ON assets',trigger_name);
END LOOP;
END $$
`);
await q.query(`DROP FUNCTION IF EXISTS enforce_asset_operational_context()`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
await q.query(`DROP TRIGGER IF EXISTS trg_assets_authoritative_inventory_relationships ON assets`);
await q.query(`
CREATE TRIGGER trg_assets_authoritative_inventory_relationships
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,concession_type_id
ON assets FOR EACH ROW EXECUTE FUNCTION enforce_authoritative_inventory_relationships()
`);
await q.query(`TRUNCATE TABLE assets CASCADE`);
await q.query(`TRUNCATE TABLE source_documents CASCADE`);
await q.query(`TRUNCATE TABLE inventory_families CASCADE`);
await q.query(`TRUNCATE TABLE finding_categories CASCADE`);
await q.query(`TRUNCATE TABLE concession_types CASCADE`);
const [territoryDoc] = (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
`, [TERRITORY_DOC,'modelo_yacimientos.sql','sha256:6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a','Modelo relacional exacto de Departamentos, Áreas, Yacimientos, Empresas y Tipo de concesión.'])) as IdRow[];
const [inventoryDoc] = (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
`, [INVENTORY_DOC,'modelo_relacional instalaciones y sub.sql','sha256:fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49','Modelo relacional exacto de tipos de Instalación/Subinstalación y sus Hallazgos.'])) as IdRow[];
if(!territoryDoc?.id || !inventoryDoc?.id) throw new Error('Authoritative source documents could not be created');
const concessionIds=new Map<number,string>();
for(const row of SOURCE.concessionTypes){
const [created]=(await q.query(`INSERT INTO concession_types(code,name,is_active) VALUES($1,$2,true) RETURNING id`,[sourceCode('CONC',row.id),row.nombre])) as IdRow[];
if(!created?.id)throw new Error(`Could not create concession ${row.nombre}`); concessionIds.set(row.id,created.id);
}
await this.seedFamiliesAndFindings(q);
const companyIds=new Map<number,string>();
for(const row of SOURCE.companies){
const id=await this.asset(q,companyType,null,null,null,null,sourceCode('ORG',row.id),row.nombre,'modelo_yacimientos.sql',`empresas:${row.id}`);
companyIds.set(row.id,id);
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name) VALUES($1::uuid,$2::organization_kind,$3)`,[id,row.nombre.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',row.nombre]);
await this.link(q,id,territoryDoc.id,`Empresa · source id ${row.id}`);
}
const departmentIds=new Map<number,string>();
for(const row of SOURCE.departments){
const id=await this.asset(q,departmentType,null,null,null,null,sourceCode('DEP',row.id),row.nombre,'modelo_yacimientos.sql',`departamentos:${row.id}`);
departmentIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Departamento · source id ${row.id}`);
}
const areaIds=new Map<number,string>();
for(const row of SOURCE.areas){
const parent=departmentIds.get(row.departamento_id); if(!parent)throw new Error(`Missing source Departamento ${row.departamento_id}`);
const id=await this.asset(q,areaType,parent,null,null,null,sourceCode('AREA',row.id),row.nombre,'modelo_yacimientos.sql',`areas:${row.id}`);
areaIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Área · source id ${row.id}`);
}
for(const row of SOURCE.fields){
const area=areaIds.get(row.area_id); const company=companyIds.get(row.empresa_id); const concession=concessionIds.get(row.tipo_concesion_id);
if(!area||!company||!concession)throw new Error(`Incomplete source relations for Yacimiento ${row.id}`);
const id=await this.asset(q,fieldType,area,area,company,concession,sourceCode('YAC',row.id),row.nombre,'modelo_yacimientos.sql',`yacimientos:${row.id}`);
await this.link(q,id,territoryDoc.id,`Yacimiento · source id ${row.id}`);
}
await q.query(`TRUNCATE TABLE area_company_relations CASCADE`);
await q.query(`TRUNCATE TABLE area_legal_rights CASCADE`);
const projectedPairs=new Set<string>();
const projectedRights=new Set<string>();
for(const row of SOURCE.fields){
const area=areaIds.get(row.area_id)!; const company=companyIds.get(row.empresa_id)!; const concession=concessionIds.get(row.tipo_concesion_id)!;
const pair=`${area}|${company}`;
if(!projectedPairs.has(pair)){
projectedPairs.add(pair);
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)`,[area,company,territoryDoc.id,'Derivado automáticamente de Yacimientos del modelo autoritativo']);
}
const rightKey=`${area}|${concession}`;
if(!projectedRights.has(rightKey)){
projectedRights.add(rightKey);
const concessionRow=SOURCE.concessionTypes.find((item)=>item.id===row.tipo_concesion_id)!;
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)`,[
area,concessionRow.nombre==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
`${concessionRow.nombre} · proyección del Yacimiento`,territoryDoc.id,'Compatibilidad derivada; el Tipo de concesión canónico está en Yacimiento.']);
}
}
await this.installFamilyFindingSync(q);
await this.verify(q);
}
public async down():Promise<void>{
throw new Error('Authoritative inventory model is a deliberate clean-load migration; restore the deploy PRE database backup.');
}
private assertSource():void{
if(SOURCE.departments.length!==7||SOURCE.companies.length!==13||SOURCE.concessionTypes.length!==2||SOURCE.areas.length!==64||SOURCE.fields.length!==230)
throw new Error('Authoritative territory source cardinality mismatch');
if(SOURCE.installationFamilies.length!==14||SOURCE.subinstallationFamilies.length!==109||SOURCE.findings.length!==181||SOURCE.installationFindings.length!==48||SOURCE.subinstallationFindings.length!==880)
throw new Error('Authoritative inventory source cardinality mismatch');
for(const area of SOURCE.areas) if(!SOURCE.departments.some((d)=>d.id===area.departamento_id)) throw new Error(`Area ${area.id} has no source Departamento`);
for(const field of SOURCE.fields){
if(!SOURCE.areas.some((a)=>a.id===field.area_id)||!SOURCE.companies.some((c)=>c.id===field.empresa_id)||!SOURCE.concessionTypes.some((c)=>c.id===field.tipo_concesion_id))
throw new Error(`Yacimiento ${field.id} has invalid source relationship`);
}
}
private async insertCommonAttributes(q:QueryRunner,typeId:string,rows:Array<[string,string,number]>):Promise<void>{
for(const [code,name,sortOrder] of rows) await q.query(`
INSERT INTO asset_attribute_definitions(asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order)
VALUES($1::uuid,$2,$3,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,$4)
`,[typeId,code,name,sortOrder]);
}
private async seedFamiliesAndFindings(q:QueryRunner):Promise<void>{
const installationIds=new Map<number,string>();
for(const row of SOURCE.installationFamilies){
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
VALUES($1,$2,'INSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-I',row.id),row.nombre,`modelo_relacional:instalaciones:${row.id}`])) as IdRow[];
if(!created?.id)throw new Error(`Could not create Instalación family ${row.id}`); installationIds.set(row.id,created.id);
}
const subIds=new Map<number,string>();
for(const row of SOURCE.subinstallationFamilies){
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
VALUES($1,$2,'SUBINSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-S',row.id),row.nombre,`modelo_relacional:subinstalaciones:${row.id}`])) as IdRow[];
const parent=installationIds.get(row.instalacion_id); if(!created?.id||!parent)throw new Error(`Invalid Subinstalación family ${row.id}`);
subIds.set(row.id,created.id);
await q.query(`INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) VALUES($1::uuid,$2::uuid)`,[created.id,parent]);
}
const [category]=(await q.query(`INSERT INTO finding_categories(code,name,sort_order,is_active) VALUES($1,$2,300,true) RETURNING id`,[FINDING_CATEGORY_CODE,'DH · Hallazgos del modelo autoritativo'])) as IdRow[];
if(!category?.id)throw new Error('Could not create authoritative finding category');
const findingIds=new Map<number,string>();
for(const row of SOURCE.findings){
const [created]=(await q.query(`INSERT INTO finding_catalog_items(category_id,code,source_number,title,import_note,revision,is_active)
VALUES($1::uuid,$2,$3,$4,$5,1,true) RETURNING id`,[category.id,sourceCode('AUTH-H',row.id),row.id,row.nombre,'modelo_relacional instalaciones y sub.sql'])) as IdRow[];
if(!created?.id)throw new Error(`Could not create Hallazgo ${row.id}`); findingIds.set(row.id,created.id);
await q.query(`INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,jsonb_build_object('id',item.id,'categoryId',item.category_id,'code',item.code,'sourceNumber',item.source_number,'title',item.title,'revision',item.revision,'isActive',item.is_active),'migration:AUTHORITATIVE'
FROM finding_catalog_items item WHERE item.id=$1::uuid`,[created.id]);
}
for(const mapping of SOURCE.installationFindings){
const family=installationIds.get(mapping.instalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
if(!family||!finding)throw new Error('Invalid installation finding mapping');
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
}
for(const mapping of SOURCE.subinstallationFindings){
const family=subIds.get(mapping.subinstalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
if(!family||!finding)throw new Error('Invalid subinstallation finding mapping');
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
}
}
private async installFamilyFindingSync(q:QueryRunner):Promise<void>{
await q.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides WHERE asset_id=NEW.id AND reason LIKE 'AUTHORITATIVE familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by)
SELECT NEW.id,m.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families m WHERE m.inventory_family_id=NEW.inventory_family_id
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$
`);
await q.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides o USING assets a
WHERE o.asset_id=a.id AND a.inventory_family_id=OLD.inventory_family_id AND o.catalog_item_id=OLD.catalog_item_id AND o.reason LIKE 'AUTHORITATIVE familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by)
SELECT a.id,NEW.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',a.created_by,a.updated_by
FROM assets a WHERE a.inventory_family_id=NEW.inventory_family_id
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$
`);
}
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,companyId:string|null,concessionId:string|null,
code:string,name:string,sourceName:string,sourceReference:string):Promise<string>{
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,information_status,operational_status,data_origin,source_name,source_reference,is_inventory_instance)
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,NULL,$6,$7,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$8,$9,false) RETURNING id`,
[typeId,parentId,areaId,companyId,concessionId,code,name,sourceName,sourceReference])) as IdRow[];
if(!row?.id)throw new Error(`Could not seed ${code}`); 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]);
}
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 canonical asset type: ${sql}`); return rows[0].id;
}
private async verify(q:QueryRunner):Promise<void>{
const [row]=await q.query(`SELECT
(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 fields,
(SELECT COUNT(*) FROM concession_types)::integer concessions,
(SELECT COUNT(*) FROM inventory_families WHERE level='INSTALLATION')::integer installation_families,
(SELECT COUNT(*) FROM inventory_families WHERE level='SUBINSTALLATION')::integer subinstallation_families,
(SELECT COUNT(*) FROM finding_catalog_items)::integer findings,
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='INSTALLATION')::integer installation_mappings,
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='SUBINSTALLATION')::integer subinstallation_mappings,
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id JOIN assets a ON a.id=y.parent_id WHERE lower(t.code)='yacimiento' AND (y.operational_area_id IS DISTINCT FROM a.id OR y.operator_company_id IS NULL OR y.concession_type_id IS NULL))::integer invalid_fields`);
const expected:Record<string,number>={companies:13,departments:7,areas:64,fields:230,concessions:2,installation_families:14,subinstallation_families:109,findings:181,installation_mappings:48,subinstallation_mappings:880,invalid_fields:0};
for(const [key,value] of Object.entries(expected)) if(Number(row?.[key]??-1)!==value)throw new Error(`Authoritative inventory verification failed: ${key}=${row?.[key]} expected=${value}`);
}
}
@@ -1,7 +1,6 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
@@ -42,7 +41,6 @@ export class CreateInspectionActDto {
observations?: string | null;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@ArrayUnique()
@IsUUID('4', { each: true })
@@ -154,41 +154,26 @@ export class InspectionActsService {
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.code ILIKE ${search}
OR search_asset.name ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search})
)
OR EXISTS (
SELECT 1 FROM assets search_company
WHERE search_company.id=visit.operator_company_id
AND search_company.name ILIKE ${search}
)
OR EXISTS (
SELECT 1 FROM assets search_area
WHERE search_area.id=visit.operational_area_id
AND search_area.name ILIKE ${search}
)
)`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) {
conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
@@ -245,24 +230,8 @@ export class InspectionActsService {
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) {
conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
@@ -548,21 +517,16 @@ export class InspectionActsService {
LEFT JOIN inspection_reports report ON report.act_id = act.id
LEFT JOIN LATERAL (
SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas
FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
WHERE context_visit.id=act.visit_id
) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
@@ -692,6 +656,7 @@ export class InspectionActsService {
visitId: string,
assetIds: string[],
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets
@@ -699,7 +664,7 @@ export class InspectionActsService {
AND asset_id = ANY($2::uuid[])
AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>;
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
if (Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección',
@@ -813,6 +778,8 @@ export class InspectionActsService {
'code', visit.code,
'status', visit.status,
'scopeAssetId', visit.scope_asset_id,
'operationalAreaId', visit.operational_area_id,
'operatorCompanyId', visit.operator_company_id,
'actualStartedAt', visit.actual_started_at
),
'assets', COALESCE((
@@ -1,10 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable()
export class F3FindingCatalogResolverService {
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly contextualCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
@@ -36,19 +40,28 @@ export class F3FindingCatalogResolverService {
enabled: true,
code: 'OTHER',
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
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
if (asset.typeCode.toLowerCase() === 'yacimiento') {
const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query);
return {
...contextual,
inventoryFamily: null,
catalogSource: 'ASSET_TYPE' as const,
configurationReason: contextual.configurationReason
?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.',
other,
};
}
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
@@ -59,7 +72,6 @@ export class F3FindingCatalogResolverService {
'mapping.inventory_family_id=$1::uuid',
'item.is_active=true',
'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL',
];
const params: unknown[] = [asset.familyId];
@@ -106,8 +118,9 @@ export class F3FindingCatalogResolverService {
name: asset.familyName,
level: asset.familyLevel,
},
catalogSource: 'INVENTORY_FAMILY' as const,
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,
items,
other,
@@ -153,13 +153,16 @@ export class InspectionFindingWorklistService {
workflowFilters.push(`finding.status = 'OPEN'`);
}
// Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se
// leen del contexto congelado de la Inspección que originó el Hallazgo, no
// del snapshot de compatibilidad del Inventario.
const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
LEFT JOIN assets area ON area.id = visit.operational_area_id
`;
const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
@@ -408,30 +408,24 @@ export class InspectionReportsService {
}
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
return `EXISTS (
SELECT 1
FROM inspection_act_assets context_filter_link
INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id
WHERE context_filter_link.act_id = act.id
AND context_filter_link.included = true
AND context_filter_asset.${column} = ${parameter}::uuid
)`;
// Reports are historical documents. Their Area/Company filters must use the
// parent Inspection snapshot, never the mutable/current Inventory context.
return `visit.${column} = ${parameter}::uuid`;
}
private contextSelect(): string {
return `
SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id',company.id,'code',company.code,'name',company.name
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id',area.id,'code',area.code,'name',area.name
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
LEFT JOIN assets company ON company.id=context_asset.operator_company_id
LEFT JOIN assets area ON area.id=context_asset.operational_area_id
WHERE context_link.act_id=act.id AND context_link.included=true
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
) END AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
) END AS areas
FROM inspection_visits context_visit
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
WHERE context_visit.id=act.visit_id
`;
}
@@ -66,9 +66,6 @@ interface LockedFindingRow {
assetId: string;
assetCode: string;
assetName: string;
companyId: string | null;
companyCode: string | null;
companyName: string | null;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
@@ -76,6 +73,12 @@ interface LockedFindingRow {
status: string;
}
interface PlannedOperatorRow {
companyId: string;
companyCode: string;
companyName: string;
}
@Injectable()
export class InspectionVerificationsService {
constructor(
@@ -142,8 +145,19 @@ export class InspectionVerificationsService {
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN LATERAL (
SELECT operator_company.id,operator_company.code,operator_company.name
FROM area_company_relations relation
INNER JOIN assets operator_company ON operator_company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND operator_company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
) company ON true
${activeVisitJoin}
`;
@@ -495,15 +509,11 @@ export class InspectionVerificationsService {
finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode",
asset.name AS "assetName",
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName",
area.id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName"
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
WHERE finding.id = ANY($1::uuid[])
FOR UPDATE OF finding
@@ -519,25 +529,51 @@ export class InspectionVerificationsService {
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
});
}
const missingContext = rows.find((row) => !row.companyId || !row.areaId);
if (missingContext) {
const missingArea = rows.find((row) => !row.areaId);
if (missingArea) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_REQUIRED',
message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`,
message: `${missingArea.code} necesita un Área física antes de planificar la visita`,
});
}
const companyId = rows[0]?.companyId;
const areaId = rows[0]?.areaId;
if (!companyId || !areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' });
if (!areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' });
}
if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) {
if (rows.some((row) => row.areaId !== areaId)) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_MIXED',
message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación',
message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación',
});
}
// A verification visit is a new operational act. Its operator is therefore
// the Company valid for the Area at the planned visit date, never the
// historical compatibility snapshot stored on the Inventory.
const [operator] = await manager.query(`
SELECT
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
AND company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
FOR SHARE OF relation
`, [areaId, plannedStartAt]) as PlannedOperatorRow[];
if (!operator) {
throw new ConflictException({
code: 'VERIFICATION_OPERATOR_NOT_ACTIVE',
message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación',
});
}
const companyId = operator.companyId;
const activeLinks = await manager.query(`
SELECT verification_link.finding_id AS "findingId", visit.code
FROM inspection_finding_verification_visits verification_link
@@ -552,7 +588,7 @@ export class InspectionVerificationsService {
});
}
const companyName = rows[0]?.companyName ?? 'Empresa';
const companyName = operator.companyName;
const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const findingCodes = rows.map((row) => row.code).join(', ');
@@ -637,6 +673,7 @@ export class InspectionVerificationsService {
areaId,
findingIds,
assetIds,
operatorSource: 'area_company_relations',
},
}, manager);
@@ -647,7 +684,7 @@ export class InspectionVerificationsService {
status: visit.status,
plannedStartAt: visit.plannedStartAt,
},
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
company: { id: companyId, code: operator.companyCode, name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
findingCount: findingIds.length,
assetCount: assetIds.length,
@@ -1,9 +1,18 @@
import { IsISO8601, IsUUID } from 'class-validator';
import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
export class CreateInspectionVisitDto {
@IsUUID('4')
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')
operatorCompanyId!: string;
@@ -0,0 +1,9 @@
import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto {
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
operatorCompanyId!: string;
}
@@ -1,9 +1,9 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
objective?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' })
scopeAssetId?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' })
operationalAreaId?: string | null;
@IsOptional()
@IsUUID('4')
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' })
operatorCompanyId?: string | null;
@IsOptional()
@@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService {
principal: AuthPrincipal,
) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.context.area.id;
const effectiveParentId = parentId ?? base.parent.id;
const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode
@@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.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';
type F4ChecklistClassification = {
findingId: string;
itemKind: InspectionChecklistItemKind;
referenceOn: string | null;
};
/**
* Capa activa F4 sobre el servicio de Inspecciones.
*
* Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica
* la clasificación técnica F4 del checklist. El contrato público de Inspección ya
* no expone título independiente ni fecha prevista de fin.
* El checklist persistido es una generación histórica append-only. F4 necesita
* mostrar la clasificación vigente de controles, pero una lectura nunca debe
* reescribir esa generación. La clasificación se deriva con SELECT y se aplica
* sólo a la vista devuelta por la API.
*/
@Injectable()
export class F4InspectionVisitsService extends InspectionVisitsService {
@@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
}
override async getById(id: string): Promise<InspectionVisitView> {
await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
const visit = await super.getById(id);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
override async create(
@@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
const created = await super.create(dto, principal, request);
await this.reclassifyCurrentChecklist(created.id);
return this.normalizeChecklist(await super.getById(created.id));
const visit = await super.create(dto, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
override async generateChecklist(
@@ -51,88 +60,106 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
await super.generateChecklist(id, principal, request);
await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
const visit = await super.generateChecklist(id, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit));
}
private normalizeChecklist<T>(visit: T): T {
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
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,
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
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;
}
private async reclassifyCurrentChecklist(visitId: string): Promise<void> {
await this.f4DataSource.transaction(async (manager) => {
const [visit] = await manager.query(`
SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt"
FROM inspection_visits
WHERE id=$1
FOR UPDATE
`, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>;
if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return;
/**
* Deriva el estado F4 actual de los ítems sin mutar la generación persistida.
* La fuente administrativa de empresa vive a nivel Acta; en este checklist de
* campo sólo importan próximos controles y verificaciones no resueltas.
*/
private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> {
if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit;
const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))];
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);
await manager.query(`
UPDATE inspection_visit_checklist_items item
SET
item_kind = CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $3::date
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.created_at DESC,
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30)
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.created_at DESC,
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
reference_on = CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.created_at DESC,
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN finding.next_control_on
ELSE NULL
END
FROM inspection_findings finding
WHERE item.visit_id=$1
AND item.generation_number=$2
AND finding.id=item.finding_id
`, [visitId, visit.generation, targetDate]);
});
const classifications = (await this.f4DataSource.query(`
SELECT
finding.id AS "findingId",
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $2::date
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.created_at DESC,
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30)
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.created_at DESC,
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END AS "itemKind",
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.created_at DESC,
verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN finding.next_control_on::text
ELSE NULL
END AS "referenceOn"
FROM inspection_findings finding
WHERE finding.id=ANY($1::uuid[])
`, [findingIds, targetDate])) as F4ChecklistClassification[];
const byFinding = new Map(classifications.map((item) => [item.findingId, item]));
visit.checklist = {
...visit.checklist,
items: visit.checklist.items.map((item) => {
const classification = byFinding.get(item.findingId);
return classification
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
: item;
}),
};
return visit;
}
}
@@ -129,6 +129,7 @@ export class FieldFindingsService {
visit.id AS "visitId",
visit.code AS "visitCode",
visit.status AS "visitStatus",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName",
@@ -138,8 +139,32 @@ export class FieldFindingsService {
asset.id AS "assetId",
asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "assetAreaId",
asset.operator_company_id AS "assetCompanyId",
(
asset.id=COALESCE(visit.scope_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=COALESCE(visit.scope_asset_id, visit.operational_area_id)
LIMIT 1
)
) AS "insideScope",
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 (
SELECT 1
FROM inspection_visit_members member
@@ -190,6 +215,7 @@ export class FieldFindingsService {
visitId: string;
visitCode: string;
visitStatus: string;
scopeAssetId: string | null;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
@@ -199,8 +225,8 @@ export class FieldFindingsService {
assetId: string | null;
assetTypeCode: string | null;
inventoryFamilyId: string | null;
assetAreaId: string | null;
assetCompanyId: string | null;
insideScope: boolean;
operatorRelationValid: boolean;
assigned: boolean;
selected: boolean;
captureRequired: boolean;
@@ -230,28 +256,35 @@ export class FieldFindingsService {
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) {
throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
message: 'Registro de Inventario no encontrado',
});
}
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
const assetTypeCode = String(row.assetTypeCode).toLowerCase();
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
throw new BadRequestException({
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
});
}
if (!row.inventoryFamilyId) {
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una 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.insideScope) {
throw new BadRequestException({
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 Yacimiento definido como alcance de esta inspección',
});
}
if (!row.selected) {
@@ -23,6 +23,7 @@ interface MobileVisitContext {
code: string;
status: string;
areaId: string;
scopeAssetId: string;
areaCode: string;
areaName: string;
companyId: string;
@@ -80,12 +81,12 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context);
// Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.areaId, visitId];
// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope.
const args: unknown[] = [context.scopeAssetId, visitId];
const conditions = [
"asset.information_status <> 'INACTIVE'",
`(
asset.operational_area_id = $1::uuid
asset.id = $1::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -198,7 +199,7 @@ export class FieldInventoryService {
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, false);
const effectiveParentId = parentId ?? context.areaId;
const effectiveParentId = parentId ?? context.scopeAssetId;
const parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(`
@@ -272,7 +273,7 @@ export class FieldInventoryService {
request: RequestWithContext,
) {
const context = await this.requireVisitContext(visitId, principal, true);
const parentId = dto.parentId ?? context.areaId;
const parentId = dto.parentId ?? context.scopeAssetId;
await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt);
@@ -412,6 +413,7 @@ export class FieldInventoryService {
visit.id,
visit.code,
visit.status,
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName",
@@ -485,6 +487,7 @@ export class FieldInventoryService {
private publicContext(context: MobileVisitContext) {
return {
inspection: { id: context.id, code: context.code, status: context.status },
scopeAssetId: context.scopeAssetId,
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: {
id: context.companyId,
@@ -503,7 +506,6 @@ export class FieldInventoryService {
asset.asset_type_id AS "typeId",
(
asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -512,16 +514,16 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
) AS "insideScope"
FROM assets asset
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [parentId, context.areaId])) as Array<{
`, [parentId, context.scopeAssetId])) as Array<{
id: string;
code: string;
name: string;
typeId: string;
insideArea: boolean;
insideScope: boolean;
}>;
if (!parent) {
@@ -530,10 +532,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe',
});
}
if (!parent.insideArea) {
if (!parent.insideScope) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área de la inspección',
message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección',
});
}
return parent;
@@ -545,7 +547,7 @@ export class FieldInventoryService {
asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
(
asset.operational_area_id=$2::uuid
asset.id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -554,15 +556,15 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
) AS "insideScope"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [assetId, context.areaId])) as Array<{
`, [assetId, context.scopeAssetId])) as Array<{
id: string;
eligibleTarget: boolean;
insideArea: boolean;
insideScope: boolean;
}>;
if (!asset) {
@@ -571,10 +573,10 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado',
});
}
if (!asset.insideArea) {
if (!asset.insideScope) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área de esta inspección',
message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección',
});
}
if (!asset.eligibleTarget) {
@@ -0,0 +1,249 @@
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. La ubicación territorial es Departamento → Área → Yacimiento.
* Empresa y Tipo de concesión pertenecen directamente al Yacimiento; por eso
* la Operadora elegida debe coincidir con el Yacimiento seleccionado.
*/
@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 yacimiento.id
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND yacimiento.operator_company_id=$3::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND yacimiento.concession_type_id IS NOT NULL
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, dto.operationalAreaId, dto.operatorCompanyId]) as Array<{ id: string }>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO',
message: 'La Empresa seleccionada no es la Empresa relacionada con el Yacimiento',
});
}
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]);
stage = 'checklist:insert';
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
visit_id,generation_number,finding_id,asset_id,item_kind,
reference_on,finding_status,finding_code,finding_title,severity
)
SELECT
$1::uuid,1,finding.id,finding.asset_id,
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
CASE WHEN finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END,
finding.status,finding.code,finding.title,finding.severity
FROM inspection_findings finding
INNER JOIN assets inventory ON inventory.id=finding.asset_id
WHERE inventory.operational_area_id=$2::uuid
AND finding.status<>'VOIDED'
AND EXISTS (
WITH RECURSIVE ancestors AS (
SELECT current_asset.id,current_asset.parent_id
FROM assets current_asset WHERE current_asset.id=finding.asset_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent INNER JOIN ancestors child ON child.parent_id=parent.id
)
SELECT 1 FROM ancestors WHERE id=$3::uuid LIMIT 1
)
ORDER BY finding.created_at,finding.id
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
stage = 'checklist:assets';
await manager.query(`
INSERT INTO inspection_visit_assets (
visit_id, asset_id, included, planning_source, added_by
)
SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
ON CONFLICT (visit_id,asset_id) DO UPDATE SET
included=true,
planning_source=CASE
WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'
ELSE 'AUTOMATIC'
END,
exclusion_reason=NULL,
excluded_by=NULL,
excluded_at=NULL,
added_by=EXCLUDED.added_by,
updated_at=CURRENT_TIMESTAMP
`, [visit.id, principal.userId]);
stage = 'checklist:asset-events';
await manager.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, reason, actor_user_id, metadata
)
SELECT DISTINCT
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
jsonb_build_object('generation',1,'source','AUTHORITATIVE_CREATE')
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
AND NOT EXISTS (
SELECT 1 FROM inspection_visit_asset_events event
WHERE event.visit_id=$1::uuid
AND event.asset_id=item.asset_id
AND event.event_type='AUTO_INCLUDED'
)
`, [visit.id, principal.userId]);
stage = 'audit:record';
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_CREATED,
entityType: 'inspection_visit',
entityId: visit.id,
afterData: {
id: visit.id,
code,
status: visit.status,
departmentHierarchy: 'Departamento → Área → Yacimiento',
operationalAreaId: dto.operationalAreaId,
scopeAssetId,
operatorCompanyId: dto.operatorCompanyId,
operatorSource: 'YACIMIENTO',
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(`inspection create failed at ${stage}: ${technical}`);
throw error;
}
}
}
@@ -0,0 +1,116 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPlanningHierarchyItem {
id: string;
code: string;
name: string;
}
@Injectable()
export class InspectionPlanningHierarchyService {
constructor(private readonly dataSource: DataSource) {}
async departments(): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
const data = await this.dataSource.query(`
SELECT department.id, department.code, department.name
FROM assets department
INNER JOIN asset_types type ON type.id=department.asset_type_id
WHERE lower(type.code)='departamento'
AND type.is_active=true
AND department.information_status<>'INACTIVE'
AND EXISTS (
SELECT 1
FROM assets area
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE area.parent_id=department.id
AND lower(area_type.code)='area'
AND area.information_status<>'INACTIVE'
)
ORDER BY department.name, department.code
`) as InspectionPlanningHierarchyItem[];
return { data };
}
async areasForDepartment(departmentId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(departmentId, 'departamento', 'El Departamento seleccionado no es válido');
const data = await this.dataSource.query(`
SELECT area.id, area.code, area.name
FROM assets area
INNER JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.parent_id=$1::uuid
AND lower(type.code)='area'
AND type.is_active=true
AND area.information_status<>'INACTIVE'
ORDER BY area.name, area.code
`, [departmentId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async yacimientosForArea(areaId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
const data = await this.dataSource.query(`
SELECT yacimiento.id, yacimiento.code, yacimiento.name
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=$1::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
ORDER BY yacimiento.name, yacimiento.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async operatorsForArea(
areaId: string,
at?: string,
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
if (at) {
const effectiveAt = new Date(at);
if (!Number.isFinite(effectiveAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_PLANNING_DATE_INVALID',
message: 'La fecha de planificación no es válida',
});
}
}
// Modelo autoritativo: la Empresa pertenece al Yacimiento. Para mantener el
// selector de planificación por Área, se proyectan las Empresas de todos los
// Yacimientos activos que pertenecen a esa Área.
const data = await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE yacimiento.parent_id=$1::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY name, company.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',
});
}
}
@@ -0,0 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
@Controller('inspection-visits')
export class InspectionPreventiveCandidatesController {
constructor(private readonly candidates: InspectionPreventiveCandidatesService) {}
@Get(':id/preventive-candidates')
@RequirePermissions('inspections.read')
list(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query('search') search?: string,
) {
return this.candidates.list(id, search);
}
}
@@ -0,0 +1,77 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
@Injectable()
export class InspectionPreventiveCandidatesService {
constructor(private readonly dataSource: DataSource) {}
async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> {
const [context] = (await this.dataSource.query(`
SELECT
visit.operational_area_id AS "operationalAreaId",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId"
FROM inspection_visits visit
WHERE visit.id = $1::uuid
`, [visitId])) as Array<{
operationalAreaId: string | null;
scopeAssetId: string | null;
}>;
if (!context) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] };
const term = search?.trim().slice(0, 200) ?? '';
const rows = (await this.dataSource.query(`
WITH RECURSIVE scope_tree AS (
SELECT root.id
FROM assets root
WHERE root.id = $2::uuid
UNION ALL
SELECT child.id
FROM assets child
INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id
)
SELECT
asset.id,
asset.code,
asset.name,
asset_type.name AS "typeName"
FROM scope_tree
INNER JOIN assets asset ON asset.id = scope_tree.id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id <> $2::uuid
AND asset.operational_area_id = $3::uuid
AND asset.information_status <> 'INACTIVE'
AND lower(asset_type.code) IN ('instalacion', 'subinstalacion')
AND NOT EXISTS (
SELECT 1
FROM inspection_visit_assets linked
WHERE linked.visit_id = $1::uuid
AND linked.asset_id = asset.id
)
AND (
$4::text = ''
OR asset.code ILIKE '%' || $4::text || '%'
OR asset.name ILIKE '%' || $4::text || '%'
OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%'
)
ORDER BY asset_type.name, asset.name, asset.code
LIMIT 100
`, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[];
return { data: rows };
}
}
@@ -295,11 +295,13 @@ export class InspectionVisitLifecycleService {
}
}
// El contexto operativo de una Inspección es Área + Operadora vigente. El
// Yacimiento pertenece al alcance físico y nunca reemplaza al Área raíz.
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
throw new ConflictException({
code: 'INSPECTION_PLANNING_INCOMPLETE',
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
message: 'Definí Área, Operadora, Inspector responsable y fecha de inicio antes de planificar',
});
}
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
@@ -320,7 +322,7 @@ export class InspectionVisitLifecycleService {
if (!relation) {
throw new ConflictException({
code: 'INSPECTION_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área',
});
}
const [inspector] = await manager.query(`
@@ -13,14 +13,18 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsService } from './inspection-visits.service';
@@ -29,6 +33,8 @@ export class InspectionVisitsController {
constructor(
private readonly visits: InspectionVisitsService,
private readonly lifecycle: InspectionVisitLifecycleService,
private readonly planningHierarchy: InspectionPlanningHierarchyService,
private readonly planningCreate: InspectionPlanningCreateService,
) {}
@Get()
@@ -43,6 +49,29 @@ export class InspectionVisitsController {
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')
@RequirePermissions('inspections.read')
planningAreas() {
@@ -53,10 +82,48 @@ export class InspectionVisitsController {
@RequirePermissions('inspections.read')
planningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@Query('at') at?: string,
) {
return this.planningHierarchy.operatorsForArea(areaId, at);
}
@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')
@RequirePermissions('inspections.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -70,7 +137,7 @@ export class InspectionVisitsController {
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.visits.create(dto, principal, request);
return this.planningCreate.create(dto, principal, request);
}
@Patch(':id')
@@ -8,15 +8,27 @@ import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
controllers: [
InspectionVisitsController,
InspectionPreventiveCandidatesController,
FieldInventoryController,
FieldFindingsController,
],
providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionPreventiveCandidatesService,
InspectionVisitLifecycleService,
FieldInventoryService,
F3FieldInventoryStructureService,
@@ -23,6 +23,7 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
export interface InspectionPerson {
id: string;
@@ -312,7 +313,7 @@ export class InspectionVisitsService {
}
const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);
await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt);
@@ -382,29 +383,24 @@ export class InspectionVisitsService {
? visit.operatorCompanyId
: dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = contextTouched
? nextAreaId
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);
await this.requireAsset(manager, nextScope);
if (contextTouched && nextAreaId && nextCompanyId) {
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
} else if (dto.scopeAssetId !== undefined) {
if (nextAreaId && nextScope) {
await validateInspectionPlanningScope(manager, nextAreaId, nextScope);
await this.assertCurrentAssetsInScope(manager, id, nextScope);
}
if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) {
visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId;
visit.scopeAssetId = nextAreaId;
} else if (dto.scopeAssetId !== undefined) {
}
if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId;
}
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions;
if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id);
@@ -463,11 +459,10 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
});
}
await this.assertAssetsMatchContext(
await this.assertAssetsInScope(
manager,
additions,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.scopeAssetId ?? visit.operationalAreaId,
);
for (const assetId of additions) {
await manager.query(`
@@ -629,11 +624,10 @@ export class InspectionVisitsService {
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
}
await this.assertAssetsMatchContext(
await this.assertAssetsInScope(
manager,
[assetId],
visit.operationalAreaId,
visit.operatorCompanyId,
visit.scopeAssetId ?? visit.operationalAreaId,
);
await manager.query(`
UPDATE inspection_visit_assets
@@ -920,6 +914,7 @@ export class InspectionVisitsService {
manager: EntityManager,
operationalAreaId: string | null,
operatorCompanyId: string | null,
effectiveAt: Date | null = null,
): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) {
@@ -938,8 +933,8 @@ export class InspectionVisitsService {
WHERE relation.area_id = area.id
AND relation.company_id = company.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 relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
) AS "activeOperatorRelation"
FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -951,7 +946,7 @@ export class InspectionVisitsService {
AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId])) as Array<{
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{
areaId: string;
companyId: string;
activeOperatorRelation: boolean;
@@ -970,44 +965,6 @@ export class InspectionVisitsService {
}
}
private async assertAssetsMatchContext(
manager: EntityManager,
assetIds: string[],
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM assets asset
WHERE asset.id = ANY($1::uuid[])
AND (
asset.operational_area_id IS DISTINCT FROM $2::uuid
OR asset.operator_company_id IS DISTINCT FROM $3::uuid
)
`, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT',
message: 'Uno o más registros no pertenecen al Área y Operadora de la visita',
});
}
}
private async assertCurrentAssetsMatchContext(
manager: EntityManager,
visitId: string,
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
await this.assertAssetsMatchContext(
manager,
await this.activeAssetIds(manager, visitId),
operationalAreaId,
operatorCompanyId,
);
}
private async appendAssetEvent(
manager: EntityManager,
visitId: string,
@@ -1041,7 +998,7 @@ export class InspectionVisitsService {
message: 'Indicá la fecha prevista antes de generar el checklist',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt);
const generation = visit.checklistGeneration + 1;
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
@@ -1106,14 +1063,25 @@ export class InspectionVisitsService {
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED'
AND (
asset.id=$4::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=$4::uuid LIMIT 1
)
)
ORDER BY finding.created_at, finding.id
`, [
visit.id,
generation,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.scopeAssetId ?? visit.operationalAreaId,
visit.plannedStartAt.toISOString().slice(0, 10),
]);
@@ -1295,7 +1263,12 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
await this.validatePlanningContext(
manager,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt,
);
if (!visit.scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED',
@@ -1350,13 +1323,10 @@ export class InspectionVisitsService {
message: 'El equipo debe incluir al inspector responsable',
});
}
// F6.1: Inventory membership is physical (Yacimiento scope).
// Company is temporal Area context and must never be reconstructed from
// assets.operator_company_id, which is only a creation/historical snapshot.
await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId);
await this.assertCurrentAssetsMatchContext(
manager,
visit.id,
visit.operationalAreaId,
visit.operatorCompanyId,
);
}
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
@@ -0,0 +1 @@
export default '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';
@@ -0,0 +1 @@
export default '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';
@@ -0,0 +1 @@
export default '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';
@@ -0,0 +1 @@
export default 'yUcRMjmcTgBT37B4nq7FkVMEZE7HK6n4vvgCjr4V8Szb5Ifc1PcTFi270pwOLjpu6N+Oe1F5muxMfafg8ujPny0NQ4hVKh/nahfgeXqf7Q9lv6qH01BT39T3hjyp8OdrubJWbc9byLPxup0VFnZaBpfEWZk8iDut+g68bYM3+TqpPFvlq8rr3KBFp+NYNHgzYnrr3ZaM8+RjsiMLLXkko/tQvOZHGpTmcpxkcA4p+k1F+ngy+Ion+2KkfzwqVrXaRfciv0vvE7Oh/W0RjSvmkZZFjdPmRVV5viPz6LdfEuJ9LjFeDaOK76eLZTP1PWWX4tYFWSmb8qa1vhHs8MWrKaKFD8XpZuqbuJLQpofSkSmETMb01faDu7tkXX75paF96lEAFKu6y++Oa5XT6ud4t+sNGcmFwVUsvfjrIMBNfdfTS+E9PaR6IVKCIxfp7qHoD/tQN4LV3qQL0pmUaBfrRHKF1E6jRSBXPFz8Esc19d1Db8WXzU7y/okc0vK7Eaa+Mehyl2z3n/Ldg8pqJVRyVQSLD8mj8DjWGfmBxdu/OHCiCWK6zsnl28rT+DobR4x8sWSKiPkJRrjOB3l4oAgbbZS70nH5uFH3HzWsktCfQgHUdTdy45N4CVPffHO2yw+S4/4oNo+98fD0j88kk5K8KiGX2+wUnS9iA0J1C4Pd1DfNvAkv4lG8PJ3kM7KAp/PrcBIvxlNjSsZPLEzGy+lwNRbZmoJKjoM6akRlEi2NZ0Q6octKo1lZwHcORi3ZWUVk2d7El/Onv6xGZH+9QNLZq31CU98QMxyF83FoLKYTOUWvtrY80VSsA7VP5WI6G52MwPORNLUN5YvBeLKRWsYqsZtT5eurBXGqozP1fSQlclHNoZgOal9I4Z4sjEk0jBbhXLk+okZlnmN7ixWxT6yyFOFVYjANUUC1+aLk8wxX4eUpBXeqyZaztGpvRe2wyjLowm2Lm9XSuJz+Xl4EX2cIm/ouhvPolqaOX2tVRHA6JHdezhcqERt9TL+8fFMOJU+lkr1UIjkNAHq/WWabEum5nNJiGqVMm/q2gfpgpqlvBni1Em7OQkRIo5lwisv/pKFlvt3Ud/ir+CU1r0Rt3hcOSb9KVBFWWSnXn5T4TyHbhVtUSKTYYcgFJOmYrYo1K95qcRXekr6LhXgjNpGw2EPGRYmJmIN4Yep785FxPJ5NS5rFyWAumS/49zqpqe+/95wyemesLopsE2nhO/IUSNVisX8JZ+Un4zW8M55O4sI1NaJitxM7pny/Y9C8okJ7Th5cGf6frWSGK1GoS/J/nrNfND/NF81vQ+0wpeg8Gt6UCU2RNqeBs5tiTN2hVrvjdV0FLaBi8MVj2jSHQvi1wRm16d1pF9TeN1EiVGFxnWdRcvtqTg7981b9kv581hF6dEnSlUBVnS+FtLwssbpZcXUTmtGEYjlKgZOHGG9eZZF2hhlJymntpFNTiXsVFe/PR+sLTPTjcLRayIkNteHcyQqRbZ8PvxZ34z9sMjJ/JBtDiYE9JNunf7zYM4/kcDw8/U1YHftPyed8J2whYXhsjT0Z/sVN3cIdkewcJSRGhmAqenkRTLI9JhtT3wVOBGXnalBW7ej2vP8335FUImNSduaUKpIpz2TuXrKPCyHK1R3Z8/Rpppk4ViYizWR8oGcrkvNLlTeKfIzCZ8U9sVTsfpcrZSpZBC7i6Sw+5UjL56SdZTwTyTwyX2RFULuexZWjUAmQSWv0mkzW3hGVKyleflPuRv0iaL/e569XjwE61kDHKnQ2SOeAdC5Ix0E6D6TzQboApLMGKKGFEqIssVCeWChTLJQrFsoWC+WLhTLGQjnDUM4wlDMM5Qxr4owNapUNapUNapUNapUNapUNapUNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapUDapUDapUDapUDapUDapUDapUDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapULapULapULapULapULapULapULapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLahUHtYqDWsVBreKgVnFQqzioVRzUKo5qFUe1iqNaxVGt4qhWcVSrOKpVHNUqjmoVR7WKo1rFUa3iqFZxVKs89GWqhMxBCV2UkKOEHkroo4ToW9sDlNBCCZtY6KOc8VHO+ChnfJQzPsoZH+WMj3LGRznjo5zxUc4EKGcClDMBypkA5UyAciZAOROgnAlQzgQoZwKUM6KSCGNNjdK2YUoHpnRhSg5TejBlEyctC14lC14lC14lC14lC14lC14lC14lOH5ao2xeJQavEoNXicGrxOBVYvAqwfGwGmXzKtnwKtnwKtnwKtnwKtnwKsHxjRpl8yo58Co58Co58Co58Co58CrB/mqNsnmVXHiVXHiVXHiVXHiVXHiVYP+jRtm8ShxeJQ6vEodXicOrxOFVgh2bGqUNUzoDmNKCKRlMacOUDkzpwpSN3Kza0Y4HU/owZYBSuo08CtCghAVb3TVKtzFDOoBTzQN09hpl8+wWmrS34Mkt1JGoUboMprRhSgemdGFK+N1dD6ZsXKVa3UeAUvIBTGnBlI08QjPzDE3NMzQ3z9DkPEOz8wxNzzM0P8/gBD2DM/QMTtEzOEfP4CQ9g7P0DE7TMzhPz+BEPYM9Ewan6hmcq2dwsp7B2XqGpusZmq9naMKeoRl7hqbsGZqzZ2jSnsFZewan7Rmct2dw4p7BmXsGp+4ZnLtncPKewdl7Bru3DM7fMziBz+AMPoNT+AzN4TM0ic/QLD5D0/gMzeMzNJHP0Ew+g1P5DM7lMziZz+BsPoPT+QzO5zM4oc/gjD6DU/oMjpEwOKnP4Kw+g9P6DM7rMzSxz9DMPkNT+wzN7TM0uc/Q7D5D0/sMzu8zOMHP4Aw/g1P8DM7xMzjJz+AsP4PT/AzO8zM40MbgTD+DU/0MzvUzONnP';
@@ -0,0 +1 @@
export default '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';
@@ -0,0 +1,41 @@
import { inflateSync } from 'node:zlib';
import payload1 from './authoritative-inventory-model.payload-1';
import payload2 from './authoritative-inventory-model.payload-2';
import payload3 from './authoritative-inventory-model.payload-3';
import payload4 from './authoritative-inventory-model.payload-4';
import payload5 from './authoritative-inventory-model.payload-5';
export const AUTHORITATIVE_INVENTORY_MODEL_SOURCES = {
territory: {
file: 'modelo_yacimientos.sql',
sha256: '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a',
},
inventory: {
file: 'modelo_relacional instalaciones y sub.sql',
sha256: 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49',
},
} as const;
export type AuthoritativeInventoryModel = {
departments: Array<{ id: number; nombre: string }>;
companies: Array<{ id: number; nombre: string }>;
concessionTypes: Array<{ id: number; nombre: string }>;
areas: Array<{ id: number; departamento_id: number; nombre: string }>;
fields: Array<{
id: number;
nombre: string;
area_id: number;
empresa_id: number;
tipo_concesion_id: number;
}>;
installationFamilies: Array<{ id: number; nombre: string }>;
subinstallationFamilies: Array<{ id: number; instalacion_id: number; nombre: string }>;
findings: Array<{ id: number; nombre: string }>;
subinstallationFindings: Array<{ subinstalacion_id: number; hallazgo_id: number }>;
installationFindings: Array<{ instalacion_id: number; hallazgo_id: number }>;
};
const payload = payload1 + payload2 + payload3 + payload4 + payload5;
export const AUTHORITATIVE_INVENTORY_MODEL: AuthoritativeInventoryModel =
JSON.parse(inflateSync(Buffer.from(payload, 'base64')).toString('utf8')) as AuthoritativeInventoryModel;
@@ -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 = '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';
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_PHASE = 'F5';
export const API_VERSION = '0.29.0-1';
export const API_PHASE = 'F6.1';
@@ -33,13 +33,15 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
});
test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
test('D5.3/F6.1 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
assert.match(service, /Ending an operator relation never moves, rewrites or blocks existing Inventory/);
assert.match(service, /operatorSnapshotPreserved:true/);
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.doesNotMatch(service, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
});
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AUTHORITATIVE_INVENTORY_MODEL as model,
AUTHORITATIVE_INVENTORY_MODEL_SOURCES as sources,
} from '../../src/reference-data/authoritative-inventory-model';
test('authoritative SQL sources keep their exact cardinalities', () => {
assert.equal(sources.territory.file, 'modelo_yacimientos.sql');
assert.equal(sources.territory.sha256, '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a');
assert.equal(sources.inventory.file, 'modelo_relacional instalaciones y sub.sql');
assert.equal(sources.inventory.sha256, 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49');
assert.equal(model.departments.length, 7);
assert.equal(model.companies.length, 13);
assert.equal(model.concessionTypes.length, 2);
assert.equal(model.areas.length, 64);
assert.equal(model.fields.length, 230);
assert.equal(model.installationFamilies.length, 14);
assert.equal(model.subinstallationFamilies.length, 109);
assert.equal(model.findings.length, 181);
assert.equal(model.installationFindings.length, 48);
assert.equal(model.subinstallationFindings.length, 880);
});
test('Area belongs only to Departamento while Yacimiento owns Area Empresa and concession', () => {
for (const area of model.areas) {
assert.ok(model.departments.some((department) => department.id === area.departamento_id));
assert.deepEqual(Object.keys(area).sort(), ['departamento_id', 'id', 'nombre']);
}
for (const field of model.fields) {
assert.ok(model.areas.some((area) => area.id === field.area_id));
assert.ok(model.companies.some((company) => company.id === field.empresa_id));
assert.ok(model.concessionTypes.some((type) => type.id === field.tipo_concesion_id));
assert.deepEqual(Object.keys(field).sort(), ['area_id', 'empresa_id', 'id', 'nombre', 'tipo_concesion_id']);
}
});
test('Subinstalación classifications belong to one installation classification and every mapping resolves', () => {
for (const subinstallation of model.subinstallationFamilies) {
assert.ok(model.installationFamilies.some((installation) => installation.id === subinstallation.instalacion_id));
}
for (const mapping of model.installationFindings) {
assert.ok(model.installationFamilies.some((installation) => installation.id === mapping.instalacion_id));
assert.ok(model.findings.some((finding) => finding.id === mapping.hallazgo_id));
}
for (const mapping of model.subinstallationFindings) {
assert.ok(model.subinstallationFamilies.some((subinstallation) => subinstallation.id === mapping.subinstalacion_id));
assert.ok(model.findings.some((finding) => finding.id === mapping.hallazgo_id));
}
});
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F5 release', () => {
assert.equal(API_PHASE, 'F5');
assert.equal(API_VERSION, '0.26.0-1');
test('health metadata reports the current F6.1 release', () => {
assert.equal(API_PHASE, 'F6.1');
assert.equal(API_VERSION, '0.29.0-1');
});
@@ -0,0 +1,92 @@
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('Authoritative manual creation keeps Area physical and stores Company plus concession on Yacimiento', () => {
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
assert.match(structure, /operator_company_id,concession_type_id,inventory_family_id/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/);
assert.match(structure, /INVENTORY_YACIMIENTO_COMPANY_REQUIRED/);
assert.match(structure, /INVENTORY_YACIMIENTO_CONCESSION_REQUIRED/);
assert.match(structure, /asset_context_history/);
assert.match(dto, /operatorCompanyId\?: string \| null/);
assert.match(dto, /concessionTypeId\?: string \| null/);
});
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('Authoritative Configuration presents exact physical relationships and technical families', () => {
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(configPage, /El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento/);
assert.match(configPage, /Tipo de concesión/);
assert.match(configPage, /Empresa relacionada/);
assert.match(configPage, /Yacimiento → Instalación/);
assert.match(configPage, /Instalación → Subinstalación/);
assert.match(configPage, /installationFamilies\.map/);
assert.match(configPage, /subinstallationFamilies\.map/);
assert.match(configPage, /Hallazgos asociados/);
});
test('Authoritative Web creation exposes Departamento as root and Yacimiento as operational owner', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Empresa relacionada/);
assert.match(createPage, /Tipo de concesión/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /<strong>Yacimiento<\/strong>/);
});
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F5 Android test cut targets production API and has a distinct installable debug version', () => {
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 20/);
assert.match(gradle, /versionName = "0\.13\.0"/);
assert.match(gradle, /versionCode = 25/);
assert.match(gradle, /versionName = "0\.16\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
@@ -113,7 +113,7 @@ test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM
);
});
test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
test('F5/F6.1 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
@@ -129,7 +129,8 @@ test('F5 source contracts keep Empresa out of physical ownership and preserve se
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
assert.match(fieldSource, /Área de esta inspección/);
assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/);
assert.match(fieldSource, /scopeAssetId/);
assert.match(fieldSource, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
@@ -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,80 @@
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('Authoritative WEB creation freezes Area and validates Company against selected Yacimiento', () => {
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, /yacimiento\.operator_company_id=\$3::uuid/);
assert.match(create, /yacimiento\.concession_type_id IS NOT NULL/);
assert.match(create, /INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('Authoritative WEB inspection creation uses Yacimiento context and not descendant Inventory snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /yacimiento\.operator_company_id/);
assert.match(create, /operatorSource: 'YACIMIENTO'/);
assert.doesNotMatch(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,80 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.1 field Inventory is bounded by the frozen Yacimiento scope', () => {
const source = api('src/inspection-visits/field-inventory.service.ts');
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(source, /const effectiveParentId = parentId \?\? context\.scopeAssetId/);
assert.match(source, /const parentId = dto\.parentId \?\? context\.scopeAssetId/);
assert.match(source, /\[parentId, context\.scopeAssetId\]/);
assert.match(source, /\[assetId, context\.scopeAssetId\]/);
assert.match(source, /AS "insideScope"/);
assert.doesNotMatch(source, /insideArea/);
});
test('F6.1 field Findings reject Inventory outside the frozen Yacimiento scope', () => {
const source = api('src/inspection-visits/field-findings.service.ts');
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(source, /AS "insideScope"/);
assert.match(source, /if \(!row\.insideScope\)/);
assert.match(source, /Yacimiento definido como alcance/);
assert.doesNotMatch(source, /AS "insideArea"/);
});
test('F6.1 planning preserves Yacimiento and never validates descendant Inventory ownership', () => {
const source = api('src/inspection-visits/inspection-visits.service.ts');
assert.match(source, /const nextScope = dto\.scopeAssetId === undefined \? visit\.scopeAssetId : dto\.scopeAssetId/);
assert.match(source, /validateInspectionPlanningScope\(manager, nextAreaId, nextScope\)/);
assert.match(source, /visit\.scopeAssetId \?\? visit\.operationalAreaId/);
assert.match(source, /visit\.plannedStartAt,/);
assert.doesNotMatch(source, /visit\.scopeAssetId = nextAreaId/);
assert.doesNotMatch(source, /assertAssetsMatchContext/);
assert.doesNotMatch(source, /assertCurrentAssetsMatchContext/);
});
test('F6.1 creation selects actionable checklist Inventory automatically', () => {
const source = api('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(source, /INSERT INTO inspection_visit_assets/);
assert.match(source, /COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL/);
assert.match(source, /'AUTO_INCLUDED'/);
});
test('Authoritative operator choices are projected from Yacimientos of the selected Area', () => {
const hierarchy = api('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = api('src/inspection-visits/inspection-visits.controller.ts');
const createPage = web('src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /operatorsForArea\([\s\S]*at\?: string/);
assert.match(hierarchy, /FROM assets yacimiento/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(hierarchy, /yacimiento\.operator_company_id/);
assert.match(hierarchy, /SELECT DISTINCT company\.id/);
assert.doesNotMatch(hierarchy, /FROM area_company_relations relation/);
assert.match(controller, /@Query\('at'\) at\?: string/);
assert.match(createPage, /operators\?at=\$\{at\}/);
assert.match(createPage, /\[areaId, plannedStartAt\]/);
});
test('F6.1 WEB keeps Area and Yacimiento as separate concepts and freezes context after creation', () => {
const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /<span>Operadora<\/span>/);
assert.match(source, /visit\.operationalArea\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.scopeAsset\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.operatorCompany\.name[\s\S]{0,160}readOnly/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}scopeAssetId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operatorCompanyId:/);
});
@@ -0,0 +1,101 @@
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('Authoritative model stores Company on Yacimiento and propagates it to physical descendants', () => {
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(migration, /Yacimiento requiere Empresa relacionada/);
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
assert.match(migration, /NEW\.operator_company_id:=parent_company/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/);
assert.match(structure, /parent\?\.operatorCompanyId/);
assert.match(structure, /ensureCompatibilityProjection/);
});
test('Authoritative Yacimiento relationships cannot be mutated through generic Inventory edits', () => {
const updateDto = source('src/asset-master/dto/update-asset.dto.ts');
assert.match(updateDto, /@IsEmpty/);
assert.match(updateDto, /La Empresa relacionada se administra en el Yacimiento/);
assert.match(updateDto, /El Tipo de concesión se administra en el Yacimiento/);
});
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/);
}
});
test('Compatibility Area relation history remains available for legacy temporal queries', () => {
const temporal = source('src/asset-master/asset-temporal.service.ts');
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
assert.match(temporal, /FROM area_company_relations relation/);
assert.match(migration, /Derivado automáticamente de Yacimientos del modelo autoritativo/);
assert.match(migration, /TRUNCATE TABLE area_company_relations CASCADE/);
});
test('F6.1 verification planning can consume the Area compatibility projection', () => {
const verification = source('src/inspection-verifications/inspection-verifications.service.ts');
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
assert.match(verification, /FROM area_company_relations relation/);
assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/);
assert.match(migration, /relation_role,source_document_id,valid_from,start_reason/);
});
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.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/);
});
test('F6.1 WEB Inventory searches use physical scope and never the operator snapshot filter', () => {
const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const preventiveApi = source('../web-v2/src/features/inspections/preventiveCandidatesApi.ts');
const preventiveService = source('src/inspection-visits/inspection-preventive-candidates.service.ts');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.match(preventiveApi, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
assert.match(preventiveService, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.doesNotMatch(inspectionEditor, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
for (const page of [fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
}
});
test('WEB creation captures Company and concession only while creating a Yacimiento', () => {
const page = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(page, /requiresYacimientoContext = kind === 'YACIMIENTO'/);
assert.match(page, /Empresa relacionada/);
assert.match(page, /Tipo de concesión/);
assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/);
assert.match(page, /concessionTypeId:concessionTypeId \|\| null/);
assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/);
});
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
test('F6.1 DB check allows Área-only physical context and rejects Company without Área', () => {
const migration=readFileSync(
resolve(process.cwd(),'src/database/migrations/1790094500000-f6-1-area-owned-operational-context-check.ts'),
'utf8',
);
assert.match(migration,/CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.doesNotMatch(migration,/operational_area_id IS NULL OR operator_company_id IS NULL/);
});
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 planning preloads the local start time and resolves Companies projected by Yacimientos', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
assert.match(page, /function currentLocalDateTime\(\)/);
assert.match(page, /useState\(currentLocalDateTime\)/);
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
assert.match(page, /response\.data\.length === 1/);
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
assert.match(page, /Sin Operadora vigente para esa fecha/);
assert.match(hierarchy, /FROM assets yacimiento/);
assert.match(hierarchy, /yacimiento\.operator_company_id/);
assert.match(hierarchy, /SELECT DISTINCT company\.id/);
});
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
assert.match(editor, /showCancelForm/);
assert.match(editor, /cancelReason/);
assert.match(editor, /<textarea/);
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
assert.match(editor, /maxLength=\{500\}/);
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
assert.match(editor, /Confirmar cancelación/);
assert.match(controller, /@Post\(':id\/cancel'\)/);
assert.match(dto, /@MinLength\(10\)/);
assert.match(dto, /@MaxLength\(4000\)/);
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
test('F6.1 presentation metadata keeps the visible WEB version aligned with package metadata', () => {
const pkg = JSON.parse(web('package.json')) as { version: string };
const version = web('src/config/version.ts');
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
assert.equal(visibleVersion, pkg.version);
assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo ÁreaOperadora consolidado'/);
});
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
const app = web('src/app/App.tsx');
const layout = web('src/layout/AppLayout.tsx');
assert.doesNotMatch(app, /relevamientos/i);
assert.doesNotMatch(layout, /relevamientos/i);
assert.match(layout, /label: 'Inspecciones'/);
assert.match(layout, /label: 'Inventarios'/);
});
test('F6.1 presentation keeps the complete Inspector profile and documentary-copy explanation visible', () => {
const user = web('src/pages/UserDetailPage.tsx');
const delivery = api('src/inspection-reports/inspection-document-delivery.service.ts');
for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) {
assert.match(user, new RegExp(`name="${field}"`));
}
assert.match(user, /email es obligatorio para un Inspector/i);
assert.match(user, /la documentación se enviará también/i);
assert.match(delivery, /recipientKind:'INSPECTOR'/);
assert.match(delivery, /documentKind:'ACT_PDF'/);
assert.match(delivery, /documentKind:'REPORT_WORD'/);
});
test('F6.1 presentation keeps Android start, Otro and chronological merge flows wired to the canonical API', () => {
const mobileApi = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
const overview = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const viewModel = android('app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(mobileApi, /inspection-visits\/\{id\}\/start/);
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
assert.match(overview, /"Iniciar inspección"/);
assert.match(overview, /Otro \/ no catalogado/);
assert.match(viewModel, /repository\.startVisit\(id\)/);
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
});
test('F6.1 presentation keeps catalog merge append-only for emitted findings', () => {
const merge = api('src/inspection-findings/finding-catalog-merge.service.ts');
const resolver = api('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(merge, /los Hallazgos emitidos nunca se reescriben/);
assert.match(merge, /historicalFindingsRewritten: false/);
assert.match(merge, /historyPolicy: 'EMITTED_FINDINGS_PRESERVED'/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import { F61_PRESENTATION_TERRITORY_SOURCE } from '../../src/reference-data/f6-1-presentation-territory-source';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
test('F6.1 presentation territory snapshot matches the approved spreadsheet cardinalities', () => {
const source = F61_PRESENTATION_TERRITORY_SOURCE;
const rows = source.rows;
assert.equal(source.file, 'Tablas de yacimiento y areas(1).xlsx');
assert.equal(source.sheet, 'cr26e_tabla1');
assert.equal(source.sha256, 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb');
assert.equal(rows.length, 230);
const areas = new Map<string, typeof rows[number][]>();
for (const row of rows) {
const id = key(row.area);
const current = areas.get(id) ?? [];
current.push(row);
areas.set(id, current);
}
const pairs = new Set(rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
const departments = new Set(rows.map((row) => key(row.departamento)));
const companies = new Set(
rows
.map((row) => row.empresaOperadora)
.filter((name) => key(name) !== key('Sin Empresa Operadora'))
.map(key),
);
const areasWithoutOperator = [...areas.values()]
.filter((sameArea) => key(sameArea[0]!.empresaOperadora) === key('Sin Empresa Operadora'));
const concessions = new Set(rows.map((row) => row.tipoConcesion));
assert.equal(areas.size, 64);
assert.equal(pairs.size, 230);
assert.equal(departments.size, 7);
assert.equal(companies.size, 12);
assert.equal(areasWithoutOperator.length, 17);
assert.deepEqual([...concessions].sort((a, b) => a.localeCompare(b, 'es')), ['Exploración', 'Explotación']);
for (const [area, sameArea] of areas) {
assert.equal(new Set(sameArea.map((row) => key(row.departamento))).size, 1, `${area}: Departamento conflictivo`);
assert.equal(new Set(sameArea.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión conflictiva`);
assert.equal(new Set(sameArea.map((row) => key(row.empresaOperadora))).size, 1, `${area}: Operadora conflictiva`);
}
});
test('F6.1 presentation reset preserves system masters and enforces the physical hierarchy', () => {
const migration = readFileSync(
resolve(process.cwd(), 'src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts'),
'utf8',
);
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
assert.match(migration, /TRUNCATE TABLE audit_events/);
assert.doesNotMatch(migration, /TRUNCATE TABLE users/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE roles/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE permissions/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/i);
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/i);
assert.match(migration, /area_company_relations/);
assert.match(migration, /area_legal_rights/);
assert.match(migration, /key\(row\.empresaOperadora\)!==key\(NO_OPERATOR\)/);
for (const contract of [
/total_assets:313/,
/companies:12/,
/departments:7/,
/areas:64/,
/yacimientos:230/,
/legal_rights:64/,
/operators:47/,
/areas_without_operator:17/,
]) assert.match(migration, contract);
});
@@ -0,0 +1,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'/);
});

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