Compare commits

..
Author SHA1 Message Date
admin 9f582be25e chore(android): publish F6 APK artifact identity 2026-09-09 11:12:21 -03:00
admin 712cb167d3 fix(f6): separate technical and finding permissions 2026-09-09 11:09:29 -03:00
admin 3ea6ea772e feat(f6): show exact classification findings in asset dossier 2026-09-09 11:06:14 -03:00
admin 4ae339cc5c test(f6): advance Android installable cut contract 2026-09-09 11:04:38 -03:00
admin 560734c0b8 test(f6): advance health metadata contract 2026-09-09 11:04:21 -03:00
admin a3f642d13f chore(android): bump F6 debug version 2026-09-09 10:58:20 -03:00
admin 17fb0d2d15 chore(f6): bump WEB package version 2026-09-09 10:58:01 -03:00
admin 0e64007996 chore(f6): bump API package version 2026-09-09 10:57:47 -03:00
admin 709f43c1e9 chore(f6): publish API phase metadata 2026-09-09 10:57:30 -03:00
admin ab5ce42d47 test(f6): lock definitive inventory and mobile contracts 2026-09-09 10:48:50 -03:00
admin 3f71323555 test(f6): retire one-to-one family expectation 2026-09-09 10:47:53 -03:00
admin f3b72ecfc3 feat(android): browse field inventory as strict hierarchy 2026-09-09 10:43:53 -03:00
admin 5c5e5de26b fix(f6): gate mobile findings by area ancestry and temporal operator 2026-09-09 10:42:03 -03:00
admin 792b115449 fix(f6): resolve field findings by technical classification 2026-09-09 10:41:17 -03:00
admin 46cb5e0eb9 fix(f6): allow atomic field classification handoff 2026-09-09 10:40:57 -03:00
admin 8708c9c93f feat(f6): agregar datos técnicos por clasificación a la ficha 2026-09-09 09:34:55 -03:00
admin 2186810be3 feat(f6): agregar API de valores técnicos por objeto 2026-09-09 09:34:25 -03:00
admin 052384bfe0 feat(f6): registrar servicio de valores técnicos 2026-09-09 09:33:56 -03:00
admin a758c9528a feat(f6): exponer valores técnicos por objeto 2026-09-09 09:33:39 -03:00
admin 5023685043 feat(f6): persistir y validar valores técnicos del Inventario 2026-09-09 09:33:28 -03:00
admin d05ea8405a feat(f6): agregar DTO de valores técnicos por objeto 2026-09-09 09:33:02 -03:00
admin dc0e1d9762 feat(f6): filtrar subinstalaciones por compatibilidad múltiple 2026-09-09 09:27:14 -03:00
admin 3f662b86ea feat(f6): mostrar compatibilidades múltiples en catálogo 2026-09-09 09:26:32 -03:00
admin 636be088e8 feat(f6): consolidar configuración técnica por clasificación 2026-09-09 09:25:57 -03:00
admin 96738bfdcb feat(f6): consumir compatibilidades y campos técnicos de familias 2026-09-09 09:24:06 -03:00
admin 0b074923be feat(f6): aplicar compatibilidad múltiple al alta de Inventarios 2026-09-09 09:23:32 -03:00
admin e181dea4cb feat(f6): exponer campos técnicos por clasificación 2026-09-09 09:22:31 -03:00
admin 76163a5ebb feat(f6): administrar compatibilidades y campos técnicos por familia 2026-09-09 09:22:13 -03:00
admin 74516ede1e feat(f6): agregar DTOs de campos técnicos por clasificación 2026-09-09 09:20:59 -03:00
admin 9f09aca30f feat(f6): aceptar compatibilidades múltiples por clasificación 2026-09-09 09:18:33 -03:00
admin 169aab5cd3 feat(f6): solidificar compatibilidades y campos técnicos por clasificación 2026-09-09 09:18:16 -03:00
admin 0f4bb13fc7 docs(f6): documentar modelo definitivo 2026-09-09 09:11:16 -03:00
admin f0db23bc9b docs(f6): ampliar contrato definitivo 2026-09-09 09:11:08 -03:00
admin 0b0851d9ec docs(f6): fijar contrato definitivo de Inventarios 2026-09-09 09:11:00 -03:00
admin 7338f277ad F5.1 · Inventario limpio, jerarquía manual y Hallazgos filtrados (#28)
Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora.
2026-09-09 08:55:01 -03:00
admin 3d83545033 F5 hotfix · restaurar provider interno del dossier
Restaura únicamente InventoryFunctionService como provider interno requerido por el dossier fusionado, sin reabrir el catálogo de funciones, y agrega regresión de arranque.
2026-09-09 07:04:46 -03:00
admin 2b87e07fd2 F5 · Android 0.13.0 de prueba y compatibilidad de familias
Integra el corte Android F5 validado por CI, con artifact 0.13.0-debug y compatibilidad correcta de familias Otro / no catalogado en Inventario de campo.
2026-09-09 06:50:06 -03:00
admin 35d4630581 F5 · Inventario operativo, territorio y catálogo autorizado (#25)
* fix(web): simplify inventory administration menu

* fix(web): remove legacy imports and function catalog routes

* fix(web): remove redundant inspections lifecycle legend

* feat(inventory): distinguish physical instances from structural records

* fix(dashboard): align inventory and act follow-up metrics

* fix(web): align dashboard summary contract

* fix(web): clarify dashboard act and report concepts

* feat(inventory): mark field-created records as real instances

* feat(inventory): map physical instance flag on asset entity

* fix(inventory): keep field yacimientos structural

* feat(inventory): classify future concrete instances at database level

* fix(inventory): count only installation and subinstallation instances

* feat(inventory): add authoritative F5 source snapshot

* feat(inventory): preload authoritative territory model

* feat(inventory): preload authoritative technical catalog

* fix(findings): use only authoritative F5 family catalog

* fix(inventory): preserve non-hierarchical operator snapshot compatibility

* feat(inventory): add inventory-only asset filter

* feat(inventory): add inventory-only tree filter

* feat(inventory): add inventory browser query contract

* feat(inventory): add area-owned inventory browser

* feat(inventory): expose area-owned inventory browser

* refactor(inventory): remove function catalog and add inventory browser

* fix(inventory): make operator relation temporal and non-owning

* feat(web): add inventory browser API client

* feat(inventory): extend inventory browser filters

* feat(inventory): add real inventory list endpoint logic

* feat(inventory): expose real inventory list

* feat(web): add real inventory list client

* refactor(web): make inventory hierarchy area-owned

* fix(web): show only real inventory instances

* fix(web): style act follow-up tabs and F5 inventory context

* fix(web): load F5 flow styles

* fix(inventory): apply area-owned operational guard on F5 up

* fix(inventory): treat company on asset as non-owning creation snapshot

* fix(inventory): resolve field inventory by area hierarchy, not company ownership

* fix(inventory): preserve custom catalog and apply authoritative universal findings

* fix(inventory): harden authoritative catalog migration checks

* fix(inventory): make authoritative territory preload safely reversible

* feat(inventory): allow independent company master creation

* fix(inventory): make guided creation area-owned and support companies

* feat(web): expose independent company master in inventory setup

* feat(web): create companies independently from physical inventory hierarchy

* fix(inventory): merge by physical area and preserve sealed documents

* test(inventory): lock F5 authoritative model and merge invariants

* feat(inventory): add family administration DTOs

* feat(inventory): administer installation and subinstallation classifications

* feat(inventory): expose family classification administration

* feat(web): add inventory classification administration API

* fix(web): configure finding applicability by inventory classification

* fix(web): redefine inventory configuration around hierarchy classifications and columns

* chore(release): identify F5 inventory model

* chore(release): bump API for F5 inventory model

* test(release): expect F5 health metadata

* chore(release): align WEB package with F5 inventory cut

* chore(release): expose F5 WEB phase

* test(dashboard): expect inspector activity and act follow-up metrics

* test(dashboard): route F5 summary query mocks explicitly

* ci: rehearse all migrations on clean PostGIS before merge

* ci: prove F5 migrations revert and reapply cleanly

* test(f5): align operational navigation contract

* test(f5): align operator lifecycle with area-owned inventory

* test(f5): make merge compatibility area-based

* test(f5): distinguish literal and normalized yacimiento counts

* test(f5): model normalized yacimiento collision explicitly

* ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal

* ci(f5): bypass irreversible historical reset in clean rehearsal

* fix(f5): make territory SQL parameter types explicit

* fix(f5): guarantee canonical inventory hierarchy before territory preload

* ci(f5): include canonical hierarchy migration in rollback gate

* fix(f5): type relation backup markers explicitly

* fix(f5): make catalog SQL text parameter types explicit
2026-09-08 23:18:15 -03:00
78 changed files with 5518 additions and 1445 deletions
+22 -90
View File
@@ -1,32 +1,35 @@
name: Android CI / RC name: Android APK
# F6: genera una APK debug verificable contra la API productiva F6.
on: on:
push: push:
branches: [main] branches:
- 'main'
- 'release/f5-android-test'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
pull_request: pull_request:
branches: [main]
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**' - 'api-v3/src/auth/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
android: build-debug-apk:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 35 timeout-minutes: 30
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -48,89 +51,18 @@ jobs:
with: with:
gradle-version: '8.13' gradle-version: '8.13'
- name: Validate mobile security and identity contract - name: Assemble debug
run: |
set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Require real unit-test results
run: |
set -Eeuo pipefail
result="$(find android-app/app/build/test-results/testDebugUnitTest -type f -name 'TEST-*.xml' -print -quit)"
test -n "$result"
grep -Eq '<testsuite[^>]+tests="[1-9][0-9]*"' "$result"
- name: Assemble debug APK
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:assembleDebug run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant - name: Unit tests
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:assembleRelease run: gradle --no-daemon :app:testDebugUnitTest
- name: Package RC artifact and checksum - name: Upload APK
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 "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug name: DH-Inspeccion-F6-0.14.0-debug
path: android-app/dist/* path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14
+147
View File
@@ -69,6 +69,153 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose - name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
# 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"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
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 $$;
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
# F5.1 intentionally ends with zero operational/domain instances. The
# technical family and finding masters remain, but territory preload,
# imports, applicability links and old audits are deliberately gone.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
f5_migrations integer;
domain_assets integer;
audits integer;
applicability integer;
territory_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
department_types integer;
area_department_rules integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name IN (
'F5InventoryPhysicalInstance1790087100000',
'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000',
'F51CleanManualInventory1790087400000'
);
IF f5_migrations <> 6 THEN
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
END IF;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets;
END IF;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 0 THEN
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF territory_sources <> 0 THEN
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', 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.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
FROM asset_types
WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true;
IF department_types <> 1 THEN
RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types;
END IF;
SELECT COUNT(*) INTO area_department_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='area' AND lower(parent.code)='departamento';
IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF;
END $$;
SQL
# F5.1 is intentionally one-way: production rollback is the PRE database
# backup, not migration:revert. Prove instead that the completed chain is
# idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: F5.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
rm -f "$rerun_log"
- name: VPS-equivalent isolated API preflight - name: VPS-equivalent isolated API preflight
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
-7
View File
@@ -11,11 +11,6 @@
**/.vite/ **/.vite/
**/coverage/ **/coverage/
# Android / Gradle local state
android-app/.gradle/
android-app/**/build/
android-app/local.properties
# Backups / exports # Backups / exports
*.zip *.zip
*.tar.gz *.tar.gz
@@ -43,8 +38,6 @@ Thumbs.db
*.key *.key
*.p12 *.p12
*.pfx *.pfx
*.jks
*.keystore
id_rsa id_rsa
id_ed25519 id_ed25519
*_github *_github
-39
View File
@@ -1,39 +0,0 @@
# DH Inspección Android · contrato de release F5
## Estado de esta etapa
La primera candidata de F5 es `0.13.0-rc1` (`versionCode 20`). Su objetivo es convertir el cliente Android en una barrera verificable del repositorio antes del piloto de campo.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar el workflow `Android CI / RC`:
1. Android lint.
2. Unit tests Android reales (el job falla si no existe ningún XML de tests con al menos una prueba).
3. `assembleDebug`.
4. `assembleRelease` para verificar que la variante productiva compile.
5. Empaquetado del APK debug RC con SHA-256 y metadata de commit/versionado.
El APK debug usa `com.korexlabs.dhinspeccion.debug`; es deliberadamente independiente de la app productiva y sirve para QA/piloto técnico sin sobrescribir una instalación release histórica.
## Firma release
La clave histórica de firma NO se versiona ni se reemplaza. La variante release se compila en CI, pero el APK de distribución final deberá firmarse con la clave histórica y comprobarse antes de instalarlo como actualización de `com.korexlabs.dhinspeccion`.
No se debe generar una clave nueva para "resolver" una falta de acceso: eso rompería la continuidad de actualización de las tablets que ya tengan una versión firmada con la clave anterior.
## Evidencia mínima de cada candidata
El artefacto de CI contiene:
- APK debug RC;
- archivo `.sha256`;
- `release-metadata.txt` con versión, versionCode, commit, applicationId, API base y canal.
Para un release de campo definitivo se agregará además:
- APK release firmada;
- huella/certificado de firma comprobado contra la versión histórica;
- prueba de actualización sobre una tablet con versión anterior;
- smoke funcional contra producción;
- registro del SHA Git exacto que originó la APK.
+3 -8
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 20 versionCode = 21
versionName = "0.13.0-rc1" versionName = "0.14.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -46,11 +46,6 @@ android {
} }
kotlinOptions.jvmTarget = "17" kotlinOptions.jvmTarget = "17"
lint {
abortOnError = true
checkReleaseBuilds = true
}
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
@@ -82,4 +77,4 @@ dependencies {
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
} }
@@ -6,10 +6,6 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:label="@string/app_name" android:label="@string/app_name"
@@ -49,6 +49,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList()) var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
private set private set
var inventoryParentId: String? by mutableStateOf(null)
private set
var fieldTypes: List<FieldType> by mutableStateOf(emptyList()) var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
private set private set
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
@@ -96,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList() visits = emptyList()
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -112,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) visit = repository.visit(id)
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -121,6 +125,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun closeVisitView() { fun closeVisitView() {
visit = null visit = null
inventory = emptyList() inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearActState() clearActState()
@@ -132,6 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -189,25 +195,34 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
inventory = repository.fieldInventory(id, search, parentId).data val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
} }
} }
fun loadFieldTypes(parentId: String? = null) { fun loadFieldTypes(parentId: String? = null) {
val id = visit?.id ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
fieldTypes = repository.fieldTypes(id, parentId).data val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
} }
} }
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
fun selectExisting(item: FieldInventoryItem) { fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
launchBusy { launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección." notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id) selectedAct = actsRepository.ensureAsset(draft.id, item.id)
@@ -236,7 +251,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
if (type.familyRequired && familyId == null) { if (type.familyRequired && familyId == null) {
error = "Elegí una familia técnica o la opción Otro / no catalogado." error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
return return
} }
launchBusy { launchBusy {
@@ -254,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.createFieldAsset(visitId, request) selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar." notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
} }
} }
@@ -278,7 +293,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) )
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id) selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable." notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
@@ -311,7 +326,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else { } else {
"Fotografía registrada." "Fotografía registrada."
} }
inventory = repository.fieldInventory(visitId, null, null).data reloadCurrentInventory(visitId)
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) { if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id) selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
@@ -1,54 +0,0 @@
package com.korexlabs.dhinspeccion.domain
/**
* UX-side mirrors of server invariants used to prevent invalid field actions before a request is sent.
* The API remains authoritative; these rules must never be used to weaken backend validation.
*/
object MobileWorkflowRules {
private val validUrgencies = setOf("URGENT", "NON_URGENT")
private val terminalCompanyOutcomes = setOf("SIGNED", "REFUSED", "ABSENT")
fun canStartInspection(status: String): Boolean = status == "PLANNED"
fun hasDraftAct(statuses: Iterable<String>): Boolean = statuses.any { it == "DRAFT" }
fun canCreateAct(
visitStatus: String,
actStatuses: Iterable<String>,
hasSelectedInventory: Boolean,
urgency: String,
): Boolean =
visitStatus == "IN_PROGRESS" &&
!hasDraftAct(actStatuses) &&
hasSelectedInventory &&
urgency in validUrgencies
fun canCreateFieldInventory(visitStatus: String): Boolean = visitStatus == "IN_PROGRESS"
fun canRegisterFinding(
visitStatus: String,
selectedActStatus: String?,
inventoryReadyForFinding: Boolean,
): Boolean =
visitStatus == "IN_PROGRESS" &&
selectedActStatus == "DRAFT" &&
inventoryReadyForFinding
fun canLockAct(actStatus: String?, responsibleDefined: Boolean): Boolean =
actStatus == "DRAFT" && responsibleDefined
fun canSealAct(
actStatus: String?,
inspectorSigned: Boolean,
companyOutcomeStatus: String?,
): Boolean =
actStatus == "LOCKED" &&
inspectorSigned &&
companyOutcomeStatus in terminalCompanyOutcomes
fun canCloseInspection(visitStatus: String, actStatuses: Iterable<String>): Boolean {
if (visitStatus != "IN_PROGRESS") return false
val active = actStatuses.filter { it != "CANCELLED" }
return active.isNotEmpty() && active.all { it == "SEALED" }
}
}
@@ -541,9 +541,7 @@ private fun hasLocation(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation -> private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation ->
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED if (!hasLocation(context)) {
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@@ -721,9 +721,7 @@ private fun f3HasLocation(context: Context): Boolean =
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation -> private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation ->
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED if (!f3HasLocation(context)) {
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@@ -383,9 +383,7 @@ private fun findingHasLocation(context: Context): Boolean =
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation -> private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation ->
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED if (!findingHasLocation(context)) {
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación.")) continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
@@ -426,15 +426,13 @@ private fun hasActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation -> private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED if (!hasActLocation(context)) {
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada")) continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location -> .addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
@@ -1,20 +0,0 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugRcKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(20, BuildConfig.VERSION_CODE)
assertEquals("0.13.0-rc1-debug", BuildConfig.VERSION_NAME)
}
@Test
fun rcTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
@@ -1,90 +0,0 @@
package com.korexlabs.dhinspeccion.domain
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MobileWorkflowRulesTest {
@Test
fun plannedInspectionCanStartButRunningOrClosedCannot() {
assertTrue(MobileWorkflowRules.canStartInspection("PLANNED"))
assertFalse(MobileWorkflowRules.canStartInspection("IN_PROGRESS"))
assertFalse(MobileWorkflowRules.canStartInspection("CLOSED"))
}
@Test
fun onlyOneDraftActIsAllowedPerInspection() {
assertFalse(MobileWorkflowRules.hasDraftAct(listOf("SEALED", "LOCKED")))
assertTrue(MobileWorkflowRules.hasDraftAct(listOf("SEALED", "DRAFT", "LOCKED")))
}
@Test
fun creatingAnActRequiresRunningInspectionInventoryValidUrgencyAndNoDraft() {
assertTrue(
MobileWorkflowRules.canCreateAct(
visitStatus = "IN_PROGRESS",
actStatuses = listOf("SEALED", "LOCKED"),
hasSelectedInventory = true,
urgency = "NON_URGENT",
),
)
assertTrue(
MobileWorkflowRules.canCreateAct(
visitStatus = "IN_PROGRESS",
actStatuses = emptyList(),
hasSelectedInventory = true,
urgency = "URGENT",
),
)
assertFalse(MobileWorkflowRules.canCreateAct("PLANNED", emptyList(), true, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", listOf("DRAFT"), true, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", emptyList(), false, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", emptyList(), true, "UNKNOWN"))
}
@Test
fun fieldInventoryCanOnlyBeCreatedWhileInspectionIsRunning() {
assertTrue(MobileWorkflowRules.canCreateFieldInventory("IN_PROGRESS"))
assertFalse(MobileWorkflowRules.canCreateFieldInventory("PLANNED"))
assertFalse(MobileWorkflowRules.canCreateFieldInventory("CLOSED"))
}
@Test
fun findingsRequireRunningInspectionDraftActAndReadyInventory() {
assertTrue(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "DRAFT", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("PLANNED", "DRAFT", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "LOCKED", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "DRAFT", false))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", null, true))
}
@Test
fun actCanOnlyLockFromDraftAfterResponsibleWasResolved() {
assertTrue(MobileWorkflowRules.canLockAct("DRAFT", true))
assertFalse(MobileWorkflowRules.canLockAct("DRAFT", false))
assertFalse(MobileWorkflowRules.canLockAct("LOCKED", true))
assertFalse(MobileWorkflowRules.canLockAct("SEALED", true))
}
@Test
fun actCanOnlySealAfterInspectorAndCompanyOutcomeAreResolved() {
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "SIGNED"))
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "REFUSED"))
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "ABSENT"))
assertFalse(MobileWorkflowRules.canSealAct("DRAFT", true, "SIGNED"))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", false, "SIGNED"))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", true, null))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", true, "PENDING"))
}
@Test
fun inspectionClosesOnlyWhenEveryNonCancelledActIsSealed() {
assertTrue(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED")))
assertTrue(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "CANCELLED", "SEALED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", emptyList()))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("CANCELLED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "LOCKED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "DRAFT")))
assertFalse(MobileWorkflowRules.canCloseInspection("CLOSED", listOf("SEALED")))
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.25.0-1", "version": "0.28.0-1",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -43,4 +43,4 @@
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
} }
} }
@@ -26,11 +26,14 @@ import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service'; import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryFunctionController } from './inventory-function.controller'; import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryFunctionService } from './inventory-function.service'; import { InventoryBrowserService } from './inventory-browser.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule],
@@ -39,7 +42,8 @@ import { InventoryFunctionService } from './inventory-function.service';
AssetsController, AssetsController,
InventoryStructureController, InventoryStructureController,
InventoryFamilyCatalogController, InventoryFamilyCatalogController,
InventoryFunctionController, InventoryTechnicalValuesController,
InventoryBrowserController,
InventoryMergeController, InventoryMergeController,
FieldInventoryMergeController, FieldInventoryMergeController,
AssetGeometriesController, AssetGeometriesController,
@@ -56,7 +60,9 @@ import { InventoryFunctionService } from './inventory-function.service';
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService, InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService, InventoryMergeService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
@@ -71,7 +77,6 @@ import { InventoryFunctionService } from './inventory-function.service';
exports: [ exports: [
AssetHistoryService, AssetHistoryService,
AssetsService, AssetsService,
InventoryFunctionService,
InventoryMergeService, InventoryMergeService,
MergedInventoryDossierService, MergedInventoryDossierService,
AssetGeometriesService, AssetGeometriesService,
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
const data = (await this.dataSource.query(` const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" SELECT DISTINCT company.id, company.code, company.name,
FROM ( company.common_name AS "commonName", company_type.name AS "typeName"
SELECT relation.company_id FROM area_company_relations relation
FROM area_company_relations relation INNER JOIN assets company ON company.id = relation.company_id
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operator_company_id AS company_id
FROM assets asset
WHERE asset.operational_area_id = $1
AND asset.operator_company_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets company ON company.id = linked.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE company.information_status <> 'INACTIVE' WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status <> 'INACTIVE'
ORDER BY company.name, company.code ORDER BY company.name, company.code
`, [areaId])) as OperationalAssetSummary[]; `, [areaId])) as OperationalAssetSummary[];
return { data }; return { data };
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
const data = (await this.dataSource.query(` const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" SELECT DISTINCT area.id, area.code, area.name,
FROM ( area.common_name AS "commonName", area_type.name AS "typeName"
SELECT relation.area_id FROM area_company_relations relation
FROM area_company_relations relation INNER JOIN assets area ON area.id = relation.area_id
WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
UNION
SELECT asset.operational_area_id AS area_id
FROM assets asset
WHERE asset.operator_company_id = $1
AND asset.operational_area_id IS NOT NULL
AND asset.information_status <> 'INACTIVE'
) linked
INNER JOIN assets area ON area.id = linked.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
WHERE area.information_status <> 'INACTIVE' WHERE relation.company_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_until IS NULL
AND area.information_status <> 'INACTIVE'
ORDER BY area.name, area.code ORDER BY area.name, area.code
`, [companyId])) as OperationalAssetSummary[]; `, [companyId])) as OperationalAssetSummary[];
return { data }; return { data };
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
} }
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
const [row] = (await manager.query(` const [row] = (await manager.query(`
INSERT INTO area_company_relations ( INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
const created = await this.loadRelation(manager, row.id); const created = await this.loadRelation(manager, row.id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
entityType: 'area_company_relation', entityType: 'area_company_relation',
entityId: row.id, entityId: row.id,
afterData: this.auditView(created), afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
: undefined,
}, manager); }, manager);
return created; return created;
}); });
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
message: 'La relación ya se encuentra finalizada', message: 'La relación ya se encuentra finalizada',
}); });
} }
if (before.assignedAssetCount > 0) {
throw new ConflictException({ // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
code: 'AREA_COMPANY_RELATION_IN_USE', // Ending an operator relation must never be blocked by existing Inventory.
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
});
}
await manager.query(` await manager.query(`
UPDATE area_company_relations UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP, SET valid_until = CURRENT_TIMESTAMP,
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
entityId: id, entityId: id,
beforeData: this.auditView(before), beforeData: this.auditView(before),
afterData: this.auditView(updated), afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager); }, manager);
return updated; return updated;
}); });
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
(relation.valid_until IS NULL) AS active, (relation.valid_until IS NULL) AS active,
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
WHERE asset.operational_area_id = relation.area_id WHERE asset.operational_area_id = relation.area_id
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount" AND asset.operator_company_id = relation.company_id
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
FROM area_company_relations relation FROM area_company_relations relation
INNER JOIN assets area ON area.id = relation.area_id INNER JOIN assets area ON area.id = relation.area_id
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -10,6 +10,8 @@ import {
} from 'class-validator'; } from 'class-validator';
export const INVENTORY_STRUCTURE_KINDS = [ export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
'AREA', 'AREA',
'YACIMIENTO', 'YACIMIENTO',
'INSTALACION', 'INSTALACION',
@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class InventoryBrowserQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
@@ -0,0 +1,75 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateInventoryFamilyDto {
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
level!: 'INSTALLATION' | 'SUBINSTALLATION';
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
}
export class UpdateInventoryFamilyDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReplaceInventoryFamilyFindingsDto {
@IsArray()
@ArrayMaxSize(2000)
@IsUUID('4', { each: true })
itemIds!: string[];
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -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;
}
@@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto {
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean() @IsBoolean()
hasGeometry?: boolean; hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
} }
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
@IsEnum(AssetInformationStatus) @IsEnum(AssetInformationStatus)
status?: AssetInformationStatus; status?: AssetInformationStatus;
@IsOptional() @IsOptional()
@IsEnum(AssetOperationalStatus) @IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus; operationalStatus?: AssetOperationalStatus;
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
@IsBoolean() @IsBoolean()
hasGeometry?: boolean; hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
@IsOptional() @IsOptional()
@IsUUID('4') @IsUUID('4')
parentId?: string; parentId?: string;
@@ -0,0 +1,6 @@
import { IsObject } from 'class-validator';
export class UpdateInventoryTechnicalValuesDto {
@IsObject()
values!: Record<string, unknown>;
}
@@ -0,0 +1,38 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
import { InventoryBrowserService } from './inventory-browser.service';
@Controller('inventory-browser')
@RequirePermissions('assets.read')
export class InventoryBrowserController {
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
@Get('items')
items(@Query() query: InventoryBrowserQueryDto) {
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);
}
@Get(':parentId/children')
children(
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
@Query() query: InventoryBrowserQueryDto,
) {
return this.inventoryBrowser.children(parentId, query);
}
}
@@ -0,0 +1,335 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
type ParentContext = {
id: string;
code: string;
name: string;
typeCode: string;
};
@Injectable()
export class InventoryBrowserService {
constructor(private readonly dataSource: DataSource) {}
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"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);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
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) {
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(`(
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
)
)`);
}
const where = conditions.join(' AND ');
const [countRow] = (await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE ${where}
`,params)) as Array<{total:number}>;
const total=Number(countRow?.total ?? 0);
const offset=(query.page-1)*query.pageSize;
params.push(query.pageSize);
const limit=`$${params.length}`;
params.push(offset);
const offsetParam=`$${params.length}`;
const data=await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
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",
(
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=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
LIMIT 1
) AS "operatorCompany",
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
(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",
asset.data_origin AS "dataOrigin",
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
asset.current_version AS "currentVersion",
asset.updated_at AS "updatedAt"
FROM assets asset
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 LATERAL (
SELECT ST_GeometryType(geometry.geometry)::text AS type
FROM asset_geometries geometry
WHERE geometry.asset_id=asset.id
ORDER BY geometry.updated_at DESC
LIMIT 1
) geometry_type ON true
WHERE ${where}
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);
return {
data,
meta:{
page:query.page,
pageSize:query.pageSize,
total,
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
},
};
}
async departments(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"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(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`);
}
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (
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
)`);
}
const data = await this.dataSource.query(`
SELECT
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,
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 } };
}
async children(parentId: string, query: InventoryBrowserQueryDto) {
const parent = await this.parent(parentId);
const allowedChildType = this.allowedChildType(parent.typeCode);
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
const params: unknown[] = [parentId, allowedChildType];
const conditions = [
'asset.parent_id=$1::uuid',
'lower(type.code)=lower($2)',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
params.push(201);
const limit = `$${params.length}`;
const rows = await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
) END AS parent,
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
asset.is_inventory_instance AS "isInventoryInstance",
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') 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
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name,asset.code
LIMIT ${limit}
`, params);
const hasMore = rows.length > 200;
const data = hasMore ? rows.slice(0,200) : rows;
return { parent, data, meta: { count: data.length, hasMore } };
}
private async parent(parentId: string): Promise<ParentContext> {
const [parent] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
`,[parentId]) as ParentContext[];
if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
}
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 Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
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';
default: return null;
}
}
}
@@ -1,11 +1,99 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common'; import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import {
CreateInventoryFamilyAttributeDto,
UpdateInventoryFamilyAttributeDto,
} from './dto/inventory-family-attribute.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families') @Controller('inventory-families')
export class InventoryFamilyCatalogController { export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {} constructor(private readonly families: InventoryFamilyCatalogService) {}
@Get('admin')
@RequirePermissions('asset_types.read')
admin() {
return this.families.listAdmin();
}
@Post()
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFamilyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.families.create(dto,principal,request);
}
@Patch(':familyId')
@RequirePermissions('asset_types.manage')
update(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:UpdateInventoryFamilyDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
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(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:ReplaceInventoryFamilyFindingsDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.replaceFindings(familyId,dto,principal,request);
}
@Get(':familyId/findings') @Get(':familyId/findings')
@RequirePermissions('assets.read') @RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) { findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
@@ -1,28 +1,69 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto';
import { DataSource } from 'typeorm'; import {
BadRequestException,
ConflictException,
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 {
CreateInventoryFamilyDto,
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;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
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() @Injectable()
export class InventoryFamilyCatalogService { export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {} constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAdmin() {
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
return { data };
}
async findings(familyId: string) { async findings(familyId: string) {
const [family] = await this.dataSource.query(` const family = await this.family(familyId, false);
SELECT id,code,name,level,information_labels AS "informationLabels"
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
`, [familyId]) as Array<{
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
}>;
if (!family) {
throw new NotFoundException({
code: 'INVENTORY_FAMILY_NOT_FOUND',
message: 'La familia técnica no existe',
});
}
const items = await this.dataSource.query(` const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
@@ -36,4 +77,369 @@ export class InventoryFamilyCatalogService {
`, [familyId]); `, [familyId]);
return { family, items, count: items.length }; return { family, items, count: items.length };
} }
async attributes(familyId: string) {
const family = await this.family(familyId, false);
const items = await this.attributeRows(this.dataSource.manager, familyId);
return { family, items, count: items.length };
}
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
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: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');
await this.replaceParents(manager, inserted.id, parentIds);
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' },
},manager);
return created;
});
}
async update(
familyId: string,
dto: UpdateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
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),
information_labels=COALESCE($3::jsonb,information_labels),
is_active=COALESCE($4::boolean,is_active),
updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
familyId,
dto.name ?? null,
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
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),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: familyId,
beforeData: before as unknown as Record<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
},manager);
return after;
});
}
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,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const family = await this.family(familyId,false,manager,true);
const uniqueIds=[...new Set(dto.itemIds)];
if (uniqueIds.length) {
const [count] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items item
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',
});
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
if (uniqueIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[uniqueIds,familyId]);
}
const after=await this.family(familyId,false,manager);
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},
},manager);
return this.findingsWithManager(manager,familyId);
});
}
private async findingsWithManager(manager:EntityManager,familyId:string) {
const family=await this.family(familyId,false,manager);
const items=await manager.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
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
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 lockClause=lock ? 'FOR UPDATE OF family' : '';
const rows=(await manager.query(this.familySelect(`
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${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 validateParents(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
requestedIds:string[],
ownId:string|null,
):Promise<string[]> {
const ids=[...new Set(requestedIds)];
if (level==='INSTALLATION') {
if (ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no lleva compatibilidades padre',
});
return [];
}
if (!ids.length) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
});
if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
});
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:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
});
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 identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
});
return [...unique.values()];
}
private isUniqueViolation(error:unknown) {
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
}
} }
@@ -39,6 +39,22 @@ type MergeRow = {
requestId: string | null; requestId: string | null;
}; };
type DocumentInvariantRow = {
actId: string;
actStatus: string;
lockedSha256: string | null;
closureSha256: string | null;
sealedAt: Date | null;
actVersion: number;
reportId: string | null;
reportStatus: string | null;
reportActClosureSha256: string | null;
reportFrozenSha256: string | null;
gedoPdfSha256: string | null;
wordSha256: string | null;
reportRevision: number | null;
};
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']); const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable() @Injectable()
@@ -161,6 +177,19 @@ export class InventoryMergeService {
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical); this.validatePair(source, canonical);
// Empresa is temporal inspection context, not physical ownership. A merge is
// valid when both records resolve to the same Area in the physical hierarchy,
// even if their historical operator snapshots differ.
const [sourceAreaId, canonicalAreaId] = await Promise.all([
this.resolvePhysicalAreaId(manager, source.id),
this.resolvePhysicalAreaId(manager, canonical.id),
]);
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
code: 'INVENTORY_MERGE_AREA_MISMATCH',
message: 'Los duplicados deben pertenecer a la misma Área física',
});
const sourceParentCanonical = source.parentId const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId) ? await this.resolveCanonicalId(manager, source.parentId)
: null; : null;
@@ -197,6 +226,8 @@ export class InventoryMergeService {
}); });
} }
const affectedAssetIds = [source.id, canonical.id];
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([ const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id), this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id), this.snapshot(manager, canonical.id),
@@ -279,6 +310,17 @@ export class InventoryMergeService {
request, request,
); );
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
// before committing the transaction; otherwise rollback the entire merge.
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
throw new ConflictException({
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
});
}
const result = { const result = {
merge: mergeRecord, merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name }, source: { id: source.id, code: source.code, name: source.name },
@@ -286,6 +328,7 @@ export class InventoryMergeService {
sourceVersionNumber, sourceVersionNumber,
reparentedChildIds, reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED', historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
documentaryInvariantsVerified: true,
}; };
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -298,10 +341,12 @@ export class InventoryMergeService {
operation: 'CHRONOLOGICAL_MERGE', operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id, sourceAssetId: source.id,
canonicalAssetId: canonical.id, canonicalAssetId: canonical.id,
physicalAreaId: sourceAreaId,
reason: dto.reason, reason: dto.reason,
sourceVersionNumber, sourceVersionNumber,
reparentedChildIds, reparentedChildIds,
historicalReferencesRewritten: false, historicalReferencesRewritten: false,
documentaryInvariantsVerified: true,
}, },
}, manager); }, manager);
return result; return result;
@@ -322,14 +367,59 @@ export class InventoryMergeService {
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE', code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo', message: 'El registro canónico no puede estar inactivo',
}); });
if (!source.operationalAreaId || !source.operatorCompanyId }
|| source.operationalAreaId !== canonical.operationalAreaId
|| source.operatorCompanyId !== canonical.operatorCompanyId) { private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
throw new BadRequestException({ const rows = (await manager.query(`
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH', WITH RECURSIVE lineage AS (
message: 'Los registros deben pertenecer a la misma Área y Operadora', SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
}); FROM assets asset WHERE asset.id=$1::uuid
} UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
FROM assets parent
JOIN lineage ON lineage.parent_id=parent.id
WHERE lineage.depth<32
)
SELECT lineage.id
FROM lineage
JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
ORDER BY lineage.depth
LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? null;
}
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
return (await manager.query(`
WITH affected_acts AS (
SELECT DISTINCT act.id
FROM inspection_acts act
LEFT JOIN inspection_act_assets act_asset
ON act_asset.act_id=act.id AND act_asset.included=true
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
WHERE act_asset.asset_id=ANY($1::uuid[])
OR finding.asset_id=ANY($1::uuid[])
)
SELECT
act.id AS "actId",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.closure_sha256 AS "closureSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "actVersion",
report.id AS "reportId",
report.status AS "reportStatus",
report.act_closure_sha256 AS "reportActClosureSha256",
report.frozen_sha256 AS "reportFrozenSha256",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.word_sha256 AS "wordSha256",
report.current_revision_number AS "reportRevision"
FROM affected_acts affected
JOIN inspection_acts act ON act.id=affected.id
LEFT JOIN inspection_reports report ON report.act_id=act.id
ORDER BY act.id,report.id NULLS FIRST
`, [assetIds])) as DocumentInvariantRow[];
} }
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> { private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
@@ -20,6 +20,7 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service'; import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string }; type StructureTypeRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = { type FamilyRow = {
id: string; id: string;
code: string; code: string;
@@ -27,28 +28,29 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION'; level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null; legacyTypeCode: string | null;
informationLabels: string[]; informationLabels: string[];
parentFamilyId: string | null; parentFamilyIds: string[];
parentFamilyCode: string | null; parentFamilies: FamilyParent[];
parentFamilyName: string | null;
}; };
type ParentRow = { type ParentRow = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeCode: string; typeCode: string;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
}; };
type IdRow = { id: string };
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = { const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area', AREA: 'area',
YACIMIENTO: 'yacimiento', YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion', INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion', SUBINSTALACION: 'subinstalacion',
}; };
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = { const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
AREA: null, EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area', YACIMIENTO: 'area',
INSTALACION: 'yacimiento', INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion', SUBINSTALACION: 'instalacion',
@@ -70,35 +72,58 @@ export class InventoryStructureService {
const types = (await this.dataSource.query(` const types = (await this.dataSource.query(`
SELECT id,code,name SELECT id,code,name
FROM asset_types FROM asset_types
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion') WHERE (
AND is_active=true lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
ORDER BY CASE lower(code) OR operational_role='COMPANY'
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2 ) AND is_active=true
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0
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[]; `)) as StructureTypeRow[];
if (types.length !== 4) { 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 || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({ throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La estructura del Inventario todavía no está completamente configurada', message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
}); });
} }
const families = (await this.dataSource.query(` const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels", family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule
JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true WHERE family.is_active=true
ORDER BY family.level,family.name,family.code ORDER BY family.level,family.name,family.code
`)) as FamilyRow[]; `)) as FamilyRow[];
return { return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
],
levels: [ levels: [
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false }, { kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false }, { kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true }, { kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true }, { kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
], ],
installationFamilies: families.filter((item) => item.level==='INSTALLATION'), installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
@@ -107,7 +132,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) { async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind; const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') { if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
throw new BadRequestException({ throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre', message: 'El nivel indicado no requiere un registro padre',
@@ -139,7 +164,7 @@ export class InventoryStructureService {
AND asset.information_status<>'INACTIVE' AND asset.information_status<>'INACTIVE'
${searchSql} ${searchSql}
ORDER BY asset.name,asset.code ORDER BY asset.name,asset.code
LIMIT 80 LIMIT 100
`, parameters); `, parameters);
return { data: rows }; return { data: rows };
} }
@@ -154,9 +179,10 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind); const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent?.operationalAreaId ?? null; const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
const operatorCompanyId = parent?.operatorCompanyId ?? null; ? await this.resolveAreaId(manager, parent)
: null;
const inserted = (await manager.query(` const inserted = (await manager.query(`
INSERT INTO assets ( INSERT INTO assets (
@@ -164,91 +190,82 @@ export class InventoryStructureService {
code,name,common_name,description,information_status,operational_status, code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES ( ) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid, $1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status, $5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid $11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id ) RETURNING id
`, [ `, [
type.id, type.id,
parent?.id ?? null, parent?.id ?? null,
operationalAreaId, operationalAreaId,
operatorCompanyId,
family?.id ?? null, family?.id ?? null,
code, generatedCode,
dto.name, dto.name,
dto.commonName ?? null, dto.commonName ?? null,
dto.description ?? null, dto.description ?? null,
AssetInformationStatus.DRAFT, AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN, AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL, AssetDataOrigin.MANUAL,
'Inventario estructural F3.1', dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6',
`inventory-structure:${dto.kind.toLowerCase()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null, family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
principal.userId, principal.userId,
])) as Array<{ id: string }>; ])) as Array<{ id: string }>;
const id = inserted[0]?.id; const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro estructural'); if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
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]);
}
const versionNumber = await this.history.capture( const versionNumber = await this.history.capture(
manager, manager,id,AssetVersionChangeType.CREATED,principal,request,
id,
AssetVersionChangeType.CREATED,
principal,
request,
); );
await manager.query(` await manager.query(`
INSERT INTO asset_context_history ( INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from, asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8) ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [ `, [
id, id,parent?.id ?? null,operationalAreaId,
parent?.id ?? null, dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
operationalAreaId, versionNumber,request.requestId,principal.userId,
operatorCompanyId,
'Alta guiada de Inventario estructural F3.1',
versionNumber,
request.requestId,
principal.userId,
]); ]);
const created = await this.loadView(manager, id); const created = await this.loadView(manager, id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED, action: AuditAction.ASSET_CREATED,
entityType: 'asset', entityType: 'asset',entityId: id,
entityId: id,
afterData: created as unknown as Record<string, unknown>, afterData: created as unknown as Record<string, unknown>,
metadata: { metadata: {
versionNumber, versionNumber,inventoryStructureKind: dto.kind,
inventoryStructureKind: dto.kind, inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
inventoryFamilyId: family?.id ?? null, operatorOwnership: false,
inventoryFamilyCode: family?.code ?? null,
}, },
}, manager); }, manager);
return created; return created;
}); });
} catch (error) { } catch (error) {
if (isUniqueViolation(error)) { if (isUniqueViolation(error)) throw new ConflictException({
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
code: 'ASSET_CODE_ALREADY_EXISTS', });
message: 'Ya existe un registro de Inventario con ese código',
});
}
throw error; throw error;
} }
} }
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> { private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const rows = (await manager.query(` const sql = kind === 'EMPRESA'
SELECT id,code,name FROM asset_types ? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1 : `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[]; const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
if (!rows[0]) { const rows = (await manager.query(sql,params)) as StructureTypeRow[];
throw new ConflictException({ if (!rows[0]) throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',message: `El nivel ${kind} no está configurado`,
message: `El nivel ${kind} no está configurado`, });
});
}
return rows[0]; return rows[0];
} }
@@ -259,24 +276,20 @@ export class InventoryStructureService {
): Promise<ParentRow | null> { ): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind]; const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) { if (!expectedType) {
if (parentId) { if (parentId) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
code: 'INVENTORY_AREA_MUST_BE_ROOT', message: kind === 'EMPRESA'
message: 'Un Área se crea como registro raíz y no puede tener padre', ? 'Una Empresa es un maestro independiente y no puede tener padre'
}); : 'Un Departamento es un registro raíz y no puede tener padre',
} });
return null; return null;
} }
if (!parentId) { if (!parentId) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED', message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`, });
});
}
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId" asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
@@ -285,15 +298,35 @@ export class InventoryStructureService {
`, [parentId])) as ParentRow[]; `, [parentId])) as ParentRow[];
const parent = rows[0]; const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' }); if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) { if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
code: 'INVENTORY_STRUCTURE_PARENT_INVALID', message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación', });
});
}
return parent; return parent;
} }
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
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
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
)
SELECT lineage.id
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
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',
});
return areaId;
}
private async requireFamily( private async requireFamily(
manager: EntityManager, manager: EntityManager,
kind: InventoryStructureKind, kind: InventoryStructureKind,
@@ -304,49 +337,59 @@ export class InventoryStructureService {
if (!expectedLevel) { if (!expectedLevel) {
if (familyId) throw new BadRequestException({ if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Área y Yacimiento no llevan familia técnica', message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
}); });
return null; return null;
} }
if (!familyId) throw new BadRequestException({ if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`, message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
}); });
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode", family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
family.information_labels AS "informationLabels", COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName" FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
FROM inventory_families family FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1 LIMIT 1
`, [familyId])) as FamilyRow[]; `, [familyId])) as FamilyRow[];
const family = rows[0]; const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' }); if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({ if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID', code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La familia técnica no corresponde al nivel seleccionado', message: 'La clasificación técnica no corresponde al nivel seleccionado',
}); });
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) { if (kind === 'SUBINSTALACION') {
throw new BadRequestException({ if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener una clasificación técnica válida',
});
const [compatible]=(await manager.query(`
SELECT 1 AS ok FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
if (!compatible) throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada', message: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
}); });
} }
return family; return family;
} }
private generatedCode(kind: InventoryStructureKind, name: string): string { private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; const prefix = kind === 'EMPRESA' ? 'EMP'
const readable = name : kind === 'DEPARTAMENTO' ? 'DEP'
.normalize('NFD') : kind === 'AREA' ? 'AREA'
.replace(/[\u0300-\u036f]/g, '') : kind === 'YACIMIENTO' ? 'YAC'
.toUpperCase() : kind === 'INSTALACION' ? 'INST'
.replace(/[^A-Z0-9]+/g, '-') : 'SUB';
.replace(/^-+|-+$/g, '') const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.slice(0, 48) || 'REGISTRO'; .replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120); return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
} }
@@ -356,6 +399,7 @@ export class InventoryStructureService {
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus", asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level, 'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels 'informationLabels',family.information_labels
@@ -364,6 +408,7 @@ export class InventoryStructureService {
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid WHERE asset.id=$1::uuid
`, [id]); `, [id]);
@@ -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}`});
}
}
+46 -10
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
export interface DashboardAuditItem { export interface DashboardInspectorActivityItem {
id: string; id: string;
occurredAt: Date; occurredAt: Date;
actorUsername: string | null; actorUsername: string | null;
@@ -19,6 +19,7 @@ interface DashboardCountsRow {
inactiveUsers: number | string; inactiveUsers: number | string;
activeSessions: number | string; activeSessions: number | string;
openFindings: number | string; openFindings: number | string;
actsInFollowUp: number | string;
findingsWithoutControlDate: number | string; findingsWithoutControlDate: number | string;
overdueControls: number | string; overdueControls: number | string;
controlsNext30Days: number | string; controlsNext30Days: number | string;
@@ -49,9 +50,14 @@ export class DashboardService {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => { return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [counts] = (await manager.query(` const [counts] = (await manager.query(`
SELECT SELECT
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets", (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation", (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry", (
SELECT COUNT(*) FROM assets asset
WHERE asset.is_inventory_instance=true
AND asset.information_status <> 'INACTIVE'
AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)
) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections", (SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers", (SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers", (SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
@@ -61,9 +67,22 @@ export class DashboardService {
WHERE revoked_at IS NULL WHERE revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP AND expires_at > CURRENT_TIMESTAMP
) AS "activeSessions", ) AS "activeSessions",
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
( (
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN' SELECT COUNT(*)
) AS "openFindings", FROM inspection_acts act
WHERE act.status IN ('SEALED','CLOSED','RECTIFIED')
AND NOT (
EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
)
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status='OPEN'
)
)
) AS "actsInFollowUp",
( (
SELECT COUNT(*) SELECT COUNT(*)
FROM inspection_findings finding FROM inspection_findings finding
@@ -115,7 +134,7 @@ export class DashboardService {
) AS "sealedActsWithoutReport" ) AS "sealedActsWithoutReport"
`)) as DashboardCountsRow[]; `)) as DashboardCountsRow[];
const recentAudit = (await manager.query(` const recentInspectorActivity = (await manager.query(`
SELECT SELECT
event.id, event.id,
event.occurred_at AS "occurredAt", event.occurred_at AS "occurredAt",
@@ -124,9 +143,25 @@ export class DashboardService {
event.entity_type AS "entityType", event.entity_type AS "entityType",
event.entity_id AS "entityId" event.entity_id AS "entityId"
FROM audit_events event FROM audit_events event
WHERE event.actor_user_id IS NOT NULL
AND (
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.user_id=event.actor_user_id AND member.included=true
)
OR EXISTS (
SELECT 1 FROM inspection_visits visit
WHERE visit.lead_inspector_user_id=event.actor_user_id
)
)
AND (
event.action LIKE 'INSPECTION_%'
OR event.action LIKE 'ASSET_%'
OR event.action LIKE 'FINDING_%'
)
ORDER BY event.occurred_at DESC, event.id DESC ORDER BY event.occurred_at DESC, event.id DESC
LIMIT 6 LIMIT 8
`)) as DashboardAuditItem[]; `)) as DashboardInspectorActivityItem[];
const upcomingControls = (await manager.query(` const upcomingControls = (await manager.query(`
SELECT SELECT
@@ -168,6 +203,7 @@ export class DashboardService {
inactiveUsers: Number(counts?.inactiveUsers ?? 0), inactiveUsers: Number(counts?.inactiveUsers ?? 0),
activeSessions: Number(counts?.activeSessions ?? 0), activeSessions: Number(counts?.activeSessions ?? 0),
openFindings: Number(counts?.openFindings ?? 0), openFindings: Number(counts?.openFindings ?? 0),
actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0), findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
overdueControls: Number(counts?.overdueControls ?? 0), overdueControls: Number(counts?.overdueControls ?? 0),
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0), controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
@@ -175,7 +211,7 @@ export class DashboardService {
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0), reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0), sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
}, },
recentAudit, recentInspectorActivity,
upcomingControls, upcomingControls,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}; };
@@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true }) @Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null; inventoryFamilyId!: string | null;
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
isInventoryInstance!: boolean;
@Column({ type: 'varchar', length: 120 }) @Column({ type: 'varchar', length: 120 })
code!: string; code!: string;
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F5InventoryPhysicalInstance1790087100000 implements MigrationInterface {
name = 'F5InventoryPhysicalInstance1790087100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN is_inventory_instance boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
CREATE INDEX idx_assets_inventory_instance_active
ON assets (is_inventory_instance, information_status)
WHERE is_inventory_instance = true
`);
await queryRunner.query(`
COMMENT ON COLUMN assets.is_inventory_instance IS
'True only for a concrete Instalacion/Subinstalacion instance. Empresa, Area and Yacimiento are structural/context masters.'
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION classify_new_asset_inventory_instance()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
type_code varchar;
BEGIN
SELECT lower(code)
INTO type_code
FROM asset_types
WHERE id = NEW.asset_type_id;
NEW.is_inventory_instance := COALESCE(type_code, '') IN ('instalacion', 'subinstalacion');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_classify_inventory_instance
BEFORE INSERT OR UPDATE OF asset_type_id ON assets
FOR EACH ROW EXECUTE FUNCTION classify_new_asset_inventory_instance()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_classify_inventory_instance ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS classify_new_asset_inventory_instance()');
await queryRunner.query('DROP INDEX IF EXISTS idx_assets_inventory_instance_active');
await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS is_inventory_instance');
}
}
@@ -0,0 +1,192 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type TypeRow = {
id: string;
code: string;
role: string;
active: boolean;
canBeRoot: boolean;
};
type RuleRow = {
childTypeId: string;
parentTypeId: string;
};
type CountRow = { total: number };
const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types';
const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules';
const CANONICAL_TYPES = [
{
code: 'yacimiento',
name: 'Yacimiento',
description: 'Yacimiento perteneciente a un Área.',
},
{
code: 'instalacion',
name: 'Instalación',
description: 'Instancia física de una Instalación dentro de un Yacimiento.',
},
{
code: 'subinstalacion',
name: 'Subinstalación',
description: 'Instancia física subordinada a una Instalación.',
},
] as const;
const CANONICAL_RULES = [
['yacimiento', 'area'],
['instalacion', 'yacimiento'],
['subinstalacion', 'instalacion'],
] as const;
export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface {
name = 'F5CanonicalInventoryHierarchy1790087150000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${CREATED_TYPES_TABLE} (
type_id uuid PRIMARY KEY,
code varchar(80) NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE TABLE ${CREATED_RULES_TABLE} (
child_type_id uuid NOT NULL,
parent_type_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (child_type_id,parent_type_id),
CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
const area = await this.requireType(queryRunner, 'area');
if (area.role !== 'AREA' || !area.active || !area.canBeRoot) {
throw new Error('F5 requires canonical active root type area with AREA operational role');
}
for (const definition of CANONICAL_TYPES) {
const inserted = (await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC'
WHERE NOT EXISTS (
SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)
)
RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
`, [definition.code, definition.name, definition.description])) as TypeRow[];
if (inserted[0]?.id) {
await queryRunner.query(`
INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code)
VALUES ($1::uuid,$2::varchar)
`, [inserted[0].id, definition.code]);
}
const type = await this.requireType(queryRunner, definition.code);
if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`);
}
}
for (const [childCode, parentCode] of CANONICAL_RULES) {
const inserted = (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)=lower($1::varchar)
AND lower(parent.code)=lower($2::varchar)
AND NOT EXISTS (
SELECT 1 FROM asset_type_parent_rules existing
WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id
)
RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId"
`, [childCode, parentCode])) as RuleRow[];
if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) {
await queryRunner.query(`
INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id)
VALUES ($1::uuid,$2::uuid)
`, [inserted[0].childTypeId, inserted[0].parentTypeId]);
}
}
const [verified] = (await queryRunner.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)='yacimiento' AND lower(parent.code)='area')
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
`)) as CountRow[];
if (Number(verified?.total ?? 0) !== 3) {
throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const [usedCreatedTypes] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id
`)) as CountRow[];
if (Number(usedCreatedTypes?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory');
}
const [foreignRules] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
WHERE (
rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
)
AND NOT EXISTS (
SELECT 1 FROM ${CREATED_RULES_TABLE} owned
WHERE owned.child_type_id=rule.child_type_id
AND owned.parent_type_id=rule.parent_type_id
)
`)) as CountRow[];
if (Number(foreignRules?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules');
}
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING ${CREATED_RULES_TABLE} owned
WHERE rule.child_type_id=owned.child_type_id
AND rule.parent_type_id=owned.parent_type_id
`);
await queryRunner.query(`
DELETE FROM asset_types type
USING ${CREATED_TYPES_TABLE} owned
WHERE type.id=owned.type_id
`);
await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`);
await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`);
}
private async requireType(queryRunner: QueryRunner, code: string): Promise<TypeRow> {
const rows = (await queryRunner.query(`
SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types
WHERE lower(code)=lower($1::varchar)
ORDER BY created_at
`, [code])) as TypeRow[];
if (rows.length !== 1) {
throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`);
}
return rows[0];
}
}
@@ -0,0 +1,602 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY';
const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx';
const BACKUP_TABLE = 'f5_territory_relation_backups';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function code(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function uniqueBy<T>(values: T[], identity: (value: T) => string): T[] {
const seen = new Set<string>();
const output: T[] = [];
for (const value of values) {
const id = identity(value);
if (seen.has(id)) continue;
seen.add(id);
output.push(value);
}
return output;
}
export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface {
name = 'F5AuthoritativeTerritory1790087200000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.areaSource.file !== TERRITORY_SOURCE_NAME
|| source.areaSource.sheet !== 'cr26e_tabla1'
|| source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78'
|| source.areaSource.rows.length !== 230
) {
throw new Error('F5 territory source contract mismatch');
}
const rows = source.areaSource.rows;
const areaRows = uniqueBy(rows, (row) => key(row.area));
const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`);
if (areaRows.length !== 64 || pairRows.length !== 230) {
throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`);
}
// Every Area must have one unambiguous source context. The workbook is the
// only authority for this preload; conflicting rows must abort the migration.
for (const areaRow of areaRows) {
const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area));
const dimensions = [
new Set(sameArea.map((row) => key(row.departamento))),
new Set(sameArea.map((row) => key(row.tipoConcesion))),
new Set(sameArea.map((row) => key(row.empresaOperadora))),
];
if (dimensions.some((values) => values.size !== 1)) {
throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`);
}
}
await this.assertCanonicalTypesAndRules(queryRunner);
await this.installHierarchyGuard(queryRunner);
await this.ensureBackupTable(queryRunner);
const preExistingDocument = await this.optionalId(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[TERRITORY_DOCUMENT_NUMBER],
);
if (preExistingDocument) {
throw new Error('F5 territory source document already exists before migration');
}
const insertedDocument = (await queryRunner.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_DOCUMENT_NUMBER,
TERRITORY_SOURCE_NAME,
`sha256:${source.areaSource.sha256}`,
`F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`,
])) as IdRow[];
const sourceDocumentId = insertedDocument[0]?.id;
if (!sourceDocumentId) throw new Error('F5 could not create territory source document');
const companyTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`,
[],
'COMPANY asset type',
);
const areaTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`,
[],
'AREA asset type',
);
const fieldTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`,
[],
'Yacimiento asset type',
);
const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))]
.filter((name) => name && key(name) !== key('Sin Empresa Operadora'))
.sort((a, b) => a.localeCompare(b, 'es'));
const companyIds = new Map<string, string>();
for (const companyName of companyNames) {
const companyId = await this.ensureRootAsset(queryRunner, {
typeId: companyTypeId,
role: 'COMPANY',
code: code('F5-ORG', key(companyName)),
name: companyName,
sourceDocumentId,
sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`,
});
companyIds.set(key(companyName), companyId);
await queryRunner.query(`
INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
VALUES ($1::uuid,$2::organization_kind,$3)
ON CONFLICT (asset_id) DO NOTHING
`, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]);
}
const departmentIds = new Map<string, string>();
for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) {
const normalized = key(departmentName);
let departmentId = await this.optionalId(queryRunner, `
SELECT id FROM administrative_departments
WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true
LIMIT 1
`, [normalized]);
if (!departmentId) {
const inserted = (await queryRunner.query(`
INSERT INTO administrative_departments (
province_code,code,name,normalized_name,is_active,source_document_id
) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid)
RETURNING id
`, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[];
departmentId=inserted[0]?.id ?? null;
}
if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`);
departmentIds.set(normalized, departmentId);
}
const areaIds = new Map<string, string>();
for (const areaRow of areaRows) {
const areaId = await this.ensureRootAsset(queryRunner, {
typeId: areaTypeId,
role: 'AREA',
code: code('F5-AREA', key(areaRow.area)),
name: areaRow.area,
sourceDocumentId,
sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`,
});
areaIds.set(key(areaRow.area), areaId);
const departmentId = departmentIds.get(key(areaRow.departamento));
if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`);
await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId);
await queryRunner.query(`
INSERT INTO area_department_relations (
area_id,department_id,valid_from,source_document_id,notes
)
SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4
WHERE NOT EXISTS (
SELECT 1 FROM area_department_relations
WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL
)
`, [
areaId,
departmentId,
sourceDocumentId,
`F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`,
]);
const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null;
await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId);
if (operatorId) {
await queryRunner.query(`
INSERT INTO area_company_relations (
area_id,company_id,relation_role,source_document_id,valid_from,start_reason
)
SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4
WHERE NOT EXISTS (
SELECT 1 FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid
AND relation_role='OPERATOR' AND valid_until IS NULL
)
`, [
areaId,
operatorId,
sourceDocumentId,
`F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`,
]);
}
const rightType = areaRow.tipoConcesion === 'Exploración'
? 'EXPLORATION_PERMIT'
: areaRow.tipoConcesion === 'Explotación'
? 'EXPLOITATION_CONCESSION'
: 'OTHER';
const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`;
await queryRunner.query(`
INSERT INTO area_legal_rights (
area_id,right_type,name,status,source_document_id,notes
)
SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5
WHERE NOT EXISTS (
SELECT 1 FROM area_legal_rights
WHERE area_id=$1::uuid
AND right_type=$2::area_legal_right_type
AND lower(btrim(name))=lower(btrim($3::varchar))
AND status IN ('ACTIVE','PENDING')
)
`, [
areaId,
rightType,
rightName,
sourceDocumentId,
`F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`,
]);
}
for (const row of pairRows) {
const areaId = areaIds.get(key(row.area));
if (!areaId) throw new Error(`F5 missing area ${row.area}`);
const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`;
let yacimientoId = await this.optionalId(queryRunner, `
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)='yacimiento'
AND asset.parent_id=$1::uuid
AND asset.information_status<>'INACTIVE'
AND lower(btrim(asset.name))=lower(btrim($2))
ORDER BY asset.created_at
LIMIT 1
`, [areaId, row.yacimiento]);
if (!yacimientoId) {
const inserted = (await queryRunner.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,
code,name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,is_inventory_instance
) VALUES (
$1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN',
'PROVIDED_DOCUMENT',$6,$7,$8,false
) RETURNING id
`, [
fieldTypeId,
areaId,
code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`),
row.yacimiento,
`Yacimiento del Área ${row.area}`,
TERRITORY_SOURCE_NAME,
sourceReference,
`${source.areaSource.sheet} · fila ${row.sourceRow}`,
])) as IdRow[];
yacimientoId = inserted[0]?.id ?? null;
}
if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`);
await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`);
}
const [verification] = (await queryRunner.query(`
SELECT
COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas,
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos
FROM asset_source_documents link
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE link.document_id=$1::uuid
`, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>;
if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) {
throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const sourceDocumentId = await this.optionalId(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[TERRITORY_DOCUMENT_NUMBER],
);
if (!sourceDocumentId) {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
return;
}
await this.assertRelationBackupsUnchanged(queryRunner);
await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId);
await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
// Remove relations created by F5 first so restoring the previously-active
// relation cannot violate active-relation uniqueness constraints.
await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await this.restoreRelationBackups(queryRunner);
await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`);
await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`);
await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
}
private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise<void> {
const [roles] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas,
COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies
FROM asset_types
`)) as Array<{areas:number; companies:number}>;
if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) {
throw new Error('F5 requires active AREA and COMPANY master types');
}
for (const [typeCode,typeName,description] of [
['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'],
['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'],
['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'],
] as const) {
await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2,$3,false,true,'GENERIC'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar))
`,[typeCode,typeName,description]);
const [type] = (await queryRunner.query(`
SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types WHERE lower(code)=lower($1) LIMIT 1
`,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>;
if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible master type configuration: ${typeCode}`);
}
}
const [ruleCount] = (await queryRunner.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)='yacimiento' AND lower(parent.code)='area')
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
`)) as CountRow[];
if (Number(ruleCount?.total ?? 0)!==3) {
throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación');
}
}
private async installHierarchyGuard(queryRunner: QueryRunner): Promise<void> {
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','area') THEN
IF NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes';
END IF;
RETURN NEW;
END IF;
IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF;
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre';
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='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 F5 inválida: Área → Yacimiento → Instalación → Subinstalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
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()
`);
}
private async ensureBackupTable(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${BACKUP_TABLE} (
relation_kind varchar(32) NOT NULL,
relation_id uuid NOT NULL,
previous_values jsonb NOT NULL,
applied_values jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (relation_kind,relation_id),
CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT'))
)
`);
}
private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise<void> {
const marker='F5: reemplazada por fuente territorial autorizada';
await queryRunner.query(`
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
SELECT 'AREA_DEPARTMENT',relation.id,
jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes),
jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3::text))
FROM area_department_relations relation
WHERE relation.area_id=$1::uuid
AND relation.valid_until IS NULL
AND relation.department_id<>$2::uuid
ON CONFLICT DO NOTHING
`,[areaId,departmentId,marker]);
await queryRunner.query(`
UPDATE area_department_relations relation
SET valid_until=(backup.applied_values->>'validUntil')::date,
notes=backup.applied_values->>'notes',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_DEPARTMENT'
AND backup.relation_id=relation.id
AND relation.area_id=$1::uuid
AND relation.valid_until IS NULL
`,[areaId]);
}
private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise<void> {
const marker='F5: reemplazada por fuente territorial autorizada';
await queryRunner.query(`
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
SELECT 'AREA_COMPANY',relation.id,
jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason),
jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3::text)
FROM area_company_relations relation
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid)
ON CONFLICT DO NOTHING
`,[areaId,operatorId,marker]);
await queryRunner.query(`
UPDATE area_company_relations relation
SET valid_until=(backup.applied_values->>'validUntil')::timestamptz,
end_reason=backup.applied_values->>'endReason',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_COMPANY'
AND backup.relation_id=relation.id
AND relation.area_id=$1::uuid
AND relation.valid_until IS NULL
`,[areaId]);
}
private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise<void> {
const [changed] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM ${BACKUP_TABLE} backup
LEFT JOIN area_company_relations company_relation
ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id
LEFT JOIN area_department_relations department_relation
ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id
WHERE (
backup.relation_kind='AREA_COMPANY'
AND (
company_relation.id IS NULL
OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz
OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason'
)
) OR (
backup.relation_kind='AREA_DEPARTMENT'
AND (
department_relation.id IS NULL
OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date
OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes'
)
)
`)) as CountRow[];
if (Number(changed?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards');
}
}
private async restoreRelationBackups(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
UPDATE area_company_relations relation
SET valid_until=(backup.previous_values->>'validUntil')::timestamptz,
end_reason=backup.previous_values->>'endReason',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id
`);
await queryRunner.query(`
UPDATE area_department_relations relation
SET valid_until=(backup.previous_values->>'validUntil')::date,
notes=backup.previous_values->>'notes',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id
`);
}
private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise<void> {
const [used] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
WHERE asset.source_reference LIKE 'F5:TERRITORY:%'
AND (
EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%')
OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id)
OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id)
OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id)
)
`)) as CountRow[];
if (Number(used?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records');
}
void sourceDocumentId;
}
private async ensureRootAsset(
queryRunner: QueryRunner,
input: {
typeId: string;
role: 'AREA' | 'COMPANY';
code: string;
name: string;
sourceDocumentId: string;
sourceReference: string;
},
): Promise<string> {
let assetId = await this.optionalId(queryRunner, `
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role=$1::asset_type_operational_role
AND asset.information_status<>'INACTIVE'
AND lower(btrim(asset.name))=lower(btrim($2))
ORDER BY asset.created_at
LIMIT 1
`,[input.role,input.name]);
if (!assetId) {
const inserted = (await queryRunner.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,
code,name,information_status,operational_status,data_origin,
source_name,source_reference,source_notes,is_inventory_instance
) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false)
RETURNING id
`,[
input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference,
'F5 · fuente territorial autorizada',
])) as IdRow[];
assetId=inserted[0]?.id ?? null;
}
if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`);
await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada');
return assetId;
}
private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void> {
await queryRunner.query(`
INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
VALUES ($1::uuid,$2::uuid,'SOURCE',$3)
ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP
`,[assetId,documentId,notes]);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const result=(await queryRunner.query(sql,params)) as IdRow[];
return result[0]?.id ?? null;
}
}
@@ -0,0 +1,182 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a
* required property of Yacimiento/Instalación/Subinstalación. The current and
* historical operator/concession truth lives in area_company_relations and is
* frozen separately by each Inspección/Acta.
*
* operator_company_id is retained only as a backwards-compatible creation/
* historical snapshot. Runtime ownership and search MUST NOT depend on it.
*/
export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface {
name = 'F5OperationalContextCompatibility1790087250000';
public async up(queryRunner: QueryRunner): Promise<void> {
await this.installAreaOwnedGuard(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await this.installLegacyPairedGuard(queryRunner);
}
private async installAreaOwnedGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
SELECT operational_role INTO asset_role
FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Área y Empresa no reciben contexto operativo de Inventario';
END IF;
RETURN NEW;
END IF;
IF NEW.operator_company_id IS NOT NULL AND NEW.operational_area_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Un snapshot de Empresa requiere un Área física';
END IF;
-- Once written, an old/current company snapshot cannot be repointed to
-- simulate physical ownership. Company changes happen in the temporal
-- Area↔Empresa relation instead.
IF TG_OP='UPDATE'
AND NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='La Empresa se cambia en la relación temporal del Área, no en el Inventario';
END IF;
IF NEW.operational_area_id IS NULL THEN
RETURN NEW;
END IF;
SELECT type.operational_role INTO area_role
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id
AND area.information_status<>'INACTIVE'
AND type.is_active=true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area must be an active AREA asset';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=NEW.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=NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area must be an ancestor in the physical hierarchy';
END IF;
-- A company value is allowed only as the context snapshot that was valid
-- at creation time. It is never used to decide future membership.
IF NEW.operator_company_id IS NOT NULL THEN
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='operator snapshot must reference an active COMPANY-role asset';
END IF;
IF TG_OP='INSERT' THEN
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='creation operator snapshot must be active for the selected Area';
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
`);
}
/** Restores the production F4-era paired Area+Empresa guard on rollback. */
private async installLegacyPairedGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN
RETURN NEW;
END IF;
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area and organization must be assigned together';
END IF;
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment';
END IF;
SELECT type.operational_role INTO area_role
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id AND area.information_status<>'INACTIVE' AND type.is_active=true;
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 area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset';
END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset';
END IF;
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=NEW.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=NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy';
END IF;
RETURN NEW;
END $$;
`);
}
}
@@ -0,0 +1,606 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
loadF5InventoryAuthoritativeSource,
type F5InstallationCatalogRow,
type F5SubinstallationCatalogRow,
} from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
const CATALOG_CATEGORY_CODE = 'F5MODEL';
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx';
const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático';
const F5_SOURCE_FAMILY_COUNT = 123;
function findingKey(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function hashCode(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function installationCode(name: string): string {
return hashCode('F5-I', findingKey(name));
}
function subinstallationCode(installation: string, name: string): string {
return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`);
}
function subOtherCode(parentCode: string): string {
return hashCode('F5-S-OTRO', parentCode);
}
export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface {
name = 'F5AuthoritativeInventoryCatalog1790087300000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.catalogSource.file !== CATALOG_SOURCE_NAME
|| source.catalogSource.sheet !== 'Hoja1'
|| source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'
|| source.catalogSource.installations.length !== 14
|| source.catalogSource.subinstallations.length !== 109
) {
throw new Error('F5 inventory catalog source contract mismatch');
}
if (source.catalogSource.universalFindings.length !== 3) {
throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`);
}
const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey));
for (const required of [
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
]) {
if (!universalKeys.has(findingKey(required))) {
throw new Error(`F5 missing authoritative universal finding: ${required}`);
}
}
await queryRunner.query(`
INSERT INTO source_documents (
document_type,document_number,title,issuer,external_reference,notes
)
VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL
DO UPDATE SET
title=EXCLUDED.title,
external_reference=EXCLUDED.external_reference,
notes=EXCLUDED.notes,
updated_at=CURRENT_TIMESTAMP
`, [
CATALOG_DOCUMENT_NUMBER,
CATALOG_SOURCE_NAME,
`sha256:${source.catalogSource.sha256}`,
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]);
// Only the known historical spreadsheet catalog is superseded. Families
// created manually by DH (including source_reference NULL) remain untouched.
await queryRunner.query(`
UPDATE inventory_families
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code) IN ('app26','app26r2')
`);
const installationIds = new Map<string,string>();
for (const installation of source.catalogSource.installations) {
const familyId = await this.upsertFamily(
queryRunner,
installationCode(installation.name),
installation.name,
'INSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`,
);
installationIds.set(findingKey(installation.name),familyId);
}
for (const subinstallation of source.catalogSource.subinstallations) {
const parentId = installationIds.get(findingKey(subinstallation.installation));
if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`);
const childId = await this.upsertFamily(
queryRunner,
subinstallationCode(subinstallation.installation,subinstallation.name),
subinstallation.name,
'SUBINSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const installationOtherId = await this.upsertFamily(
queryRunner,
'F5-I-OTRO',
'Otro / no catalogado',
'INSTALLATION',
'F5:SYSTEM:OTHER:INSTALLATION',
);
for (const [installationKey,parentId] of installationIds) {
const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey);
if (!parent) continue;
const childId = await this.upsertFamily(
queryRunner,
subOtherCode(installationCode(parent.name)),
'Otro / no catalogado',
'SUBINSTALLATION',
`F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const rootOtherChild = await this.upsertFamily(
queryRunner,
subOtherCode('F5-I-OTRO'),
'Otro / no catalogado',
'SUBINSTALLATION',
'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO',
);
await this.parentRule(queryRunner,rootOtherChild,installationOtherId);
await queryRunner.query(`
INSERT INTO finding_categories(code,name,sort_order,is_active)
SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar))
`, [CATALOG_CATEGORY_CODE]);
await queryRunner.query(`
UPDATE finding_categories
SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)=lower($1::varchar)
`,[CATALOG_CATEGORY_CODE]);
const categoryId = await this.id(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
'F5 finding category',
);
const titleByKey = new Map<string,string>();
const register = (title: string): void => {
const clean = title.trim();
if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return;
const itemKey = findingKey(clean);
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean);
};
for (const title of source.catalogSource.universalFindings) register(title);
for (const family of source.catalogSource.installations) for (const title of family.findings) register(title);
for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title);
if (titleByKey.size !== 177) {
throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`);
}
const itemIdByKey = new Map<string,string>();
const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es'));
let sourceNumber=1;
for (const [itemKey,title] of orderedTitles) {
const itemCode = hashCode('F5-H',itemKey);
let itemId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[itemCode],
);
if (!itemId) {
const itemRows = (await queryRunner.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
`,[
categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[];
itemId=itemRows[0]?.id ?? null;
} else {
await queryRunner.query(`
UPDATE finding_catalog_items
SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5,
is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
itemId,categoryId,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
]);
}
if (!itemId) throw new Error(`F5 could not create finding ${title}`);
itemIdByKey.set(itemKey,itemId);
await queryRunner.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',category.id,'categoryCode',category.code,
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
),'migration:F5'
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM finding_catalog_item_versions version
WHERE version.item_id=item.id AND version.revision=item.revision
)
`,[itemId]);
sourceNumber+=1;
}
// Add F5 mappings only. Never delete mappings created by office users or by
// historical migrations; inactive historical families simply stop being offered.
for (const family of source.catalogSource.installations) {
await this.mapFindings(
queryRunner,
installationCode(family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
for (const family of source.catalogSource.subinstallations) {
await this.mapFindings(
queryRunner,
subinstallationCode(family.installation,family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
await this.installFamilySyncFunctions(queryRunner);
// Keep pre-existing profile administration untouched. Yacimiento needs a
// profile only to expose OTROS because the source does not provide a family.
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
FROM asset_types WHERE lower(code)='yacimiento'
ON CONFLICT (asset_type_id) DO NOTHING
`);
const [counts] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations,
COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
FROM inventory_families WHERE is_active=true
`)) as Array<{ installations:number; subinstallations:number }>;
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`);
}
const [itemCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
}
// Verify every universal finding is independently attached to every one of
// the 14 + 109 source families. This intentionally avoids optional DB text
// extensions such as unaccent.
for (const universalTitle of source.catalogSource.universalFindings) {
const universalItemId = itemIdByKey.get(findingKey(universalTitle));
if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`);
const [mappedCount] = (await queryRunner.query(`
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
FROM finding_catalog_item_inventory_families mapping
JOIN inventory_families family ON family.id=mapping.inventory_family_id
WHERE mapping.catalog_item_id=$1::uuid
AND family.is_active=true
AND family.source_reference LIKE $2
`,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[];
if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) {
throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const categoryId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
);
if (categoryId) {
const [usedFinding] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
JOIN finding_catalog_items item ON item.id=finding.catalog_item_id
WHERE item.category_id=$1::uuid
`,[categoryId])) as CountRow[];
if (Number(usedFinding?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items');
}
}
const [usedFamily] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE family.source_reference LIKE 'F5:%'
`)) as CountRow[];
if (Number(usedFamily?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families');
}
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason=$1::text
`,[F5_AUTO_REASON]);
if (categoryId) {
await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping
USING finding_catalog_items item
WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`
DELETE FROM finding_catalog_item_versions version
USING finding_catalog_items item
WHERE item.id=version.item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]);
}
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_families child
WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%'
`);
await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`);
await queryRunner.query(`
UPDATE inventory_families
SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2'
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_type_profiles profile
USING asset_types type
WHERE profile.asset_type_id=type.id
AND lower(type.code)='yacimiento'
AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
`);
await queryRunner.query(`
DELETE FROM source_documents
WHERE document_number=$1::varchar AND issuer='Dirección de Hidrocarburos'
`,[CATALOG_DOCUMENT_NUMBER]);
await this.restoreF31FamilySyncFunctions(queryRunner);
// Rebuild only automatic historical overrides. Manual overrides have never
// been touched by this migration.
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F3.1 familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP
`);
}
private async upsertFamily(
queryRunner: QueryRunner,
familyCode: string,
name: string,
level: 'INSTALLATION'|'SUBINSTALLATION',
sourceReference: string,
): Promise<string> {
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
is_active=true,updated_at=CURRENT_TIMESTAMP
`,[familyCode,name,level,sourceReference]);
return this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`inventory family ${familyCode}`,
);
}
private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise<void> {
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
`,[childId,parentId]);
}
private async mapFindings(
queryRunner: QueryRunner,
familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>,
): Promise<void> {
const familyId = await this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`family ${familyCode}`,
);
const mapped = new Set<string>();
for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey);
const itemId=itemIdByKey.get(itemKey);
if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`);
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[itemId,familyId]);
}
}
private async installFamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.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 'F% 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,mapping.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.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 override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F% 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 asset.id,NEW.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,$1::text,
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason=$1::text,updated_at=CURRENT_TIMESTAMP
`,[F5_AUTO_REASON]);
}
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.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 'F3.1 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,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.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 override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F3.1 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 asset.id,NEW.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const rows=(await queryRunner.query(sql,params)) as IdRow[];
return rows[0]?.id ?? null;
}
}
@@ -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)
`);
}
}
@@ -1,36 +1,57 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable() @Injectable()
export class F3FindingCatalogResolverService { export class F3FindingCatalogResolverService {
constructor( constructor(private readonly dataSource: DataSource) {}
private readonly dataSource: DataSource,
private readonly legacyCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(` const [asset] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name, SELECT asset.id,asset.code,asset.name,
type.code AS "typeCode",
asset.inventory_family_id AS "familyId", asset.inventory_family_id AS "familyId",
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel" family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel",
family.is_active AS "familyActive"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid WHERE asset.id=$1::uuid
`, [assetId]) as Array<{ `, [assetId]) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeCode: string;
familyId: string | null; familyId: string | null;
familyCode: string | null; familyCode: string | null;
familyName: string | null; familyName: string | null;
familyLevel: string | null; familyLevel: string | null;
familyActive: boolean | null;
}>; }>;
if (!asset) { if (!asset) {
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
} }
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
const other = {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. Quedará registrado para revisión en oficina.',
};
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'El Yacimiento es contexto estructural y no posee clasificación técnica. Usá OTROS sólo si necesitás registrar una observación excepcional.'
: 'El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
};
}
const filters = [ const filters = [
'mapping.inventory_family_id=$1::uuid', 'mapping.inventory_family_id=$1::uuid',
@@ -83,15 +104,10 @@ export class F3FindingCatalogResolverService {
level: asset.familyLevel, level: asset.familyLevel,
}, },
typeConfigured: true, typeConfigured: true,
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`, configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories, categories,
items, items,
other: { other,
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.',
},
}; };
} }
} }
@@ -44,6 +44,8 @@ const STRUCTURAL_CHILD: Record<string, string | undefined> = {
instalacion: 'subinstalacion', instalacion: 'subinstalacion',
}; };
const OTHER_FAMILY_SQL = "source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR source_reference LIKE 'F5:SYSTEM:OTHER:%'";
@Injectable() @Injectable()
export class F3FieldInventoryStructureService { export class F3FieldInventoryStructureService {
constructor( constructor(
@@ -122,7 +124,20 @@ export class F3FieldInventoryStructureService {
asset: { id: string; code: string; name: string }; asset: { id: string; code: string; name: string };
[key: string]: unknown; [key: string]: unknown;
}; };
if (!family) return created;
// Área/Yacimiento remain structural context. A concrete Installation/Subinstallation
// created in field is a real Inventory instance from the moment it is registered.
await this.dataSource.query(`
UPDATE assets
SET is_inventory_instance=$3::boolean,updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [
created.asset.id,
principal.userId,
typeCode === 'instalacion' || typeCode === 'subinstalacion',
]);
if (!family) return this.fieldInventory.detail(visitId, created.asset.id, principal);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const [current] = await manager.query(` const [current] = await manager.query(`
@@ -189,10 +204,10 @@ export class F3FieldInventoryStructureService {
return this.dataSource.query(` return this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels", SELECT id,code,name,level,information_labels AS "informationLabels",
source_reference AS "sourceReference", source_reference AS "sourceReference",
(source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther" (${OTHER_FAMILY_SQL}) AS "isOther"
FROM inventory_families FROM inventory_families
WHERE level='INSTALLATION' AND is_active=true WHERE level='INSTALLATION' AND is_active=true
ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code ORDER BY (${OTHER_FAMILY_SQL}) ASC,name,code
`) as Promise<FamilyRow[]>; `) as Promise<FamilyRow[]>;
} }
if (expectedTypeCode === 'subinstalacion') { if (expectedTypeCode === 'subinstalacion') {
@@ -206,13 +221,13 @@ export class F3FieldInventoryStructureService {
SELECT family.id,family.code,family.name,family.level, SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels", family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference", family.source_reference AS "sourceReference",
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther" (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') AS "isOther"
FROM inventory_family_parent_rules rule FROM inventory_family_parent_rules rule
JOIN inventory_families family ON family.id=rule.child_family_id JOIN inventory_families family ON family.id=rule.child_family_id
WHERE rule.parent_family_id=$1::uuid WHERE rule.parent_family_id=$1::uuid
AND family.level='SUBINSTALLATION' AND family.level='SUBINSTALLATION'
AND family.is_active=true AND family.is_active=true
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') ASC,family.name,family.code
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>; `, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
} }
return []; return [];
@@ -138,8 +138,31 @@ export class FieldFindingsService {
asset.id AS "assetId", asset.id AS "assetId",
asset_type.code AS "assetTypeCode", asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId", asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "assetAreaId", (
asset.operator_company_id AS "assetCompanyId", asset.operational_area_id=visit.operational_area_id
OR asset.id=visit.operational_area_id
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
)
) AS "insideArea",
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid",
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM inspection_visit_members member FROM inspection_visit_members member
@@ -199,8 +222,8 @@ export class FieldFindingsService {
assetId: string | null; assetId: string | null;
assetTypeCode: string | null; assetTypeCode: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
assetAreaId: string | null; insideArea: boolean;
assetCompanyId: string | null; operatorRelationValid: boolean;
assigned: boolean; assigned: boolean;
selected: boolean; selected: boolean;
captureRequired: boolean; captureRequired: boolean;
@@ -230,6 +253,12 @@ export class FieldFindingsService {
message: 'La inspección no tiene Área y Operadora definidas', message: 'La inspección no tiene Área y Operadora definidas',
}); });
} }
if (!row.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!row.assetId) { if (!row.assetId) {
throw new NotFoundException({ throw new NotFoundException({
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND', code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
@@ -245,13 +274,13 @@ export class FieldFindingsService {
if (!row.inventoryFamilyId) { if (!row.inventoryFamilyId) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED', code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos', message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
}); });
} }
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) { if (!row.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Área y Operadora de esta inspección', message: 'El Inventario no pertenece al Área de esta inspección',
}); });
} }
if (!row.selected) { if (!row.selected) {
@@ -29,6 +29,7 @@ interface MobileVisitContext {
companyCode: string; companyCode: string;
companyName: string; companyName: string;
assigned: boolean; assigned: boolean;
operatorRelationValid: boolean;
} }
interface FieldDiscoveryCreated { interface FieldDiscoveryCreated {
@@ -79,11 +80,23 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context); if (query.parentId) await this.requireParentInContext(query.parentId, context);
const args: unknown[] = [context.areaId, context.companyId, visitId]; // Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.areaId, visitId];
const conditions = [ const conditions = [
'asset.operational_area_id = $1::uuid',
'asset.operator_company_id = $2::uuid',
"asset.information_status <> 'INACTIVE'", "asset.information_status <> 'INACTIVE'",
`(
asset.operational_area_id = $1::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=$1::uuid LIMIT 1
)
)`,
"(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')",
]; ];
if (query.search?.trim()) { if (query.search?.trim()) {
@@ -129,13 +142,13 @@ export class FieldInventoryService {
) END AS parent, ) END AS parent,
EXISTS ( EXISTS (
SELECT 1 FROM inspection_visit_assets link SELECT 1 FROM inspection_visit_assets link
WHERE link.visit_id = $3::uuid WHERE link.visit_id = $2::uuid
AND link.asset_id = asset.id AND link.asset_id = asset.id
AND link.included = true AND link.included = true
) AS "selectedInInspection", ) AS "selectedInInspection",
EXISTS ( EXISTS (
SELECT 1 FROM asset_field_discoveries discovery SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
) AS "captureRequired", ) AS "captureRequired",
EXISTS ( EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
@@ -143,26 +156,26 @@ export class FieldInventoryService {
( (
SELECT COUNT(*)::integer SELECT COUNT(*)::integer
FROM asset_field_capture_events capture FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO' AND capture.event_type = 'PHOTO'
) AS "fieldPhotoCount", ) AS "fieldPhotoCount",
( (
NOT EXISTS ( NOT EXISTS (
SELECT 1 FROM asset_field_discoveries discovery SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
) )
OR ( OR (
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
AND EXISTS ( AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id AND capture.asset_id = asset.id
AND capture.event_type = 'CREATED' AND capture.event_type = 'CREATED'
) )
AND EXISTS ( AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO' AND capture.event_type = 'PHOTO'
) )
@@ -279,6 +292,7 @@ export class FieldInventoryService {
typeId: dto.typeId, typeId: dto.typeId,
parentId, parentId,
operationalAreaId: context.areaId, operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId, operatorCompanyId: context.companyId,
description: dto.description ?? null, description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null, discoveryNotes: dto.discoveryNotes ?? null,
@@ -413,7 +427,19 @@ export class FieldInventoryService {
AND member.user_id = $2::uuid AND member.user_id = $2::uuid
AND member.included = true AND member.included = true
) )
) AS assigned ) AS assigned,
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"
FROM inspection_visits visit FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id LEFT JOIN assets company ON company.id = visit.operator_company_id
@@ -429,6 +455,12 @@ export class FieldInventoryService {
message: 'La inspección no tiene Área y Operadora definidas', message: 'La inspección no tiene Área y Operadora definidas',
}); });
} }
if (!context.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!context.assigned) { if (!context.assigned) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED', code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
@@ -469,18 +501,27 @@ export class FieldInventoryService {
asset.code, asset.code,
asset.name, asset.name,
asset.asset_type_id AS "typeId", asset.asset_type_id AS "typeId",
asset.operational_area_id AS "areaId", (
asset.operator_company_id AS "companyId" 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
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=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset FROM assets asset
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [parentId])) as Array<{ `, [parentId, context.areaId])) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeId: string; typeId: string;
areaId: string | null; insideArea: boolean;
companyId: string | null;
}>; }>;
if (!parent) { if (!parent) {
@@ -489,13 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe', message: 'La ubicación padre no existe',
}); });
} }
if ( if (!parent.insideArea) {
parent.id !== context.areaId
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área y Operadora de la inspección', message: 'La ubicación padre no pertenece al Área de la inspección',
}); });
} }
return parent; return parent;
@@ -503,16 +541,28 @@ export class FieldInventoryService {
private async requireAssetInContext(assetId: string, context: MobileVisitContext) { private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
const [asset] = (await this.dataSource.query(` const [asset] = (await this.dataSource.query(`
SELECT id, SELECT
operational_area_id AS "areaId", asset.id,
operator_company_id AS "companyId" (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
FROM assets (
WHERE id = $1::uuid asset.operational_area_id=$2::uuid
AND information_status <> 'INACTIVE' OR EXISTS (
`, [assetId])) as Array<{ 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=$2::uuid LIMIT 1
)
) AS "insideArea"
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<{
id: string; id: string;
areaId: string | null; eligibleTarget: boolean;
companyId: string | null; insideArea: boolean;
}>; }>;
if (!asset) { if (!asset) {
@@ -521,10 +571,16 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) { if (!asset.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área y Operadora de esta inspección', message: 'El registro no pertenece al Área de esta inspección',
});
}
if (!asset.eligibleTarget) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET',
message: 'El registro no es un Yacimiento ni una instancia real de Inventario',
}); });
} }
return asset; return asset;
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.25.0-1'; export const API_VERSION = '0.28.0-1';
export const API_PHASE = 'F4'; export const API_PHASE = 'F6';
@@ -7,13 +7,15 @@ async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
} }
test('D5.3.11.1 exposes imported operational assignments in Maestro navigation without creating legal relations', async () => { test('D5.3.11.1/F5 navigates Area ↔ Empresa from temporal operator relations instead of Inventory ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts'); const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /SELECT asset\.operator_company_id AS company_id/); assert.match(service, /FROM area_company_relations relation/);
assert.match(service, /WHERE asset\.operational_area_id = \$1/); assert.match(service, /WHERE relation\.area_id = \$1/);
assert.match(service, /SELECT asset\.operational_area_id AS area_id/); assert.match(service, /WHERE relation\.company_id = \$1/);
assert.match(service, /WHERE asset\.operator_company_id = \$1/); assert.match(service, /relation\.relation_role = 'OPERATOR'/);
assert.match(service, /UNION/); assert.match(service, /relation\.valid_until IS NULL/);
assert.doesNotMatch(service, /SELECT asset\.operator_company_id AS company_id/);
assert.doesNotMatch(service, /SELECT asset\.operational_area_id AS area_id/);
assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/); assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/);
}); });
@@ -33,11 +33,13 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
}); });
test('D5.3 relation lifecycle is historical and audited', async () => { test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts'); const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/); assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/); assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /AREA_COMPANY_RELATION_IN_USE/); assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
}); });
+17 -14
View File
@@ -2,27 +2,28 @@ import { strict as assert } from 'node:assert';
import test from 'node:test'; import test from 'node:test';
import { DashboardService } from '../../src/dashboard/dashboard.service'; import { DashboardService } from '../../src/dashboard/dashboard.service';
test('DashboardService returns F4 operational counts and recent activity consistently', async () => { test('DashboardService returns F5 operational counts and inspector activity consistently', async () => {
const recent = [{ const recent = [{
id: 'event-id', id: 'event-id',
occurredAt: new Date('2026-08-13T12:00:00.000Z'), occurredAt: new Date('2026-09-08T12:00:00.000Z'),
actorUsername: 'admin', actorUsername: 'inspector1',
action: 'AUTH_LOGIN_SUCCESS', action: 'INSPECTION_STARTED',
entityType: 'auth_session', entityType: 'inspection_visit',
entityId: 'session-id', entityId: 'visit-id',
}]; }];
const manager = { const manager = {
query: async (sql: string) => { query: async (sql: string) => {
if (sql.includes('COUNT(*) FROM users')) { if (sql.includes('COUNT(*) FROM users')) {
return [{ return [{
activeUsers: '4', inactiveUsers: '2', activeSessions: '3', activeUsers: '4', inactiveUsers: '2', activeSessions: '3',
totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2', totalAssets: '0', assetsNeedValidation: '0', assetsWithoutGeometry: '0', plannedInspections: '2',
openFindings: '8', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3', openFindings: '8', actsInFollowUp: '6', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3',
reportsWorking: '1', reportsOfficialized: '2', sealedActsWithoutReport: '4', reportsWorking: '1', reportsOfficialized: '2', sealedActsWithoutReport: '4',
}]; }];
} }
if (sql.includes('FROM inspection_findings finding')) return []; if (sql.includes('FROM audit_events event')) return recent;
return recent; if (sql.includes('next_control_on AS "nextControlOn"')) return [];
throw new Error(`Unexpected DashboardService query in test: ${sql}`);
}, },
}; };
const dataSource = { const dataSource = {
@@ -31,14 +32,15 @@ test('DashboardService returns F4 operational counts and recent activity consist
const result = await new DashboardService(dataSource as never).summary(); const result = await new DashboardService(dataSource as never).summary();
assert.deepEqual(result.counts, { assert.deepEqual(result.counts, {
totalAssets: 12, totalAssets: 0,
assetsNeedValidation: 4, assetsNeedValidation: 0,
assetsWithoutGeometry: 6, assetsWithoutGeometry: 0,
plannedInspections: 2, plannedInspections: 2,
activeUsers: 4, activeUsers: 4,
inactiveUsers: 2, inactiveUsers: 2,
activeSessions: 3, activeSessions: 3,
openFindings: 8, openFindings: 8,
actsInFollowUp: 6,
findingsWithoutControlDate: 5, findingsWithoutControlDate: 5,
overdueControls: 2, overdueControls: 2,
controlsNext30Days: 3, controlsNext30Days: 3,
@@ -46,7 +48,8 @@ test('DashboardService returns F4 operational counts and recent activity consist
reportsOfficialized: 2, reportsOfficialized: 2,
sealedActsWithoutReport: 4, sealedActsWithoutReport: 4,
}); });
assert.equal(result.recentAudit, recent); assert.deepEqual(result.recentInspectorActivity, recent);
assert.equal('recentAudit' in result, false);
assert.deepEqual(result.upcomingControls, []); assert.deepEqual(result.upcomingControls, []);
assert.equal(typeof result.generatedAt, 'string'); assert.equal(typeof result.generatedAt, 'string');
}); });
@@ -19,12 +19,14 @@ test('F3.1 registra cada fusión como evento append-only con snapshots', () => {
assert.match(migration, /append-only/); assert.match(migration, /append-only/);
}); });
test('F3.1 sólo permite fusionar Instalaciones o Subinstalaciones compatibles', () => { test('F3.1/F5 sólo permite fusionar Instalaciones o Subinstalaciones compatibles dentro de la misma Área física', () => {
assert.match(service, /MERGEABLE_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/); assert.match(service, /MERGEABLE_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(service, /INVENTORY_MERGE_TYPE_INVALID/); assert.match(service, /INVENTORY_MERGE_TYPE_INVALID/);
assert.match(service, /INVENTORY_MERGE_CONTEXT_MISMATCH/); assert.match(service, /resolvePhysicalAreaId/);
assert.match(service, /INVENTORY_MERGE_AREA_MISMATCH/);
assert.match(service, /INVENTORY_MERGE_PARENT_MISMATCH/); assert.match(service, /INVENTORY_MERGE_PARENT_MISMATCH/);
assert.match(service, /INVENTORY_MERGE_CHILD_FAMILY_CONFLICT/); assert.match(service, /INVENTORY_MERGE_CHILD_FAMILY_CONFLICT/);
assert.doesNotMatch(service, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
}); });
test('F3.1 preserva referencias históricas y sólo reubica hijos actuales', () => { test('F3.1 preserva referencias históricas y sólo reubica hijos actuales', () => {
+4 -4
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('F4 health metadata reports the deployed phase without changing the release version', () => { test('health metadata reports the current F6 release', () => {
assert.equal(API_PHASE, 'F4'); assert.equal(API_PHASE, 'F6');
assert.equal(API_VERSION, '0.25.0-1'); assert.equal(API_VERSION, '0.28.0-1');
}); });
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F5.1 clean start deletes domain instances and audit history but preserves master configuration by contract', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
assert.match(migration, /TRUNCATE TABLE audit_events/);
assert.match(migration, /TRUNCATE TABLE administrative_departments CASCADE/);
assert.match(migration, /TRUNCATE TABLE finding_catalog_item_inventory_families/);
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/);
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/);
assert.doesNotMatch(migration, /TRUNCATE TABLE users/);
});
test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(dto, /'DEPARTAMENTO'/);
assert.match(structure, /DEPARTAMENTO: 'departamento'/);
assert.match(structure, /AREA: 'departamento'/);
assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(migration, /child_code='area' AND parent_code<>'departamento'/);
assert.match(migration, /WHERE lower\(code\)='area'/);
assert.match(migration, /SET can_be_root=false/);
});
test('F5.1 manual creation permits Area context without requiring an operator', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
assert.match(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/);
assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.match(structure, /operator_company_id,inventory_family_id/);
assert.match(structure, /asset_context_history/);
assert.match(structure, /operational_area_id,operator_company_id/);
});
test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => {
const browser = source('src/asset-master/inventory-browser.service.ts');
const controller = source('src/asset-master/inventory-browser.controller.ts');
assert.match(browser, /'departamento','area','yacimiento','instalacion','subinstalacion'/);
assert.match(browser, /case 'departamento': return 'area'/);
assert.doesNotMatch(browser, /['"]asset\.is_inventory_instance=true['"]/);
assert.match(controller, /@Get\('departments'\)/);
assert.match(controller, /@Get\('companies'\)/);
});
test('F5.1 Finding Catalog defaults to associated findings and exposes all items only for linking', () => {
const panel = source('../web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx');
assert.match(panel, /type ViewMode = 'ASSOCIATED' \| 'ALL'/);
assert.match(panel, /useState<ViewMode>\('ASSOCIATED'\)/);
assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/);
assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/);
assert.match(panel, /Todos para vincular/);
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Filtrar por tipo de Instalación/);
});
test('F5.1 Web creation and configuration expose Departamento as the physical root', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /familyId=\$\{familyEditor\.id\}/);
});
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 21/);
assert.match(gradle, /versionName = "0\.14\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6 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',
);
assert.match(service, /F5:SYSTEM:OTHER:%/);
assert.match(service, /AS "isOther"/);
assert.match(service, /isOtherFamily: family\.isOther/);
});
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { loadF5InventoryAuthoritativeSource } from '../../src/reference-data/f5-authoritative-inventory-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('F5 territory preload is exactly the authorized Tablas de yacimiento y areas workbook', () => {
const source = loadF5InventoryAuthoritativeSource().areaSource;
assert.equal(source.file, 'Tablas de yacimiento y areas.xlsx');
assert.equal(source.sheet, 'cr26e_tabla1');
assert.equal(source.sha256, '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78');
assert.equal(source.rows.length, 230);
const areas = new Set(source.rows.map((row) => key(row.area)));
const literalYacimientoNames = new Set(source.rows.map((row) => row.yacimiento.trim()));
const yacimientos = new Set(source.rows.map((row) => key(row.yacimiento)));
const areaYacimiento = new Set(source.rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
const departments = new Set(source.rows.map((row) => key(row.departamento)));
const concessionTypes = new Set(source.rows.map((row) => key(row.tipoConcesion)));
const operators = new Set(source.rows.map((row) => key(row.empresaOperadora)));
assert.equal(areas.size, 64);
// The authorized workbook contains 220 literal Yacimiento names. The F5 key
// intentionally folds accents/punctuation/case, so one pair of literal names
// collides into the same normalized key. Yacimiento identity is Area+Yacimiento,
// never a globally-unique normalized name.
assert.equal(literalYacimientoNames.size, 220);
assert.equal(yacimientos.size, 219);
assert.equal(areaYacimiento.size, 230);
assert.equal(departments.size, 7);
assert.equal(concessionTypes.size, 2);
assert.equal(operators.size, 13);
const literalNamesByKey = new Map<string, Set<string>>();
for (const row of source.rows) {
const normalized = key(row.yacimiento);
const literals = literalNamesByKey.get(normalized) ?? new Set<string>();
literals.add(row.yacimiento.trim());
literalNamesByKey.set(normalized, literals);
}
assert.equal([...literalNamesByKey.values()].filter((items) => items.size > 1).length, 1);
const literalAreasByYacimiento = new Map<string, Set<string>>();
const areasByYacimiento = new Map<string, Set<string>>();
for (const row of source.rows) {
const literal = row.yacimiento.trim();
const literalMemberships = literalAreasByYacimiento.get(literal) ?? new Set<string>();
literalMemberships.add(key(row.area));
literalAreasByYacimiento.set(literal, literalMemberships);
const normalized = key(row.yacimiento);
const memberships = areasByYacimiento.get(normalized) ?? new Set<string>();
memberships.add(key(row.area));
areasByYacimiento.set(normalized, memberships);
}
const literalMembershipSurplus = [...literalAreasByYacimiento.values()]
.reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0);
const normalizedMembershipSurplus = [...areasByYacimiento.values()]
.reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0);
assert.equal(literalMembershipSurplus, 10);
assert.equal(normalizedMembershipSurplus, 11);
for (const area of areas) {
const rows = source.rows.filter((row) => key(row.area) === area);
assert.equal(new Set(rows.map((row) => key(row.departamento))).size, 1, `${area}: departamento`);
assert.equal(new Set(rows.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión`);
assert.equal(new Set(rows.map((row) => key(row.empresaOperadora))).size, 1, `${area}: operadora`);
}
});
test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM references', () => {
const source = loadF5InventoryAuthoritativeSource().catalogSource;
assert.equal(source.file, 'final_modelov2.xlsx');
assert.equal(source.sheet, 'Hoja1');
assert.equal(source.sha256, 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155');
assert.equal(source.installations.length, 14);
assert.equal(source.subinstallations.length, 109);
const installationKeys = new Set(source.installations.map((item) => key(item.name)));
assert.equal(installationKeys.size, 14);
for (const subinstallation of source.subinstallations) {
assert.ok(installationKeys.has(key(subinstallation.installation)), `${subinstallation.name} parent`);
assert.ok(subinstallation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${subinstallation.name} has unresolved IDEM`);
}
for (const installation of source.installations) {
assert.ok(installation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${installation.name} has unresolved IDEM`);
}
const allFindings = new Map<string, string>();
for (const finding of source.universalFindings) allFindings.set(key(finding), finding);
for (const installation of source.installations) {
for (const finding of installation.findings) allFindings.set(key(finding), finding);
}
for (const subinstallation of source.subinstallations) {
for (const finding of subinstallation.findings) allFindings.set(key(finding), finding);
}
assert.equal(allFindings.size, 177);
assert.deepEqual(
new Set(source.universalFindings.map(key)),
new Set([
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
].map(key)),
);
});
test('F5 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');
const catalogMigration = readFileSync('src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts', 'utf8');
assert.match(mergeSource, /resolvePhysicalAreaId/);
assert.match(mergeSource, /documentInvariantsBefore/);
assert.match(mergeSource, /locked_sha256/);
assert.match(mergeSource, /closure_sha256/);
assert.match(mergeSource, /frozen_sha256/);
assert.match(mergeSource, /gedo_pdf_sha256/);
assert.match(mergeSource, /historicalReferencesRewritten:\s*false/);
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, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
assert.match(contextMigration, /public async up[\s\S]*installAreaOwnedGuard/);
assert.match(contextMigration, /public async down[\s\S]*installLegacyPairedGuard/);
assert.match(contextMigration, /La Empresa se cambia en la relación temporal del Área/);
assert.doesNotMatch(catalogMigration, /unaccent\s*\(/i);
assert.doesNotMatch(catalogMigration, /source_reference\s+IS\s+NULL/i);
assert.match(catalogMigration, /F5_SOURCE_FAMILY_COUNT\s*=\s*123/);
});
@@ -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,90 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6 models concrete Installation 1→N Subinstallations while each child keeps exactly one physical parent', () => {
const asset = source('src/database/entities/asset.entity.ts');
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(asset, /@Column\(\{ name: 'parent_id', type: 'uuid', nullable: true \}\)/);
assert.match(asset, /parentId!: string \| null/);
assert.match(migration, /IF child_type='subinstalacion' THEN/);
assert.match(migration, /IF NEW\.parent_id IS NULL THEN/);
assert.doesNotMatch(asset, /parentIds!/);
});
test('F6 makes technical Installation↔Subinstallation compatibility truly many-to-many', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const catalog = source('src/asset-master/inventory-family-catalog.service.ts');
assert.match(migration, /PRIMARY KEY \(child_family_id,parent_family_id\)/);
assert.match(catalog, /parentFamilyIds: string\[\]/);
assert.match(catalog, /requestedIds:string\[\]/);
assert.match(catalog, /INSERT INTO inventory_family_parent_rules\(child_family_id,parent_family_id\)/);
assert.match(catalog, /UNNEST\(\$2::uuid\[\]\)/);
assert.match(catalog, /ON CONFLICT \(child_family_id,parent_family_id\) DO NOTHING/);
});
test('F6 database rejects a Subinstallation classification incompatible with its concrete Installation parent', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
assert.match(migration, /SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW\.parent_id/);
assert.match(migration, /rule\.child_family_id=NEW\.inventory_family_id AND rule\.parent_family_id=parent_family/);
assert.match(migration, /La clasificación de Subinstalación no es compatible con la Instalación padre/);
});
test('F6 technical values are defined by classification and cannot leak between families', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const service = source('src/asset-master/inventory-technical-values.service.ts');
assert.match(migration, /CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions/);
assert.match(migration, /CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values/);
assert.match(migration, /El campo técnico no pertenece a la clasificación del Inventario/);
assert.match(service, /inventory_family_attribute_definitions/);
assert.match(service, /asset_inventory_attribute_values/);
});
test('F6 field findings resolve by exact technical classification and keep OTROS available', () => {
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
assert.match(resolver, /mapping\.inventory_family_id=\$1::uuid/);
assert.match(resolver, /asset\.inventory_family_id AS "familyId"/);
assert.doesNotMatch(resolver, /category\.code='f5model'/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
});
test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
});
test('F6 field-created technical Inventory may be provisional only until the selected family is assigned', () => {
const migration = source('src/database/migrations/1790091000000-f6-solid-inventory-model.ts');
const fieldStructure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(migration, /NEW\.data_origin='FIELD_SURVEY' AND NEW\.information_status='DRAFT'/);
assert.match(fieldStructure, /SET inventory_family_id=\$2::uuid/);
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
+1
View File
@@ -0,0 +1 @@
# F6 · Modelo definitivo de Inventarios
+7
View File
@@ -0,0 +1,7 @@
# F6 · Modelo definitivo de Inventarios
La jerarquía física canónica es fija:
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
+7
View File
@@ -0,0 +1,7 @@
# F6 · Modelo definitivo de Inventarios
La jerarquía física canónica es fija:
Departamento → Área → Yacimiento → Instalación → Subinstalación
Empresa es un maestro independiente y se relaciona temporalmente con Área.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-web", "name": "dhv2-web",
"version": "0.20.0-4", "version": "0.23.0-1",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -24,4 +24,4 @@
"typescript": "^5.9.0", "typescript": "^5.9.0",
"vite": "^7.0.0" "vite": "^7.0.0"
} }
} }
-4
View File
@@ -18,7 +18,6 @@ import { AssetEditorPage } from '../pages/AssetEditorPage';
import { InventoryCreatePage } from '../pages/InventoryCreatePage'; import { InventoryCreatePage } from '../pages/InventoryCreatePage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage'; import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AssetTypesPage } from '../pages/AssetTypesPage'; import { AssetTypesPage } from '../pages/AssetTypesPage';
import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage';
import { HistoryPage } from '../pages/HistoryPage'; import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage'; import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage'; import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
@@ -27,7 +26,6 @@ import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
import { FindingCatalogPage } from '../pages/FindingCatalogPage'; import { FindingCatalogPage } from '../pages/FindingCatalogPage';
import { FindingDetailPage } from '../pages/FindingDetailPage'; import { FindingDetailPage } from '../pages/FindingDetailPage';
import { FindingsPage } from '../pages/FindingsPage'; import { FindingsPage } from '../pages/FindingsPage';
import { AssetImportsPage } from '../pages/AssetImportsPage';
import { ActsPage } from '../pages/ActsPage'; import { ActsPage } from '../pages/ActsPage';
import { ReportsPage } from '../pages/ReportsPage'; import { ReportsPage } from '../pages/ReportsPage';
import { ReportDetailPage } from '../pages/ReportDetailPage'; import { ReportDetailPage } from '../pages/ReportDetailPage';
@@ -55,7 +53,6 @@ export function App() {
<Route path="/activos/:id" element={<AssetEditorPage />} /> <Route path="/activos/:id" element={<AssetEditorPage />} />
</Route> </Route>
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route> <Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
<Route element={<PermissionRoute permission="asset_imports.read" />}><Route path="/importaciones" element={<AssetImportsPage />} /></Route>
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route> <Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} /> <Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
<Route element={<PermissionRoute permission="inspections.read" />}> <Route element={<PermissionRoute permission="inspections.read" />}>
@@ -82,7 +79,6 @@ export function App() {
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route> <Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route> <Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AssetTypesPage />} /></Route> <Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AssetTypesPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.manage" />}><Route path="/admin/inventory-functions" element={<InventoryFunctionsPage />} /></Route>
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route> <Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route> <Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
<Route path="/sin-acceso" element={<AccessDeniedPage />} /> <Route path="/sin-acceso" element={<AccessDeniedPage />} />
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.21.0-1'; export const APP_VERSION = '0.21.0-1';
export const APP_PHASE = 'Fase F3.1 · Inventario estructural y catálogo contextual'; export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado';
+90
View File
@@ -0,0 +1,90 @@
/* F5 · ajustes visuales acotados a los flujos saneados. */
.status-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 18px;
padding: 7px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 14px;
background: var(--surface, #fff);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
}
.status-tabs button {
appearance: none;
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 38px;
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text-muted, #64748b);
font: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.status-tabs button:hover {
background: #f8fafc;
border-color: #e2e8f0;
color: #0f172a;
}
.status-tabs button.active {
background: #eef4ff;
border-color: #bfd2fb;
color: #173d7a;
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.04);
}
.status-tabs button small {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #edf2f7;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.status-tabs button.active small {
background: #d7e5ff;
color: #173d7a;
}
.asset-browser-item-status > strong {
display: block;
font-size: 18px;
line-height: 1;
text-align: right;
}
.asset-browser-item-status > small {
display: block;
margin-top: 5px;
}
@media (max-width: 720px) {
.status-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 9px;
}
.status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
}
@@ -1,59 +1,98 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getFindingCatalogAssetSelection, replaceFindingCatalogAssetSelection } from '../../lib/api'; import {
import type { FindingCatalogAssetSelection } from '../../lib/api'; getInventoryFamilyFindings,
getInventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import type {
InventoryFamilyFinding,
InventoryTechnicalValues,
} from '../../lib/inventoryStructureApi';
import { AssetTechnicalDataPanel } from './AssetTechnicalDataPanel';
export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) { export function AssetFindingCatalogPanel({ assetId, canManage }: { assetId: string; canManage: boolean }) {
const [selection, setSelection] = useState<FindingCatalogAssetSelection | null>(null); const { hasPermission } = useAuth();
const [enabled, setEnabled] = useState<Set<string>>(new Set()); const canEditTechnical = hasPermission('assets.update');
const [reason, setReason] = useState(''); const [technical, setTechnical] = useState<InventoryTechnicalValues | null>(null);
const [items, setItems] = useState<InventoryFamilyFinding[]>([]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [unsupported, setUnsupported] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = () => getFindingCatalogAssetSelection(assetId).then((loaded) => { useEffect(() => {
setSelection(loaded); let active = true;
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id))); setLoading(true);
}); setUnsupported(false);
setError('');
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, [assetId]); getInventoryTechnicalValues(assetId)
.then(async (loaded) => {
const findings = await getInventoryFamilyFindings(loaded.family.id);
if (!active) return;
setTechnical(loaded);
setItems(findings.items);
})
.catch((requestError) => {
if (!active) return;
const message = errorMessage(requestError);
if (message.includes('no tiene clasificación técnica')) setUnsupported(true);
else setError(message);
})
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [assetId]);
const visible = useMemo(() => { const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase(); const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName] if (!needle) return items;
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? []; return items.filter((item) => [item.title, item.code, item.categoryName, item.legalBasis ?? '']
}, [selection, search]); .some((value) => value.toLocaleLowerCase().includes(needle)));
}, [items, search]);
const toggle = (id: string) => setEnabled((current) => { if (loading) return <div className="panel"><LoadingBlock label="Cargando clasificación y Hallazgos aplicables…" /></div>;
const next = new Set(current); if (error) return <Alert>{error}</Alert>;
if (next.has(id)) next.delete(id); else next.add(id); if (unsupported || !technical) return <section className="panel asset-finding-catalog-panel">
return next; <div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>Sin clasificación técnica</h2><p className="section-copy">Departamento, Área y Yacimiento son niveles estructurales. Los Hallazgos se registran sobre Instalaciones y Subinstalaciones clasificadas.</p></div></div>
});
const save = async () => {
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetSelection(assetId, { enabledItemIds: [...enabled], reason });
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason('');
setSuccess('Subconjunto de hallazgos actualizado para este objeto.');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando hallazgos aplicables…" /></div>;
if (!selection) return <Alert>{error || 'No se pudo cargar la configuración.'}</Alert>;
return <section className="panel asset-finding-catalog-panel">
<div className="panel-heading"><div><span className="eyebrow">HALLAZGOS APLICABLES</span><h2>{selection.asset.name}</h2><p className="section-copy">Base: {selection.asset.assetTypeName}. Las excepciones de esta pantalla afectan sólo a este objeto del Inventario.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{!selection.typeConfigured && <div className="temporal-notice"><Icon name="alert" /><p><strong>El tipo técnico todavía no tiene un catálogo restringido.</strong> Por compatibilidad, su base actual incluye todo el catálogo activo. Podés configurar primero el tipo general en Catálogo de hallazgos.</p></div>}
<label className="search-field"><Icon name="search" /><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Buscar hallazgo aplicable…" /></label>
<div className="finding-selection-list">{visible.map((item) => <label className={`finding-selection-row ${item.assetOverride !== null ? 'override' : ''}`} key={item.id}><input type="checkbox" disabled={!canManage} checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code} · base del tipo: {item.typeDefaultEnabled ? 'sí' : 'no'}{item.assetOverride !== null ? ' · excepción de este objeto' : ''}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
{canManage && <><label className="field"><span>Motivo de la excepción</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Explicá por qué este objeto usa un subconjunto distinto…" /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar excepciones'}</button></div></>}
</section>; </section>;
}
return <div className="asset-tab-stack">
<AssetTechnicalDataPanel assetId={assetId} canEdit={canEditTechnical} />
<section className="panel asset-finding-catalog-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">HALLAZGOS APLICABLES</span>
<h2>{technical.family.name}</h2>
<p className="section-copy">Se muestran únicamente los Hallazgos asociados a esta clasificación técnica. Éste es el mismo catálogo que consume la APK para este objeto.</p>
</div>
<span className="count-pill">{items.length} asociados</span>
</div>
{canManage && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>¿Falta un Hallazgo?</strong> Administrá la clasificación en Configuración de Inventarios y el cambio se aplicará a todos los objetos de este mismo tipo técnico.</p>
<Link className="button secondary" to="/admin/asset-types">Configurar Inventarios</Link>
</div>}
{items.length > 0 && <label className="search-field">
<Icon name="search" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={`Buscar entre los ${items.length} Hallazgos asociados…`} />
</label>}
{items.length === 0
? <div className="inline-empty">Esta clasificación todavía no tiene Hallazgos asociados. Desde Configuración de Inventarios podés vincular los que correspondan.</div>
: visible.length === 0
? <div className="inline-empty">No hay Hallazgos asociados que coincidan con la búsqueda.</div>
: <div className="finding-selection-list">{visible.map((item) => <div className="finding-selection-row" key={item.id}>
<span className="asset-symbol"><Icon name="alert" size={15} /></span>
<span>
<strong>{item.title}</strong>
<small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small>
{item.legalBasis && <small>{item.legalBasis}</small>}
</span>
</div>)}</div>}
</section>
</div>;
}
+117 -197
View File
@@ -1,256 +1,176 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router'; import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api';
import { import {
getAsset, listInventoryCompanies,
getAssetLineage, listInventoryDepartments,
listAreasForCompany, listInventoryChildren,
listCompaniesForArea, } from '../../lib/inventoryBrowserApi';
listAssetTreeChildren, import type {
listOperationalAreas, InventoryBrowserCompany,
listOperationalCompanies, InventoryBrowserDepartment,
} from '../../lib/api'; InventoryBrowserItem,
import type { AssetDetail, AssetLineageItem, AssetListItem, OperationalAssetSummary } from '../../lib/api'; InventoryQuery,
} from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation'; import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
type TreeFilters = Omit<NonNullable<Parameters<typeof listAssetTreeChildren>[0]>, 'parentId' | 'limit'>; function navigationHref(base: URLSearchParams, parentId?: string) {
type NavigationSection = 'companies' | 'territory';
type ChildGroupKey = 'fields' | 'installations' | 'wells' | 'equipment';
const INSTALLATION_CODES = new Set([
'estructura_local', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion', 'subestacion',
'zona_bombas', 'sistema_drenaje', 'sistema_electrico_iluminacion', 'sistema_defensa_incendios',
'pileta_api', 'cargadero_descargadero',
]);
const GROUP_ORDER: ChildGroupKey[] = ['fields', 'installations', 'wells', 'equipment'];
const GROUP_LABELS: Record<ChildGroupKey, { title: string; description: string }> = {
fields: { title: 'Yacimientos', description: 'Unidades territoriales u operativas dentro del Área.' },
installations: { title: 'Instalaciones y estructura', description: 'Plantas, baterías, estaciones, locaciones y niveles estructurales.' },
wells: { title: 'Pozos', description: 'Pozos identificados dentro del contexto seleccionado.' },
equipment: { title: 'Equipos y otros elementos', description: 'Equipos técnicos y demás elementos registrados en el inventario.' },
};
function childGroup(item: AssetListItem): ChildGroupKey {
const code = item.type.code.toLowerCase();
if (code === 'yacimiento') return 'fields';
if (INSTALLATION_CODES.has(code)) return 'installations';
if (code === 'pozo') return 'wells';
return 'equipment';
}
function normalizeSearch(value: string | undefined) {
return value?.trim().toLocaleLowerCase('es-AR') ?? '';
}
function matchesSearch(item: { name: string; code: string; commonName?: string | null }, search?: string) {
if (!search) return true;
const term = normalizeSearch(search);
return item.name.toLocaleLowerCase('es-AR').includes(term) || item.code.toLocaleLowerCase('es-AR').includes(term) || Boolean(item.commonName?.toLocaleLowerCase('es-AR').includes(term));
}
function navigationHref(base: URLSearchParams, section: NavigationSection, options: { companyId?: string; parentId?: string } = {}) {
const params = new URLSearchParams(base); const params = new URLSearchParams(base);
params.delete('view'); params.delete('view');
params.delete('page'); params.delete('page');
params.delete('operationalAreaId'); params.delete('section');
params.delete('operatorCompanyId'); params.delete('companyId');
params.set('section', section); parentId ? params.set('parentId', parentId) : params.delete('parentId');
options.companyId ? params.set('companyId', options.companyId) : params.delete('companyId'); return `/inventarios${params.size ? `?${params}` : ''}`;
options.parentId ? params.set('parentId', options.parentId) : params.delete('parentId');
return `/inventarios?${params}`;
} }
function AssetCard({ item, href }: { item: AssetListItem; href: string }) { function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
return <Link className="asset-browser-item" to={href}> return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span> <span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
<span className="asset-browser-item-main">
<strong>{department.name}</strong>
<small>{department.code} · {department.areaCount} Área{department.areaCount === 1 ? '' : 's'}</small>
</span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(department.informationStatus)}`}>{assetStatusLabel(department.informationStatus)}</span></span>
<Icon name="chevron" size={16} />
</Link>;
}
function CompanyCard({ company }: { company: InventoryBrowserCompany }) {
return <Link className="asset-browser-item" to={`/inventarios/${company.id}`}>
<span className="asset-browser-item-icon"><Icon name="users" size={17} /></span>
<span className="asset-browser-item-main"><strong>{company.name}</strong><small>{company.code} · {company.areaCount} Área{company.areaCount === 1 ? '' : 's'} operada{company.areaCount === 1 ? '' : 's'}</small></span>
<span className="asset-browser-item-status"><span className={`status-badge ${assetStatusClass(company.informationStatus)}`}>{assetStatusLabel(company.informationStatus)}</span></span>
<Icon name="chevron" size={16} />
</Link>;
}
function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name={['departamento','area','yacimiento'].includes(item.type.code.toLowerCase()) ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-main"> <span className="asset-browser-item-main">
<strong>{item.name}</strong> <strong>{item.name}</strong>
<small>{item.code} · {item.type.name}{item.commonName ? ` · ${item.commonName}` : ''}</small> <small>
{item.code} · {item.type.name}
{item.inventoryFamily ? ` · ${item.inventoryFamily.name}` : ''}
{item.commonName ? ` · ${item.commonName}` : ''}
</small>
</span> </span>
<span className="asset-browser-item-status"> <span className="asset-browser-item-status">
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span> <span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
<small>{assetOperationalStatusLabel(item.operationalStatus)}</small> {item.inventoryFamily
? <small>{item.findingCount} Hallazgo{item.findingCount === 1 ? '' : 's'} asociado{item.findingCount === 1 ? '' : 's'}</small>
: <small>{item.childrenCount} registro{item.childrenCount === 1 ? '' : 's'} inferior{item.childrenCount === 1 ? '' : 'es'}</small>}
</span> </span>
<Icon name="chevron" size={16} /> <Icon name="chevron" size={16} />
</Link>; </Link>;
} }
function SummaryCard({ item, href, subtitle }: { item: OperationalAssetSummary; href: string; subtitle: string }) { function nextLevelLabel(typeCode: string | undefined) {
return <Link className="asset-browser-item" to={href}> switch (typeCode?.toLowerCase()) {
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span> case 'departamento': return 'Áreas';
<span className="asset-browser-item-main"> case 'area': return 'Yacimientos';
<strong>{item.name}</strong> case 'yacimiento': return 'Instalaciones';
<small>{item.code} · {subtitle}{item.commonName ? ` · ${item.commonName}` : ''}</small> case 'instalacion': return 'Subinstalaciones';
</span> default: return 'Niveles inferiores';
<Icon name="chevron" size={16} /> }
</Link>;
} }
export function AssetHierarchyView({ filters }: { filters: TreeFilters }) { export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create'); const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const rawSection = searchParams.get('section');
const section: NavigationSection | null = rawSection === 'companies' || rawSection === 'territory' ? rawSection : null;
const companyId = searchParams.get('companyId') ?? '';
const parentId = searchParams.get('parentId') ?? ''; const parentId = searchParams.get('parentId') ?? '';
const [departments, setDepartments] = useState<InventoryBrowserDepartment[]>([]);
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]); const [companies, setCompanies] = useState<InventoryBrowserCompany[]>([]);
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]); const [children, setChildren] = useState<InventoryBrowserItem[]>([]);
const [company, setCompany] = useState<AssetDetail | null>(null);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]); const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
let active = true; let active = true;
setLoading(true); setLoading(true); setError(''); setDepartments([]); setCompanies([]); setChildren([]); setLineage([]); setHasMore(false);
setError('');
setCompanies([]);
setAreas([]);
setCompany(null);
setLineage([]);
setChildren([]);
setHasMore(false);
const run = async () => { const run = async () => {
if ((!section || section === 'companies') && !companyId && !parentId) { if (!parentId) {
const loaded = filters.operationalAreaId ? await listCompaniesForArea(filters.operationalAreaId) : await listOperationalCompanies(); const [loadedDepartments, loadedCompanies] = await Promise.all([
if (active) setCompanies(loaded.filter((item) => (!filters.operatorCompanyId || item.id === filters.operatorCompanyId) && matchesSearch(item, filters.search))); listInventoryDepartments({ search: filters.search, operatorCompanyId: filters.operatorCompanyId }),
return; listInventoryCompanies({ search: filters.search }),
}
if (section === 'companies' && companyId && !parentId) {
const [loadedCompany, loadedAreas] = await Promise.all([getAsset(companyId), listAreasForCompany(companyId)]);
if (!active) return;
setCompany(loadedCompany);
setAreas(loadedAreas.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (section === 'territory' && !parentId) {
const loaded = await listOperationalAreas();
if (active) setAreas(loaded.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (parentId) {
const [loadedLineage, childResponse, loadedCompany] = await Promise.all([
getAssetLineage(parentId),
listAssetTreeChildren({
...filters,
operatorCompanyId: companyId || filters.operatorCompanyId,
parentId,
limit: 200,
}),
companyId ? getAsset(companyId) : Promise.resolve(null),
]); ]);
if (!active) return; if (active) { setDepartments(loadedDepartments.data); setCompanies(loadedCompanies.data); }
setLineage(loadedLineage); return;
setChildren(childResponse.data);
setHasMore(childResponse.meta.hasMore);
setCompany(loadedCompany);
} }
const [loadedLineage, response] = await Promise.all([
getAssetLineage(parentId),
listInventoryChildren(parentId, { search: filters.search }),
]);
if (!active) return;
setLineage(loadedLineage.filter((item) => ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data); setHasMore(response.meta.hasMore);
}; };
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false)); run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
return () => { active = false; }; return () => { active = false; };
}, [section, companyId, parentId, JSON.stringify(filters)]); }, [parentId, filters.search, filters.operatorCompanyId]);
const groupedChildren = useMemo(() => { if (loading) return <LoadingBlock label="Cargando Inventarios…" />;
const groups = new Map<ChildGroupKey, AssetListItem[]>();
children.forEach((item) => {
const key = childGroup(item);
groups.set(key, [...(groups.get(key) ?? []), item]);
});
return GROUP_ORDER.map((key) => ({ key, items: groups.get(key) ?? [] })).filter((group) => group.items.length > 0);
}, [children]);
const current = lineage.at(-1) ?? null; if (!parentId) {
const activeSection: NavigationSection = section ?? 'companies';
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (!section && !companyId && !parentId) {
return <div className="asset-browser-panel"> return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"> <div className="asset-browser-section-heading">
<div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Cada empresa tiene su propio inventario. Ingresá para recorrer Áreas, Yacimientos, instalaciones y equipos.</p></div> <div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={navigationHref(searchParams, 'territory')}><Icon name="map" />Vista territorial</Link><span className="count-pill">{companies.length}</span></div> <div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
</div> </div>
{companies.length === 0 ? <EmptyState title="No hay inventarios para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>} {departments.length === 0
<div className="asset-browser-levels" aria-label="Estructura de los inventarios"> ? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
<div><span>1</span><strong>Empresa</strong><small>Inventario principal</small></div><i></i> : <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div><span>2</span><strong>Área</strong><small>Contexto territorial</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Nivel territorial</small></div><i></i> <div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>4</span><strong>Instalación</strong><small>Planta, batería, estación</small></div><i></i> <div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>5</span><strong>Equipo</strong><small>Equipo, pozo, tanque</small></div> <div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div> </div>
<section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
</section>
</div>; </div>;
} }
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del inventario"> const current = lineage.at(-1) ?? null;
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del Inventario">
<Link to="/inventarios">Inventarios</Link> <Link to="/inventarios">Inventarios</Link>
{activeSection === 'territory' && <><span></span><Link to={navigationHref(searchParams, 'territory')}>Vista territorial</Link></>} {lineage.map((item,index) => {
{company && <><span></span>{parentId ? <Link to={navigationHref(searchParams, 'companies', { companyId: company.id })}>{company.name}</Link> : <strong>{company.name}</strong>}</>} const isLast=index===lineage.length-1;
{lineage.map((item, index) => { return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}</span>;
const isLast = index === lineage.length - 1;
return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })}>{item.name}</Link>}</span>;
})} })}
</nav>; </nav>;
if (section === 'companies' && !companyId) { return <div className="asset-browser-panel">
return <div className="asset-browser-panel"> {breadcrumb}
{breadcrumb} {error && <Alert>{error}</Alert>}
{error && <Alert>{error}</Alert>} {hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Ingresá al inventario de una empresa para ver sus Áreas y continuar hacia Yacimientos, instalaciones y equipos.</p></div><span className="count-pill">{companies.length}</span></div> <div className="asset-browser-current-heading">
{companies.length === 0 ? <EmptyState title="No hay empresas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>} <div><span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span><h2>{current?.name ?? 'Nivel de Inventario'}</h2><p>{current?.code ?? ''}</p></div>
</div>; <div className="asset-browser-current-actions">
} {current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha{['instalacion','subinstalacion'].includes(current.type.code.toLowerCase()) ? ' y Hallazgos' : ''}</Link>}
{canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>}
if (section === 'companies' && companyId && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-current-heading"><div><span className="eyebrow">INVENTARIO DE EMPRESA</span><h2>{company?.name ?? 'Organización'}</h2><p>{company?.code} · Áreas con registros asociados a este inventario.</p></div>{company && <Link className="button secondary" to={`/inventarios/${company.id}`}>Ver ficha</Link>}</div>
<div className="asset-browser-group">
<div className="asset-browser-group-heading"><div><h3>Áreas del inventario</h3><p>Seleccioná un Área para continuar hacia Yacimientos, instalaciones y equipos.</p></div><span>{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="Sin Áreas en el inventario" text="No hay Áreas con registros asignados a esta empresa para los filtros actuales." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'companies', { companyId, parentId: item.id })} />)}</div>}
</div> </div>
</div>; </div>
}
if (section === 'territory' && !parentId) { <section className="asset-browser-group">
return <div className="asset-browser-panel"> <div className="asset-browser-group-heading"><div><h3>{nextLevelLabel(current?.type.code)}</h3><p>Jerarquía: Departamento Área Yacimiento Instalación Subinstalación.</p></div><span>{children.length}</span></div>
{breadcrumb} {children.length === 0
{error && <Alert>{error}</Alert>} ? <EmptyState title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'} text={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Abrí la ficha para ver su clasificación y Hallazgos asociados.' : 'Agregá el primer registro de este nivel o revisá la búsqueda actual.'} />
<div className="asset-browser-section-heading"><div><span className="eyebrow">TERRITORIO</span><h2>Áreas y yacimientos</h2><p>Ingresá por un Área para navegar su estructura física.</p></div><span className="count-pill">{areas.length}</span></div> : <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
{areas.length === 0 ? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'territory', { parentId: item.id })} />)}</div>} </section>
</div>; </div>;
}
if (parentId && current) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda o los filtros para acotar los resultados.</Alert>}
<div className="asset-browser-current-heading">
<div><span className="eyebrow">{current.type.name}</span><h2>{current.name}</h2><p>{current.code}{current.commonName ? ` · ${current.commonName}` : ''}{company ? ` · Contexto: ${company.name}` : ''}</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>{canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${current.id}`}><Icon name="plus" />Agregar aquí</Link>}</div>
</div>
{groupedChildren.length === 0 ? <EmptyState title="No hay niveles inferiores" text="Este nivel no tiene registros inferiores que coincidan con los filtros actuales." /> : <div className="asset-browser-groups">
{groupedChildren.map(({ key, items }) => <section className={`asset-browser-group group-${key}`} key={key}>
<div className="asset-browser-group-heading"><div><h3>{GROUP_LABELS[key].title}</h3><p>{GROUP_LABELS[key].description}</p></div><span>{items.length}</span></div>
<div className="asset-browser-list">{items.map((item) => <AssetCard key={item.id} item={item} href={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })} />)}</div>
</section>)}
</div>}
</div>;
}
return <>{error && <Alert>{error}</Alert>}<EmptyState title="No se pudo abrir la estructura" text="Volvé al inicio de Inventarios e intentá nuevamente." /></>;
} }
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { SearchableSelect } from '../../components/SearchableSelect';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getInventoryTechnicalValues, replaceInventoryTechnicalValues } from '../../lib/inventoryStructureApi';
import type { InventoryFamilyAttribute, InventoryTechnicalValues } from '../../lib/inventoryStructureApi';
function localDateTime(value:unknown){
if(typeof value!=='string'||!value)return '';
const date=new Date(value); if(Number.isNaN(date.getTime()))return value.slice(0,16);
const local=new Date(date.getTime()-date.getTimezoneOffset()*60000); return local.toISOString().slice(0,16);
}
function displayValue(definition:InventoryFamilyAttribute,value:unknown){
if(value===undefined||value===null||value==='')return 'Sin completar';
if(definition.dataType==='BOOLEAN')return value?'Sí':'No';
if(definition.dataType==='DATETIME'&&typeof value==='string')return new Date(value).toLocaleString('es-AR');
return `${String(value)}${definition.unit?` ${definition.unit}`:''}`;
}
export function AssetTechnicalDataPanel({assetId,canEdit}:{assetId:string;canEdit:boolean}){
const [data,setData]=useState<InventoryTechnicalValues|null>(null);
const [values,setValues]=useState<Record<string,unknown>>({});
const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false);
const [unsupported,setUnsupported]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
useEffect(()=>{
let active=true; setLoading(true); setUnsupported(false); setError('');
getInventoryTechnicalValues(assetId).then((loaded)=>{if(!active)return;setData(loaded);setValues(Object.fromEntries(loaded.definitions.map((definition)=>{
const raw=loaded.values[definition.id]; return [definition.id,definition.dataType==='DATETIME'?localDateTime(raw):raw??''];
})))}).catch((requestError)=>{if(!active)return;const message=errorMessage(requestError);if(message.includes('no tiene clasificación técnica'))setUnsupported(true);else setError(message);}).finally(()=>active&&setLoading(false));
return()=>{active=false};
},[assetId]);
if(loading)return <div className="form-section"><LoadingBlock label="Cargando datos técnicos…" /></div>;
if(unsupported)return null;
if(error&&!data)return <div className="form-section"><Alert>{error}</Alert></div>;
if(!data)return null;
const activeDefinitions=data.definitions.filter((item)=>item.isActive);
const save=async()=>{
setSaving(true);setError('');setSuccess('');
try{
const normalized:Record<string,unknown>={};
for(const definition of activeDefinitions){const raw=values[definition.id];if(raw===''||raw===null||raw===undefined)continue;
if(definition.dataType==='NUMBER')normalized[definition.id]=Number(raw);
else if(definition.dataType==='BOOLEAN')normalized[definition.id]=Boolean(raw);
else if(definition.dataType==='DATETIME')normalized[definition.id]=new Date(String(raw)).toISOString();
else normalized[definition.id]=raw;
}
const saved=await replaceInventoryTechnicalValues(assetId,normalized);setData(saved);setSuccess('Datos técnicos guardados.');
}catch(requestError){setError(errorMessage(requestError));}finally{setSaving(false)}
};
return <div className="form-section">
<div><h2>Información técnica · {data.family.name}</h2><p className="section-copy">Estos campos pertenecen a la clasificación concreta del elemento, no al nivel genérico.</p></div>
{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
{activeDefinitions.length===0?<div className="inline-empty">Esta clasificación todavía no tiene campos técnicos configurados.</div>:canEdit?<div className="dynamic-attributes">{activeDefinitions.map((definition)=>{
const value=values[definition.id];const label=<span>{definition.name}{definition.unit?` (${definition.unit})`:''}{definition.isRequired?<em>obligatorio</em>:<em>opcional</em>}</span>;
if(definition.dataType==='BOOLEAN')return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.checked}))}/><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>;
if(definition.dataType==='SELECT')return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value??'')} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option)=><option key={option} value={option}>{option}</option>)}</SearchableSelect></label>;
const inputType=definition.dataType==='NUMBER'?'number':definition.dataType==='DATE'?'date':definition.dataType==='DATETIME'?'datetime-local':'text';
return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value??'')} step={definition.dataType==='NUMBER'?'any':undefined} required={definition.isRequired} onChange={(event)=>setValues((current)=>({...current,[definition.id]:event.target.value}))}/></label>;
})}</div>:<div className="attribute-list">{activeDefinitions.map((definition)=><div className="attribute-card" key={definition.id}><span className="asset-symbol"><Icon name="layers" size={15}/></span><span><strong>{definition.name}</strong><small>{definition.code}</small></span><span>{displayValue(definition,data.values[definition.id])}</span></div>)}</div>}
{canEdit&&activeDefinitions.length>0&&<div className="form-actions"><button type="button" className="button primary" disabled={saving} onClick={()=>void save()}><Icon name="check" />{saving?'Guardando…':'Guardar datos técnicos'}</button></div>}
</div>;
}
@@ -1,95 +1,91 @@
import { SearchableSelect } from '../../components/SearchableSelect'; import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon'; import { Icon } from '../../components/Icon';
import { getFindingCatalogAdmin } from '../../lib/api';
import type { FindingAdminCatalog } from '../../lib/api';
import { import {
getFindingCatalogAssetTypeSelection, listInventoryFamiliesAdmin,
listAssetTypes, replaceInventoryFamilyFindings,
replaceFindingCatalogAssetTypeSelection, } from '../../lib/inventoryStructureApi';
} from '../../lib/api'; import type { InventoryFamily } from '../../lib/inventoryStructureApi';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
type ViewMode = 'ASSOCIATED' | 'ALL';
function familyContext(family: InventoryFamily) {
if (family.level === 'INSTALLATION') return `Instalación · ${family.name}`;
const parents = family.parentFamilies.map((parent) => parent.name).join(', ');
return `Subinstalación · ${family.name}${parents ? ` · compatible con ${parents}` : ''}`;
}
export function FindingCatalogTypeApplicabilityPanel() { export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]); const [searchParams] = useSearchParams();
const [typeId, setTypeId] = useState(''); const requestedFamilyId = searchParams.get('familyId') ?? '';
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set()); const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState(''); const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('ASSOCIATED');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(''); const [success, setSuccess] = useState('');
useEffect(() => { const selectedFamily = families.find((family) => family.id === familyId) ?? null;
listAssetTypes() const savedIds = useMemo(() => new Set(selectedFamily?.findingItemIds ?? []), [selectedFamily]);
.then((loaded) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
setTypes(technical);
setTypeId(technical[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
useEffect(() => { useEffect(() => {
if (!typeId) { setSelection(null); return; } Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
setLoading(true); setError(''); setSuccess(''); .then(([loadedFamilies, loadedCatalog]) => {
getFindingCatalogAssetTypeSelection(typeId) const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
.then((loaded) => { setFamilies(activeFamilies); setCatalog(loadedCatalog);
setSelection(loaded); setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) ? requestedFamilyId : activeFamilies[0]?.id ?? '');
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(loaded.reason ?? '');
}) })
.catch((requestError) => setError(errorMessage(requestError))) .catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
.finally(() => setLoading(false)); }, [requestedFamilyId]);
}, [typeId]);
const visible = useMemo(() => { useEffect(() => {
const needle = search.trim().toLocaleLowerCase(); const selected = families.find((family) => family.id === familyId);
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName] setEnabled(new Set(selected?.findingItemIds ?? [])); setViewMode('ASSOCIATED'); setSearch(''); setSuccess(''); setError('');
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? []; }, [familyId, families]);
}, [selection, search]);
const toggle = (id: string) => setEnabled((current) => { const activeCategoryIds = useMemo(() => new Set(catalog.categories.filter((category) => category.isActive).map((category) => category.id)), [catalog.categories]);
const next = new Set(current); const categoryName = useMemo(() => new Map(catalog.categories.map((category) => [category.id, category.name])), [catalog.categories]);
if (next.has(id)) next.delete(id); else next.add(id); const available = useMemo(() => {
return next; const needle = search.trim().toLocaleLowerCase('es-AR');
}); return catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? ''].some((value) => value.toLocaleLowerCase('es-AR').includes(needle))));
}, [catalog.items, search, activeCategoryIds, categoryName]);
const visible = useMemo(() => viewMode === 'ASSOCIATED' ? available.filter((item) => savedIds.has(item.id)) : available, [available, savedIds, viewMode]);
const toggle = (id: string) => setEnabled((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const selectVisible = () => setEnabled((current) => { const next = new Set(current); visible.forEach((item) => next.add(item.id)); return next; });
const save = async () => { const save = async () => {
if (!selection) return; if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
try { try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, { const saved = await replaceInventoryFamilyFindings(selectedFamily.id, { itemIds: [...enabled], reason });
enabledItemIds: [...enabled], const itemIds = saved.items.map((item) => item.id); setEnabled(new Set(itemIds));
reason, setFamilies((current) => current.map((family) => family.id === selectedFamily.id ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length } : family));
}); setViewMode('ASSOCIATED'); setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
setSelection(saved); } catch (requestError) { setError(errorMessage(requestError)); }
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id))); finally { setSaving(false); }
setReason(saved.reason ?? reason);
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
}; };
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>; if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null; if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel"> return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div> <div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR CLASIFICACIÓN</span><h2>Qué Hallazgos verá el inspector</h2><p className="section-copy">Al elegir una clasificación se muestran sólo sus Hallazgos asociados. Todos para vincular se usa exclusivamente para modificar la relación.</p></div><span className="count-pill">{savedIds.size} vinculados</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar"> <div className="form-grid finding-applicability-toolbar"><label className="field"><span>Clasificación de Inventario</span><SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>{families.map((family) => <option value={family.id} key={family.id}>{familyContext(family)}</option>)}</SearchableSelect></label><label className="field"><span>Buscar dentro de {viewMode === 'ASSOCIATED' ? 'los asociados' : 'todo el Catálogo'}</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label></div>
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label> {selectedFamily && <div className="temporal-notice"><Icon name="layers" /><p><strong>{selectedFamily.name}:</strong> {selectedFamily.level === 'SUBINSTALLATION' && selectedFamily.parentFamilies.length ? `compatible con ${selectedFamily.parentFamilies.map((parent) => parent.name).join(', ')}. ` : ''}Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.</p></div>}
<label className="field"><span>Buscar hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label> <div className="quick-view-row" style={{ marginBottom: 12 }}><button type="button" className={viewMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => { setViewMode('ASSOCIATED'); setSearch(''); }}>Asociados ({savedIds.size})</button><button type="button" className={viewMode === 'ALL' ? 'active' : ''} onClick={() => { setViewMode('ALL'); setSearch(''); }}>Todos para vincular ({catalog.items.filter((item) => item.isActive && activeCategoryIds.has(item.categoryId)).length})</button></div>
</div> {viewMode === 'ASSOCIATED' && visible.length === 0 ? <div className="inline-empty"><strong>{search.trim() ? 'Ningún Hallazgo asociado coincide con la búsqueda.' : 'Esta clasificación todavía no tiene Hallazgos asociados.'}</strong><br />{!search.trim() && 'Abrí “Todos para vincular” para elegirlos.'}</div> : <>{viewMode === 'ALL' && <div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={selectVisible}>Seleccionar visibles</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>}<div className="finding-selection-list">{visible.map((item) => viewMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></div>)}</div></>}
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>} {viewMode === 'ALL' && <><label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} /></label><div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : `Guardar ${enabled.size} asociados`}</button></div></>}
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></div>
</section>; </section>;
} }
-2
View File
@@ -32,14 +32,12 @@ const documents: NavItem[] = [
const master: NavItem[] = [ const master: NavItem[] = [
{ to: '/inventarios', label: 'Inventarios', icon: 'layers', permission: 'assets.read' }, { to: '/inventarios', label: 'Inventarios', icon: 'layers', permission: 'assets.read' },
{ to: '/mapa', label: 'Mapa', icon: 'map', permission: 'assets.read' }, { to: '/mapa', label: 'Mapa', icon: 'map', permission: 'assets.read' },
{ to: '/importaciones', label: 'Importaciones', icon: 'upload', permission: 'asset_imports.read' },
]; ];
const administration: NavItem[] = [ const administration: NavItem[] = [
{ to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' }, { to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' },
{ to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' }, { to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' },
{ to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' }, { to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' },
{ to: '/admin/inventory-functions', label: 'Catálogo de funciones', icon: 'history', permission: 'asset_types.manage' },
{ to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' }, { to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' },
{ to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' }, { to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' },
]; ];
+2 -1
View File
@@ -10,6 +10,7 @@ export interface DashboardSummaryF4 {
inactiveUsers: number; inactiveUsers: number;
activeSessions: number; activeSessions: number;
openFindings: number; openFindings: number;
actsInFollowUp: number;
findingsWithoutControlDate: number; findingsWithoutControlDate: number;
overdueControls: number; overdueControls: number;
controlsNext30Days: number; controlsNext30Days: number;
@@ -17,7 +18,7 @@ export interface DashboardSummaryF4 {
reportsOfficialized: number; reportsOfficialized: number;
sealedActsWithoutReport: number; sealedActsWithoutReport: number;
}; };
recentAudit: Array<{ recentInspectorActivity: Array<{
id: string; id: string;
occurredAt: string; occurredAt: string;
actorUsername: string | null; actorUsername: string | null;
+144
View File
@@ -0,0 +1,144 @@
import { apiRequest } from './api';
import type {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
PageMeta,
} from './api';
export interface InventoryBrowserDepartment {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
areaCount: number;
childrenCount: number;
}
export interface InventoryBrowserCompany {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
areaCount: number;
}
export interface InventoryBrowserArea {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
currentOperator: { id: string; code: string; name: string } | null;
yacimientoCount: number;
inventoryCount: number;
}
export interface InventoryBrowserItem {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
parent: { id: string; code: string; name: string } | null;
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
isInventoryInstance: boolean;
inventoryFamily: { id: string; code: string; name: string; level: string } | null;
childrenCount: number;
findingCount: number;
hasGeometry: boolean;
updatedAt: string;
}
export interface InventoryListItem {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
parent: { id: string; code: string; name: string } | null;
operationalArea: { id: string; code: string; name: string } | null;
operatorCompany: { id: string; code: string; name: string } | null;
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
childrenCount: number;
hasGeometry: boolean;
geometryType: string | null;
mediaCount: number;
dataOrigin: AssetDataOrigin;
provenanceVerified: boolean;
currentVersion: number;
updatedAt: string;
}
export interface InventoryBrowserParent {
id: string;
code: string;
name: string;
typeCode: string;
}
export interface InventoryQuery {
page?: number;
pageSize?: number;
search?: string;
typeId?: string;
status?: AssetInformationStatus | '';
operationalStatus?: AssetOperationalStatus | '';
operationalAreaId?: string;
operatorCompanyId?: string;
needsValidation?: boolean;
hasGeometry?: boolean;
}
function queryString(params: InventoryQuery): string {
const query = new URLSearchParams();
Object.entries(params).forEach(([key,value]) => {
if (value === undefined || value === null || value === '') return;
query.set(key,String(value));
});
return query.size ? `?${query}` : '';
}
export function listInventory(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>(
`/inventory-browser/items${queryString(params)}`,
);
}
// Compatibility alias while old call sites are removed.
export const listRealInventory = listInventory;
export function listInventoryDepartments(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserDepartment[]; meta: { count: number } }>(
`/inventory-browser/departments${queryString(params)}`,
);
}
export function listInventoryCompanies(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserCompany[]; meta: { count: number } }>(
`/inventory-browser/companies${queryString(params)}`,
);
}
export function listInventoryAreas(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>(
`/inventory-browser/areas${queryString(params)}`,
);
}
export function listInventoryChildren(parentId: string, params: InventoryQuery = {}) {
return apiRequest<{
parent: InventoryBrowserParent;
data: InventoryBrowserItem[];
meta: { count: number; hasMore: boolean };
}>(`/inventory-browser/${parentId}/children${queryString(params)}`);
}
+41 -106
View File
@@ -1,121 +1,56 @@
import { apiRequest } from './api'; import { apiRequest } from './api';
export type InventoryStructureKind = 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; export type InventoryStructureKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export type InventoryFamilyAttributeDataType = 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'SELECT';
export interface InventoryFamilyParent { id:string; code:string; name:string; }
export interface InventoryFamily { export interface InventoryFamily {
id: string; id:string; code:string; name:string; level:'INSTALLATION'|'SUBINSTALLATION'; legacyTypeCode?:string|null;
code: string; informationLabels:string[]; sourceReference?:string|null; isActive?:boolean; parentFamilyIds:string[];
name: string; parentFamilies:InventoryFamilyParent[]; assetCount?:number; findingCount?:number; findingItemIds?:string[]; technicalAttributeCount?:number;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
} }
export interface InventoryFamilyAttribute {
export interface InventoryStructureLevel { id:string; inventoryFamilyId:string; code:string; name:string; dataType:InventoryFamilyAttributeDataType;
kind: InventoryStructureKind; isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
label: string;
type: { id: string; code: string; name: string };
parentKind: InventoryStructureKind | null;
requiresFamily: boolean;
} }
export interface InventoryFamilyAttributes { family:InventoryFamily; items:InventoryFamilyAttribute[]; count:number; }
export interface InventoryStructureOptions { export interface InventoryTechnicalValues {
levels: InventoryStructureLevel[]; assetId:string;
installationFamilies: InventoryFamily[]; family:{id:string;code:string;name:string;level:string};
subinstallationFamilies: InventoryFamily[]; definitions:InventoryFamilyAttribute[];
values:Record<string,unknown>;
} }
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; installationFamilies:InventoryFamily[]; subinstallationFamilies:InventoryFamily[]; }
export interface InventoryStructureParent { export interface InventoryStructureParent {
id: string; id:string; code:string; name:string; commonName:string|null; type:{id:string;code:string;name:string};
code: string; inventoryFamily:null|{id:string;code:string;name:string;level:'INSTALLATION'|'SUBINSTALLATION';informationLabels:string[]};
name: string; parent:null|{id:string;code:string;name:string};
commonName: string | null;
type: { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
};
parent: null | { id: string; code: string; name: string };
} }
export interface InventoryFamilyFinding { export interface InventoryFamilyFinding {
id: string; id:string; code:string; sourceNumber:number; title:string; legalBasis:string|null; glossary:string|null;
code: string; suggestedSeverity:number|null; categoryId:string; categoryCode:string; categoryName:string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
suggestedSeverity: number | null;
categoryId: string;
categoryCode: string;
categoryName: string;
} }
export interface InventoryFamilyFindings { family:InventoryFamily; items:InventoryFamilyFinding[]; count:number; }
export interface InventoryFamilyFindings {
family: {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
items: InventoryFamilyFinding[];
count: number;
}
export interface CreatedInventoryStructure { export interface CreatedInventoryStructure {
id: string; id:string; code:string; name:string; commonName:string|null; description:string|null; informationStatus:string; operationalStatus:string;
code: string; type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string};
name: string; inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
commonName: string | null;
description: string | null;
informationStatus: string;
operationalStatus: string;
type: { id: string; code: string; name: string };
parent: null | { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
} }
export function getInventoryStructureOptions() { export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
return apiRequest<InventoryStructureOptions>('/inventory-structure'); export async function listInventoryStructureParents(kind:InventoryStructureKind,search=''){
} const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
export async function listInventoryStructureParents(kind: InventoryStructureKind, search = '') {
const query = new URLSearchParams();
if (search.trim()) query.set('search', search.trim());
const suffix = query.size ? `?${query}` : '';
return (await apiRequest<{ data: InventoryStructureParent[] }>(
`/inventory-structure/parents/${kind}${suffix}`,
)).data;
}
export function getInventoryFamilyFindings(familyId: string) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
}
export function createInventoryStructure(input: {
kind: InventoryStructureKind;
code?: string | null;
name: string;
commonName?: string | null;
parentId?: string | null;
familyId?: string | null;
description?: string | null;
}) {
return apiRequest<CreatedInventoryStructure>('/inventory-structure', {
method: 'POST',
body: JSON.stringify(input),
});
} }
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
export function replaceInventoryTechnicalValues(assetId:string,values:Record<string,unknown>){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`,{method:'PUT',body:JSON.stringify({values})});}
export async function listInventoryFamiliesAdmin(){return (await apiRequest<{data:InventoryFamily[]}>('/inventory-families/admin')).data;}
export function createInventoryFamily(input:{level:'INSTALLATION'|'SUBINSTALLATION';name:string;parentFamilyIds?:string[];informationLabels?:string[]}){return apiRequest<InventoryFamily>('/inventory-families',{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamily(familyId:string,input:{name?:string;parentFamilyIds?:string[];informationLabels?:string[];isActive?:boolean}){return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import './styles.css'; import './styles.css';
import './f5.css';
import { App } from './app/App'; import { App } from './app/App';
import { AuthProvider } from './auth/AuthContext'; import { AuthProvider } from './auth/AuthContext';
+215 -154
View File
@@ -1,32 +1,36 @@
import { SearchableSelect } from '../components/SearchableSelect'; import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { import {
bootstrapMasterDefaults,
createAssetAttribute, createAssetAttribute,
createAssetType, getFindingCatalogAdmin,
enrichMasterDefaults,
getMasterEnrichmentStatus,
listAssetTypes, listAssetTypes,
updateAssetAttribute, updateAssetAttribute,
updateAssetType,
} from '../lib/api'; } from '../lib/api';
import type { import type {
AssetAttributeDataType, AssetAttributeDataType,
AssetAttributeDefinition, AssetAttributeDefinition,
AssetType, AssetType,
AssetTypeOperationalRole, FindingAdminCatalog,
MasterEnrichmentStatus,
} from '../lib/api'; } from '../lib/api';
import {
const OPERATIONAL_ROLES: Array<{ value: AssetTypeOperationalRole; label: string; help: string }> = [ createInventoryFamily,
{ value: 'GENERIC', label: 'Elemento operativo / genérico', help: 'Instalaciones, estaciones, equipos y demás elementos administrables.' }, createInventoryFamilyAttribute,
{ value: 'AREA', label: 'Área', help: 'Representa el ámbito territorial de operación.' }, getInventoryFamilyAttributes,
{ value: 'COMPANY', label: 'Organización', help: 'Empresa, UTE u otra organización vinculable a áreas.' }, listInventoryFamiliesAdmin,
]; replaceInventoryFamilyFindings,
updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi';
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' }, { value: 'TEXT', label: 'Texto' },
@@ -36,28 +40,70 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
{ value: 'DATETIME', label: 'Fecha y hora' }, { value: 'DATETIME', label: 'Fecha y hora' },
{ value: 'SELECT', label: 'Lista de opciones' }, { value: 'SELECT', label: 'Lista de opciones' },
]; ];
const EMPTY_FINDING_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
function attributeTypeLabel(value: AssetAttributeDataType) { type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyEditor = InventoryFamily | 'new' | null;
type FamilyFindingMode = 'ASSOCIATED' | 'ALL';
type TechnicalEditor = InventoryFamilyAttribute | 'new' | null;
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
{ kind: 'DEPARTAMENTO', label: 'Departamento', description: 'Raíz territorial de la estructura física.' },
{ kind: 'AREA', label: 'Área', description: 'Pertenece obligatoriamente a un Departamento.' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia dentro de una Instalación y con clasificación técnica.' },
];
function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null {
if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null;
if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null;
const code = kind.toLowerCase();
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
}
function attributeTypeLabel(value: AssetAttributeDataType | InventoryFamilyAttributeDataType) {
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value; return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
} }
function attributeCodeFromName(value: string) {
function typeCodeFromName(value: string) { return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80); .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
} }
export function AssetTypesPage() { export function AssetTypesPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage'); const canManage = hasPermission('asset_types.manage');
const canManageFindings = hasPermission('finding_catalog.manage');
const [types, setTypes] = useState<AssetType[]>([]); const [types, setTypes] = useState<AssetType[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null); const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [creating, setCreating] = useState(false); const [findingCatalog, setFindingCatalog] = useState<FindingAdminCatalog>(EMPTY_FINDING_CATALOG);
const [code, setCode] = useState(''); const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION');
const [name, setName] = useState(''); const [selectedInstallationFamilyId, setSelectedInstallationFamilyId] = useState('');
const [description, setDescription] = useState(''); const [loading, setLoading] = useState(true);
const [canBeRoot, setCanBeRoot] = useState(false); const [saving, setSaving] = useState(false);
const [isActive, setIsActive] = useState(true); const [error, setError] = useState('');
const [operationalRole, setOperationalRole] = useState<AssetTypeOperationalRole>('GENERIC'); const [success, setSuccess] = useState('');
const [parentTypeIds, setParentTypeIds] = useState<string[]>([]);
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
const [familyName, setFamilyName] = useState('');
const [familyParentIds, setFamilyParentIds] = useState<Set<string>>(new Set());
const [familyActive, setFamilyActive] = useState(true);
const [familyFindingIds, setFamilyFindingIds] = useState<Set<string>>(new Set());
const [familyFindingMode, setFamilyFindingMode] = useState<FamilyFindingMode>('ASSOCIATED');
const [familyFindingSearch, setFamilyFindingSearch] = useState('');
const [technicalAttributes, setTechnicalAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [technicalLoading, setTechnicalLoading] = useState(false);
const [technicalEditor, setTechnicalEditor] = useState<TechnicalEditor>(null);
const [technicalCode, setTechnicalCode] = useState('');
const [technicalName, setTechnicalName] = useState('');
const [technicalType, setTechnicalType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [technicalRequired, setTechnicalRequired] = useState(false);
const [technicalActive, setTechnicalActive] = useState(true);
const [technicalUnit, setTechnicalUnit] = useState('');
const [technicalOptions, setTechnicalOptions] = useState('');
const [technicalOrder, setTechnicalOrder] = useState(0);
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null); const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
const [attributeCode, setAttributeCode] = useState(''); const [attributeCode, setAttributeCode] = useState('');
const [attributeName, setAttributeName] = useState(''); const [attributeName, setAttributeName] = useState('');
@@ -67,166 +113,181 @@ export function AssetTypesPage() {
const [attributeUnit, setAttributeUnit] = useState(''); const [attributeUnit, setAttributeUnit] = useState('');
const [attributeOptions, setAttributeOptions] = useState(''); const [attributeOptions, setAttributeOptions] = useState('');
const [attributeOrder, setAttributeOrder] = useState(0); const [attributeOrder, setAttributeOrder] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [enrichment, setEnrichment] = useState<MasterEnrichmentStatus | null>(null);
const selected = types.find((type) => type.id === selectedId) ?? null; const load = async () => {
const [loadedTypes, loadedFamilies, loadedFindingCatalog] = await Promise.all([
const selectType = (type: AssetType) => { listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin(),
setCreating(false); setSelectedId(type.id); setCode(type.code); setName(type.name); ]);
setDescription(type.description); setCanBeRoot(type.canBeRoot); setIsActive(type.isActive); setTypes(loadedTypes); setFamilies(loadedFamilies); setFindingCatalog(loadedFindingCatalog);
setOperationalRole(type.operationalRole);
setParentTypeIds(type.allowedParentTypes.map((parent) => parent.id));
setAttributeEditor(null); setError(''); setSuccess('');
}; };
const startCreate = () => {
setCreating(true); setSelectedId(null); setCode(''); setName(''); setDescription('');
setCanBeRoot(false); setIsActive(true); setOperationalRole('GENERIC'); setParentTypeIds([]);
setAttributeEditor(null); setError(''); setSuccess('');
};
const load = async (preferId?: string) => {
const loaded = await listAssetTypes();
setTypes(loaded);
if (loaded.length > 0) {
try { setEnrichment(await getMasterEnrichmentStatus()); } catch { setEnrichment(null); }
} else {
setEnrichment(null);
}
const next = loaded.find((type) => type.id === preferId) ?? loaded[0];
if (next) selectType(next);
};
useEffect(() => { useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []); }, []);
const toggleParent = (id: string) => setParentTypeIds((current) => const selectedType = canonicalType(types, selectedKind);
current.includes(id) ? current.filter((value) => value !== id) : [...current, id], const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
); const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
const visibleSubinstallationFamilies = useMemo(() => subinstallationFamilies.filter((family) =>
family.isActive !== false && (!selectedInstallationFamilyId || family.parentFamilyIds.includes(selectedInstallationFamilyId)),
), [subinstallationFamilies, selectedInstallationFamilyId]);
const installDefaultMaster = async () => { const activeFindingCategoryIds = useMemo(() => new Set(
const confirmed = window.confirm( findingCatalog.categories.filter((category) => category.isActive).map((category) => category.id),
'¿Instalar la configuración inicial de Hidrocarburos?\n\nSe crearán tipos, jerarquías y atributos base. No se crearán empresas, áreas ni registros reales.', ), [findingCatalog.categories]);
); const findingCategoryNames = useMemo(() => new Map(
if (!confirmed) return; findingCatalog.categories.map((category) => [category.id, category.name]),
setSaving(true); setError(''); setSuccess(''); ), [findingCatalog.categories]);
try { const visibleFamilyFindings = useMemo(() => {
const result = await bootstrapMasterDefaults(); const needle = familyFindingSearch.trim().toLocaleLowerCase('es-AR');
setTypes(result.data); return findingCatalog.items.filter((item) => {
const next = result.data.find((type) => type.code === 'area') ?? result.data[0]; if (!item.isActive || !activeFindingCategoryIds.has(item.categoryId)) return false;
if (next) selectType(next); if (familyFindingMode === 'ASSOCIATED' && !familyFindingIds.has(item.id)) return false;
setSuccess(`Configuración inicial instalada: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas de jerarquía.`); if (!needle) return true;
} catch (requestError) { setError(errorMessage(requestError)); } return [item.title, item.code, findingCategoryNames.get(item.categoryId) ?? '']
finally { setSaving(false); } .some((value) => value.toLocaleLowerCase('es-AR').includes(needle));
});
}, [findingCatalog.items, activeFindingCategoryIds, familyFindingMode, familyFindingIds, familyFindingSearch, findingCategoryNames]);
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName('');
setFamilyParentIds(new Set(level === 'SUBINSTALLATION' && selectedInstallationFamilyId ? [selectedInstallationFamilyId] : []));
setFamilyActive(true); setFamilyFindingIds(new Set()); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
setTechnicalAttributes([]); setTechnicalEditor(null); setError(''); setSuccess('');
}; };
const openFamily = async (family: InventoryFamily) => {
const enrichTechnicalCatalog = async () => { if (family.level === 'INSTALLATION') setSelectedInstallationFamilyId(family.id);
const confirmed = window.confirm( setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
'¿Completar el catálogo técnico de Hidrocarburos?\n\nSólo se agregarán tipos, atributos y relaciones de jerarquía que falten. No se modificarán tipos existentes ni se crearán registros reales.', setFamilyParentIds(new Set(family.parentFamilyIds)); setFamilyActive(family.isActive !== false);
); setFamilyFindingIds(new Set(family.findingItemIds ?? [])); setFamilyFindingMode('ASSOCIATED'); setFamilyFindingSearch('');
if (!confirmed) return; setTechnicalEditor(null); setTechnicalLoading(true); setError(''); setSuccess('');
setSaving(true); setError(''); setSuccess(''); try { setTechnicalAttributes((await getInventoryFamilyAttributes(family.id)).items); }
try { catch (requestError) { setError(errorMessage(requestError)); setTechnicalAttributes([]); }
const result = await enrichMasterDefaults(); finally { setTechnicalLoading(false); }
setTypes(result.data);
setEnrichment(await getMasterEnrichmentStatus());
const next = result.data.find((type) => type.id === selectedId) ?? result.data.find((type) => type.code === 'area') ?? result.data[0];
if (next) selectType(next);
setSuccess(`Catálogo técnico completado: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas nuevas.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
}; };
const toggleParentCompatibility = (id: string) => setFamilyParentIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const toggleFamilyFinding = (id: string) => setFamilyFindingIds((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const saveType = async (event: FormEvent) => { const saveFamily = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess(''); event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try { try {
const saved = creating if (familyEditor === 'new') {
? await createAssetType({ code, name, description, canBeRoot, operationalRole, allowedParentTypeIds: parentTypeIds }) const created = await createInventoryFamily({
: await updateAssetType(selected!.id, { name, description, canBeRoot, isActive, operationalRole, allowedParentTypeIds: parentTypeIds }); level: familyLevel, name: familyName.trim(),
await load(saved.id); parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
setSuccess(creating ? 'Tipo de elemento creado correctamente' : 'Tipo de elemento actualizado'); });
setCreating(false); setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
await load(); await openFamily(created); return;
}
if (familyEditor) {
await updateInventoryFamily(familyEditor.id, {
name: familyName.trim(), parentFamilyIds: familyLevel === 'SUBINSTALLATION' ? [...familyParentIds] : [],
isActive: familyActive,
});
if (canManageFindings) await replaceInventoryFamilyFindings(familyEditor.id, {
itemIds: [...familyFindingIds], reason: 'Actualización desde Configuración de Inventarios F6',
});
setSuccess('Clasificación actualizada.'); await load(); setFamilyEditor(null);
}
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const openTechnicalAttribute = (attribute: InventoryFamilyAttribute | 'new') => {
setTechnicalEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') {
setTechnicalCode(''); setTechnicalName(''); setTechnicalType('TEXT'); setTechnicalRequired(false);
setTechnicalActive(true); setTechnicalUnit(''); setTechnicalOptions(''); setTechnicalOrder(technicalAttributes.length);
} else {
setTechnicalCode(attribute.code); setTechnicalName(attribute.name); setTechnicalType(attribute.dataType);
setTechnicalRequired(attribute.isRequired); setTechnicalActive(attribute.isActive); setTechnicalUnit(attribute.unit ?? '');
setTechnicalOptions(attribute.options?.join('\n') ?? ''); setTechnicalOrder(attribute.sortOrder);
}
};
const saveTechnicalAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!familyEditor || familyEditor === 'new' || !technicalEditor) return;
setSaving(true); setError(''); setSuccess('');
const options = technicalOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try {
if (technicalEditor === 'new') await createInventoryFamilyAttribute(familyEditor.id, {
code: technicalCode, name: technicalName.trim(), dataType: technicalType, isRequired: technicalRequired,
unit: technicalUnit || null, ...(technicalType === 'SELECT' ? { options } : {}), sortOrder: technicalOrder,
});
else await updateInventoryFamilyAttribute(familyEditor.id,technicalEditor.id,{
name:technicalName.trim(),dataType:technicalType,isRequired:technicalRequired,isActive:technicalActive,
unit:technicalUnit || null,options:technicalType==='SELECT' ? options : null,sortOrder:technicalOrder,
});
setTechnicalAttributes((await getInventoryFamilyAttributes(familyEditor.id)).items);
setTechnicalEditor(null); setSuccess('Campo técnico actualizado.'); await load();
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
const openAttribute = (attribute: AssetAttributeDefinition | 'new') => { const openAttribute = (attribute: AssetAttributeDefinition | 'new') => {
setAttributeEditor(attribute); setAttributeEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') { if (attribute === 'new') {
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
setAttributeRequired(false); setAttributeActive(true); setAttributeUnit(''); setAttributeActive(true); setAttributeUnit(''); setAttributeOptions(''); setAttributeOrder(selectedType?.attributes.length ?? 0);
setAttributeOptions(''); setAttributeOrder(selected?.attributes.length ?? 0);
} else { } else {
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
setAttributeType(attribute.dataType); setAttributeRequired(attribute.isRequired); setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder); setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
} }
setError(''); setSuccess('');
}; };
const saveAttribute = async (event: FormEvent) => { const saveAttribute = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault(); if (!selectedType || !attributeEditor) return;
if (!selected || !attributeEditor) return;
setSaving(true); setError(''); setSuccess(''); setSaving(true); setError(''); setSuccess('');
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean); const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try { try {
const saved = attributeEditor === 'new' if (attributeEditor === 'new') await createAssetAttribute(selectedType.id, {
? await createAssetAttribute(selected.id, { code: attributeCode,name: attributeName,dataType: attributeType,isRequired: attributeRequired,
code: attributeCode, name: attributeName, dataType: attributeType, unit: attributeUnit || null,...(attributeType === 'SELECT' ? { options } : {}),sortOrder: attributeOrder,
isRequired: attributeRequired, unit: attributeUnit || null, });
...(attributeType === 'SELECT' ? { options } : {}), sortOrder: attributeOrder, else await updateAssetAttribute(selectedType.id, attributeEditor.id, {
}) name: attributeName,dataType: attributeType,isRequired: attributeRequired,isActive: attributeActive,
: await updateAssetAttribute(selected.id, attributeEditor.id, { unit: attributeUnit || null,options: attributeType === 'SELECT' ? options : null,sortOrder: attributeOrder,
name: attributeName, dataType: attributeType, });
isRequired: attributeRequired, isActive: attributeActive, await load(); setAttributeEditor(null); setSuccess('Campo general actualizado.');
unit: attributeUnit || null,
options: attributeType === 'SELECT' ? options : null,
sortOrder: attributeOrder,
});
await load(saved.id);
setSuccess(attributeEditor === 'new' ? 'Atributo agregado correctamente' : 'Atributo actualizado');
setAttributeEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); } } catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
if (loading) return <LoadingBlock label="Cargando tipos de inventario…" />; if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
return <section> <div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Administrá clasificaciones, compatibilidades, Hallazgos y campos técnicos desde un único lugar.</p></div>{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}</div>
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Definí qué clases de elementos pueden formar parte de los inventarios y qué información necesita cada una. Las reglas técnicas quedan en configuración avanzada.</p></div>{canManage && <button className="button primary" onClick={startCreate}><Icon name="plus" />Nuevo tipo</button>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>} {error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{types.length > 0 && enrichment && !enrichment.complete && <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CATÁLOGO TÉCNICO</span><h2>Completar nomenclatura de inspección</h2><p>La estructura base ya existe. Esta mejora agrega únicamente las familias técnicas que faltan: plantas, baterías, sistemas y equipos específicos, manteniendo un solo tipo Pozo con método/función configurable.</p><div className="master-bootstrap-notice"><strong>Es una ampliación no destructiva.</strong><span>No reemplaza configuraciones existentes ni crea operadoras, áreas o registros reales.</span></div></div><div className="master-bootstrap-types"><strong>Pendiente</strong><div className="bootstrap-type-grid"><span><Icon name="check" />{enrichment.missingTypeCodes.length} tipos técnicos</span><span><Icon name="check" />{enrichment.missingAttributeCount} atributos</span><span><Icon name="check" />{enrichment.missingParentRuleCount} reglas de jerarquía</span></div></div>{canManage && enrichment.canApply ? <div className="master-bootstrap-actions"><button className="button primary" onClick={enrichTechnicalCatalog} disabled={saving}><Icon name="check" />{saving ? 'Completando…' : 'Completar catálogo técnico'}</button></div> : <Alert>{enrichment.reason ?? 'No se puede aplicar automáticamente sobre esta configuración.'}</Alert>}</div>}
{types.length === 0 && !creating ? <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CONFIGURACIÓN INICIAL</span><h2>Preparar inventarios de Hidrocarburos</h2><p>La configuración de inventarios está vacía. Podés instalar una estructura inicial segura con niveles territoriales, instalaciones, sistemas y familias técnicas de inspección.</p><div className="master-bootstrap-notice"><strong>No carga datos reales automáticamente.</strong><span>Las operadoras, áreas y registros concretos se cargarán después con fuente y vigencia.</span></div></div><div className="master-bootstrap-types"><strong>Incluye</strong><div className="bootstrap-type-grid">{['Área','Organización','Yacimiento / Locación','Planta / Batería / Estación','Sistemas técnicos','Pozo con método configurable','Tanques, bombas y otros equipos','Ducto / Cañería'].map((label) => <span key={label}><Icon name="check" />{label}</span>)}</div></div>{canManage ? <div className="master-bootstrap-actions"><button className="button primary" onClick={installDefaultMaster} disabled={saving}><Icon name="check" />{saving ? 'Instalando…' : 'Instalar configuración base'}</button><button className="button secondary" onClick={startCreate} disabled={saving}><Icon name="plus" />Configurar manualmente</button></div> : <Alert>Necesitás permiso para administrar tipos de inventario y ejecutar la configuración inicial.</Alert>}</div> : <div className="asset-types-layout">
<aside className="panel role-list"><div className="role-list-heading"><strong>Tipos disponibles</strong><span>{types.length}</span></div>{types.map((type) => <button key={type.id} className={`role-list-item ${selectedId === type.id && !creating ? 'active' : ''}`} onClick={() => selectType(type)}><span><strong>{type.name}</strong><small>{type.code} · {OPERATIONAL_ROLES.find((role) => role.value === type.operationalRole)?.label ?? type.operationalRole}</small></span><span className="role-count">{type.assetCount} registro{Number(type.assetCount) === 1 ? '' : 's'}</span></button>)}</aside>
<div className="asset-type-workspace"> <div className="panel" style={{ marginBottom: 18 }}><div className="panel-heading"><div><span className="eyebrow">MODELO F6</span><h2>Estructura física fija</h2><p className="section-copy">La clasificación describe qué es cada Instalación/Subinstalación, pero nunca altera su nivel jerárquico.</p></div></div><div className="asset-browser-levels" aria-label="Estructura de Inventarios"><div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i><div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i><div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i><div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i><div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div></div><div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente; su relación con Área no reescribe el árbol físico.</p></div></div>
<form className="panel form-panel" onSubmit={saveType}>
<div className="panel-heading"><div><span className="eyebrow">{creating ? 'NUEVO TIPO' : selected?.isActive ? 'TIPO DISPONIBLE' : 'TIPO NO DISPONIBLE'}</span><h2>{creating ? 'Crear tipo de elemento' : name}</h2></div>{!creating && <span className={`status-badge ${isActive ? 'active' : 'inactive'}`}>{isActive ? 'Disponible' : 'No disponible'}</span>}</div>
<label className="field"><span>Nombre visible</span><input value={name} onChange={(event) => { setName(event.target.value); if (creating) setCode(typeCodeFromName(event.target.value)); }} disabled={!canManage} required maxLength={160} placeholder="Tanque, Bomba, Planta…" /></label>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canManage} maxLength={2000} rows={3} placeholder="Cuándo debe utilizarse este tipo de elemento" /></label>
<div className="type-summary-grid"><div><small>Comportamiento</small><strong>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.label}</strong></div><div><small>Puede estar dentro de</small><strong>{parentTypeIds.length ? types.filter((type) => parentTypeIds.includes(type.id)).map((type) => type.name).slice(0,3).join(', ') + (parentTypeIds.length > 3 ? ` +${parentTypeIds.length-3}` : '') : canBeRoot ? 'Es raíz' : 'Sin configurar'}</strong></div><div><small>Campos técnicos</small><strong>{selected?.attributes.length ?? 0}</strong></div></div>
<details className="advanced-config" open={creating}>
<summary>Configuración avanzada</summary>
<p>Estas opciones controlan reglas internas de los inventarios. La configuración inicial ya las deja preparadas para los tipos estándar.</p>
<div className="form-grid"><label className="field"><span>Código interno</span><input value={code} onChange={(event) => setCode(event.target.value.toLowerCase())} disabled={!creating || !canManage} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Comportamiento</span><SearchableSelect value={operationalRole} onChange={(event) => setOperationalRole(event.target.value as AssetTypeOperationalRole)} disabled={!canManage}>{OPERATIONAL_ROLES.map((role) => <option key={role.value} value={role.value}>{role.label}</option>)}</SearchableSelect><small>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.help}</small></label></div>
<div className="type-flags"><label className="check-row"><input type="checkbox" checked={canBeRoot} onChange={(event) => setCanBeRoot(event.target.checked)} disabled={!canManage} /><span><strong>Puede ser raíz</strong><small>Permite crear registros de este tipo sin un registro padre.</small></span></label>{!creating && <label className="check-row"><input type="checkbox" checked={isActive} onChange={(event) => setIsActive(event.target.checked)} disabled={!canManage} /><span><strong>Tipo disponible</strong><small>Los tipos inactivos se conservan para el historial pero no aparecen en altas nuevas.</small></span></label>}</div>
<div className="parent-type-section"><h3>¿Dónde puede estar contenido?</h3><p>Seleccioná sólo los tipos que pueden actuar como padre físico.</p><div className="choice-grid">{types.filter((type) => type.id !== selected?.id).map((type) => <label className={`choice-card compact ${parentTypeIds.includes(type.id) ? 'selected' : ''}`} key={type.id}><input type="checkbox" checked={parentTypeIds.includes(type.id)} onChange={() => toggleParent(type.id)} disabled={!canManage} /><span><strong>{type.name}</strong></span><Icon name="check" /></label>)}</div></div>
</details>
{canManage && <div className="form-actions">{creating && <button className="button secondary" type="button" onClick={() => types[0] && selectType(types[0])}>Cancelar</button>}<button className="button primary" disabled={saving}>{saving ? 'Guardando…' : creating ? 'Crear tipo' : 'Guardar configuración'}</button></div>}
</form>
{!creating && selected && <div className="panel attributes-panel"><div className="panel-heading"><div><span className="eyebrow">CAMPOS DINÁMICOS</span><h2>Atributos</h2></div>{canManage && <button className="button secondary" onClick={() => openAttribute('new')}><Icon name="plus" />Agregar atributo</button>}</div> <div className="dashboard-grid" style={{ alignItems: 'start' }}>
{selected.attributes.length === 0 ? <div className="inline-empty">No hay atributos configurados para este tipo.</div> : <div className="attribute-list">{selected.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => canManage && openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>} <article className="panel"><div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Tocá uno para editar Hallazgos, campos técnicos y ver sus Subinstalaciones compatibles.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className={`attribute-card ${selectedInstallationFamilyId === family.id ? 'active' : ''}`} key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.findingCount ?? 0} Hallazgos · {family.technicalAttributeCount ?? 0} campos técnicos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div></article>
<article className="panel"><div className="panel-heading"><div><span className="eyebrow">COMPATIBILIDAD</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Una misma clasificación puede ser válida para varias Instalaciones.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div><label className="field" style={{ marginBottom: 14 }}><span>Filtrar por tipo de Instalación</span><SearchableSelect value={selectedInstallationFamilyId} onChange={(event) => setSelectedInstallationFamilyId(event.target.value)}><option value="">Todas las Instalaciones</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}</SearchableSelect></label>{visibleSubinstallationFamilies.length === 0 ? <div className="inline-empty">{selectedInstallationFamilyId ? 'No hay tipos compatibles con esta Instalación.' : 'No hay tipos de Subinstalación configurados.'}</div> : <div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{visibleSubinstallationFamilies.map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => void openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin compatibilidades'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>}</article>
</div>
{attributeEditor && <form className="attribute-editor" onSubmit={saveAttribute}><div className="attribute-editor-heading"><h3>{attributeEditor === 'new' ? 'Nuevo atributo' : `Editar ${attributeEditor.name}`}</h3><button type="button" className="button text" onClick={() => setAttributeEditor(null)}>Cerrar</button></div><div className="form-grid"><label className="field"><span>Código</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Nombre</span><input value={attributeName} onChange={(event) => setAttributeName(event.target.value)} required maxLength={160} /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label><label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} placeholder="m, bar, °C…" /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} required /></label></div>{attributeType === 'SELECT' && <label className="field"><span>Opciones <em>una por línea</em></span><textarea value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} required rows={4} placeholder={'Opción A\nOpción B'} /></label>}<div className="type-flags"><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Todo registro de este tipo debe completar el valor.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Atributo activo</strong><small>Desactivarlo conserva los valores históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar atributo'}</button></div></form>} <article className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">CAMPOS GENERALES DEL NIVEL</span><h2>Información estructural</h2><p className="section-copy">Usá estos campos sólo cuando correspondan a todo el nivel. Marca, potencia, capacidad u otros datos técnicos deben configurarse dentro de cada clasificación.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nuevo campo general</button>}</div><div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div><p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay campos generales para este nivel.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}</article>
</div>}
</div> {familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN TÉCNICA</span><h2>{familyEditor === 'new' ? 'Nueva clasificación' : familyName}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">
</div>} {familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentIds(new Set()); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}
<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required /></label>
{familyLevel === 'SUBINSTALLATION' && <div className="field"><span>Tipos de Instalación compatibles <em>{familyParentIds.size} seleccionados</em></span><div className="finding-selection-list" style={{ maxHeight: 220, overflow: 'auto' }}>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label className="finding-selection-row" key={family.id}><input type="checkbox" checked={familyParentIds.has(family.id)} onChange={() => toggleParentCompatibility(family.id)} /><span><strong>{family.name}</strong><small>{family.code}</small></span></label>)}</div></div>}
{familyEditor !== 'new' && <>
<div className="temporal-notice"><Icon name="alert" /><p><strong>{familyFindingIds.size} Hallazgos asociados.</strong> La APK sólo verá estos Hallazgos para esta clasificación, además de OTROS.</p></div>
<div className="quick-view-row"><button type="button" className={familyFindingMode === 'ASSOCIATED' ? 'active' : ''} onClick={() => setFamilyFindingMode('ASSOCIATED')}>Asociados ({familyFindingIds.size})</button>{canManageFindings && <button type="button" className={familyFindingMode === 'ALL' ? 'active' : ''} onClick={() => setFamilyFindingMode('ALL')}>Agregar o quitar</button>}</div>
<label className="field"><span>Buscar Hallazgo</span><input value={familyFindingSearch} onChange={(event) => setFamilyFindingSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
{visibleFamilyFindings.length === 0 ? <div className="inline-empty">{familyFindingMode === 'ASSOCIATED' ? 'No hay Hallazgos asociados.' : 'No hay coincidencias.'}</div> : <div className="finding-selection-list" style={{ maxHeight: 280, overflow: 'auto' }}>{visibleFamilyFindings.map((item) => familyFindingMode === 'ALL' ? <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={familyFindingIds.has(item.id)} onChange={() => toggleFamilyFinding(item.id)} /><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></label> : <div className="finding-selection-row" key={item.id}><span className="asset-symbol"><Icon name="check" size={14} /></span><span><strong>{item.title}</strong><small>{findingCategoryNames.get(item.categoryId) ?? 'Catálogo'} · {item.code}</small></span></div>)}</div>}
<div className="panel-heading" style={{ marginTop: 10 }}><div><span className="eyebrow">DATOS DEL RUBRO</span><h3>Campos técnicos</h3><p className="section-copy">Sólo aparecen cuando un elemento usa esta clasificación.</p></div>{canManage && !technicalEditor && <button type="button" className="button secondary" onClick={() => openTechnicalAttribute('new')}><Icon name="plus" />Nuevo campo</button>}</div>
{technicalLoading ? <LoadingBlock label="Cargando campos técnicos…" /> : technicalAttributes.length === 0 && !technicalEditor ? <div className="inline-empty">No hay campos técnicos definidos.</div> : !technicalEditor && <div className="attribute-list">{technicalAttributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openTechnicalAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span>{attribute.isRequired && <span className="tag">Obligatorio</span>}<Icon name="chevron" /></button>)}</div>}
{technicalEditor && <div className="panel" style={{ padding: 14 }}><div className="panel-heading"><div><strong>{technicalEditor === 'new' ? 'Nuevo campo técnico' : 'Editar campo técnico'}</strong></div><button type="button" className="icon-button" onClick={() => setTechnicalEditor(null)}>×</button></div><label className="field"><span>Nombre</span><input value={technicalName} onChange={(event) => { setTechnicalName(event.target.value); if (technicalEditor === 'new') setTechnicalCode(attributeCodeFromName(event.target.value)); }} required /></label><label className="field"><span>Código interno</span><input value={technicalCode} onChange={(event) => setTechnicalCode(event.target.value.toLowerCase())} disabled={technicalEditor !== 'new'} pattern="[a-z][a-z0-9_]*" required /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={technicalType} onChange={(event) => setTechnicalType(event.target.value as InventoryFamilyAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>{technicalType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={4} value={technicalOptions} onChange={(event) => setTechnicalOptions(event.target.value)} placeholder="Una por línea" required /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={technicalUnit} onChange={(event) => setTechnicalUnit(event.target.value)} /></label><label className="field"><span>Orden</span><input type="number" min={0} max={10000} value={technicalOrder} onChange={(event) => setTechnicalOrder(Number(event.target.value))} /></label><label className="check-row"><input type="checkbox" checked={technicalRequired} onChange={(event) => setTechnicalRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Debe completarse para esta clasificación.</small></span></label>{technicalEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={technicalActive} onChange={(event) => setTechnicalActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva datos existentes.</small></span></label>}<div className="form-actions"><button type="button" className="button secondary" onClick={() => setTechnicalEditor(null)}>Cancelar</button><button type="button" className="button primary" disabled={saving || !technicalName.trim() || !technicalCode || (technicalType === 'SELECT' && !technicalOptions.trim())} onClick={(event) => void saveTechnicalAttribute(event as unknown as FormEvent)}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></div>}
<Link className="button secondary" to={`/admin/finding-catalog?familyId=${familyEditor.id}`}>Abrir Catálogo completo <Icon name="chevron" /></Link>
<label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Clasificación disponible</strong><small>Al desactivarla deja de ofrecerse en nuevas altas.</small></span></label>
</>}
</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && familyParentIds.size === 0)}>{saving ? 'Guardando…' : familyEditor === 'new' ? 'Crear clasificación' : 'Guardar clasificación'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">CAMPO GENERAL DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nuevo campo' : 'Editar campo'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Campo activo</strong><small>Desactivarlo conserva los datos actuales.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar campo'}</button></div></form></div>}
</section>; </section>;
} }
+45 -32
View File
@@ -15,14 +15,15 @@ import {
ASSET_OPERATIONAL_STATUSES, ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES, ASSET_STATUSES,
} from '../features/assets/assetPresentation'; } from '../features/assets/assetPresentation';
import { listAssets, listAssetTree, listAssetTypes } from '../lib/api'; import { listAssetTypes } from '../lib/api';
import type { import type {
AssetInformationStatus, AssetInformationStatus,
AssetListItem,
AssetOperationalStatus, AssetOperationalStatus,
AssetType, AssetType,
PageMeta, PageMeta,
} from '../lib/api'; } from '../lib/api';
import { listInventory } from '../lib/inventoryBrowserApi';
import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi';
import { formatDate } from '../lib/format'; import { formatDate } from '../lib/format';
const quickViews = [ const quickViews = [
@@ -35,7 +36,7 @@ const quickViews = [
export function AssetsPage() { export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams(); const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext(); const operationalContext = useOperationalContext();
const [assets, setAssets] = useState<AssetListItem[]>([]); const [assets, setAssets] = useState<InventoryListItem[]>([]);
const [types, setTypes] = useState<AssetType[]>([]); const [types, setTypes] = useState<AssetType[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 }); const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -59,13 +60,16 @@ export function AssetsPage() {
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus; const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
useEffect(() => { useEffect(() => {
listAssetTypes().then(setTypes).catch(() => undefined); listAssetTypes().then((data) => setTypes(data.filter((type) =>
type.operationalRole === 'COMPANY'
|| ['departamento','area','yacimiento','instalacion','subinstalacion'].includes(type.code.toLowerCase()),
))).catch(() => undefined);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (view !== 'list') return; if (view !== 'list') return;
setLoading(true); setError(''); setLoading(true); setError('');
listAssets({ listInventory({
page, pageSize: 25, search, typeId, status, page, pageSize: 25, search, typeId, status,
operationalStatus: effectiveOperationalStatus, operationalStatus: effectiveOperationalStatus,
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
@@ -75,7 +79,7 @@ export function AssetsPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]); }, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const filters: Parameters<typeof listAssetTree>[0] = useMemo(() => ({ const filters: InventoryQuery = useMemo(() => ({
search, typeId, status, operationalStatus: effectiveOperationalStatus, search, typeId, status, operationalStatus: effectiveOperationalStatus,
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]); }), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
@@ -83,26 +87,29 @@ export function AssetsPage() {
const update = (changes: Record<string, string | null>) => { const update = (changes: Record<string, string | null>) => {
const next = new URLSearchParams(urlParams); const next = new URLSearchParams(urlParams);
Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key)); Object.entries(changes).forEach(([key, value]) => value ? next.set(key, value) : next.delete(key));
next.delete('page'); next.delete('page'); setUrlParams(next);
setUrlParams(next);
}; };
const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); }; const applySearch = (event: FormEvent) => { event.preventDefault(); update({ search: draftSearch.trim() || null }); };
const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null }); const setQuick = (key: string) => update({ quick: key === 'all' ? null : key, operationalStatus: key === 'out' ? null : rawOperationalStatus || null });
const clearFilters = () => { const clearFilters = () => {
const next = new URLSearchParams(); const next = new URLSearchParams();
if (view === 'list') next.set('view', 'list'); if (view === 'list') next.set('view', 'list');
['section', 'companyId', 'parentId'].forEach((key) => { const parentId=urlParams.get('parentId'); if (parentId) next.set('parentId',parentId);
const value = urlParams.get(key);
if (value) next.set(key, value);
});
setDraftSearch(''); setUrlParams(next); setDraftSearch(''); setUrlParams(next);
}; };
const setPage = (value: number) => { const next = new URLSearchParams(urlParams); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); }; const setPage = (value: number) => {
const next = new URLSearchParams(urlParams);
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
};
const advancedActive = Boolean(typeId || status || rawOperationalStatus); const advancedActive = Boolean(typeId || status || rawOperationalStatus);
return <section> return <section>
<div className="page-heading asset-center-heading"> <div className="page-heading asset-center-heading">
<div><span className="eyebrow">INVENTARIOS</span><h1>Inventarios</h1><p>Consultá y administrá el inventario operativo de cada empresa desde un solo lugar.</p></div> <div>
<span className="eyebrow">INVENTARIOS</span>
<h1>Inventarios</h1>
<p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p>
</div>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate> <PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div> </div>
@@ -111,15 +118,15 @@ export function AssetsPage() {
<div className="asset-center-controls"> <div className="asset-center-controls">
<form className="asset-center-search" onSubmit={applySearch}> <form className="asset-center-search" onSubmit={applySearch}>
<Icon name="search" /> <Icon name="search" />
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar registro, código o identificación…" /> <input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por nombre, código o identificación…" />
<button className="button primary" type="submit">Buscar</button> <button className="button primary" type="submit">Buscar</button>
</form> </form>
<div className="quick-view-row" aria-label="Vistas rápidas"> <div className="quick-view-row" aria-label="Vistas rápidas">
{quickViews.map((item) => <button key={item.key} type="button" className={quick === item.key ? 'active' : ''} onClick={() => setQuick(item.key)}>{item.label}</button>)} {quickViews.map((item) => <button key={item.key} type="button" className={quick === item.key ? 'active' : ''} onClick={() => setQuick(item.key)}>{item.label}</button>)}
<button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button> {view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div> </div>
{(advancedOpen || advancedActive) && <div className="advanced-filter-panel"> {view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label> <label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button> <button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -127,19 +134,25 @@ export function AssetsPage() {
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{view === 'hierarchy' ? <AssetHierarchyView filters={filters} /> : loading ? <LoadingBlock label="Cargando inventario…" /> : assets.length === 0 ? <EmptyState title="No encontramos registros" text="Probá con otra búsqueda o cambiá los filtros seleccionados." /> : <div className="table-panel compact-assets-table"> {view === 'hierarchy'
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div> ? <AssetHierarchyView filters={filters} />
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Tipo</th><th>Área / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody> : loading
{assets.map((asset) => <tr key={asset.id}> ? <LoadingBlock label="Cargando Inventarios…" />
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td> : assets.length === 0
<td><span className="tag">{asset.type.name}</span></td> ? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." />
<td>{asset.operationalArea || asset.operatorCompany ? <span><strong className="table-primary">{asset.operationalArea?.name ?? 'Sin área'}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td> : <div className="table-panel compact-assets-table">
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td> <div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<td>{formatDate(asset.updatedAt)}</td> <div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td> {assets.map((asset) => <tr key={asset.id}>
</tr>)} <td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
</tbody></table></div> <td><span className="tag">{asset.type.name}</span></td>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div> <td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : asset.operatorCompany ? <span>{asset.operatorCompany.name}</span> : <span className="muted">{asset.parent?.name ?? 'Raíz / maestro independiente'}</span>}</td>
</div>} <td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
<td>{formatDate(asset.updatedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
</tr>)}
</tbody></table></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>}
</section>; </section>;
} }
+13 -12
View File
@@ -29,7 +29,7 @@ export function DashboardPage() {
return <section> return <section>
<div className="page-heading dashboard-heading"> <div className="page-heading dashboard-heading">
<div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención en Inspecciones, Hallazgos, verificaciones e Informes.</p></div> <div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención en Inspecciones, Actas, verificaciones e Informes.</p></div>
<span className={`health-pill ${health?.status === 'ok' ? 'ok' : ''}`}><span />{health?.status === 'ok' ? 'Sistema operativo' : 'Verificando sistema'}</span> <span className={`health-pill ${health?.status === 'ok' ? 'ok' : ''}`}><span />{health?.status === 'ok' ? 'Sistema operativo' : 'Verificando sistema'}</span>
</div> </div>
@@ -38,21 +38,21 @@ export function DashboardPage() {
<div className="dashboard-section-heading"><div><span className="eyebrow">HOY</span><h2>Qué requiere atención</h2></div></div> <div className="dashboard-section-heading"><div><span className="eyebrow">HOY</span><h2>Qué requiere atención</h2></div></div>
<div className="stat-grid operational-stat-grid"> <div className="stat-grid operational-stat-grid">
<Link className="stat-card" to="/inspecciones?status=PLANNED"><div className="stat-icon blue"><Icon name="calendar" /></div><div><small>INSPECCIONES PLANIFICADAS</small><strong>{summary.counts.plannedInspections}</strong><span>Pendientes de iniciar</span></div></Link> <Link className="stat-card" to="/inspecciones?status=PLANNED"><div className="stat-icon blue"><Icon name="calendar" /></div><div><small>INSPECCIONES PLANIFICADAS</small><strong>{summary.counts.plannedInspections}</strong><span>Pendientes de iniciar</span></div></Link>
<Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Requieren control operativo</span></div></Link> <Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Hallazgos que requieren control técnico</span></div></Link>
<Link className="stat-card" to="/hallazgos"><div className="stat-icon observed"><Icon name="alert" /></div><div><small>HALLAZGOS ABIERTOS</small><strong>{summary.counts.openFindings}</strong><span>Seguimiento técnico activo</span></div></Link> <Link className="stat-card" to="/seguimiento-actas"><div className="stat-icon observed"><Icon name="clipboard" /></div><div><small>ACTAS EN SEGUIMIENTO</small><strong>{summary.counts.actsInFollowUp}</strong><span>Respuestas, compromisos y regularización</span></div></Link>
<Link className="stat-card" to="/informes?view=pending"><div className="stat-icon violet"><Icon name="clipboard" /></div><div><small>ACTAS SIN INF</small><strong>{summary.counts.sealedActsWithoutReport}</strong><span>Actas selladas pendientes de Informe</span></div></Link> <Link className="stat-card" to="/informes?view=pending"><div className="stat-icon violet"><Icon name="clipboard" /></div><div><small>ACTAS SIN INFORME</small><strong>{summary.counts.sealedActsWithoutReport}</strong><span>Actas selladas que todavía no tienen Informe</span></div></Link>
</div> </div>
<div className="dashboard-section-heading compact"><div><span className="eyebrow">INVENTARIOS Y DOCUMENTOS</span><h2>Estado general</h2></div></div> <div className="dashboard-section-heading compact"><div><span className="eyebrow">INVENTARIOS Y DOCUMENTOS</span><h2>Estado general</h2></div></div>
<div className="stat-grid secondary-stat-grid"> <div className="stat-grid secondary-stat-grid">
<Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO</small><strong>{summary.counts.totalAssets}</strong><span>Elementos registrados</span></div></Link> <Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO REAL</small><strong>{summary.counts.totalAssets}</strong><span>Instancias físicas registradas</span></div></Link>
<Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Sin geometría registrada</span></div></Link> <Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Inventarios reales sin geometría</span></div></Link>
<Link className="stat-card compact" to="/informes"><div className="stat-icon gray"><Icon name="audit" /></div><div><small>INF EN PREPARACIÓN</small><strong>{summary.counts.reportsWorking}</strong><span>{summary.counts.reportsOfficialized} oficializados en GEDO</span></div></Link> <Link className="stat-card compact" to="/informes"><div className="stat-icon gray"><Icon name="audit" /></div><div><small>INFORMES EN PREPARACIÓN</small><strong>{summary.counts.reportsWorking}</strong><span>Informe creado, todavía no oficializado · {summary.counts.reportsOfficialized} oficializados</span></div></Link>
<Link className="stat-card compact" to="/hallazgos/planificacion"><div className="stat-icon green"><Icon name="calendar" /></div><div><small>PRÓXIMAS VERIFICACIONES</small><strong>{summary.counts.controlsNext30Days}</strong><span>{summary.counts.findingsWithoutControlDate} Hallazgos abiertos sin fecha de control</span></div></Link> <Link className="stat-card compact" to="/hallazgos/planificacion"><div className="stat-icon green"><Icon name="calendar" /></div><div><small>PRÓXIMAS VERIFICACIONES</small><strong>{summary.counts.controlsNext30Days}</strong><span>{summary.counts.findingsWithoutControlDate} Hallazgos abiertos sin fecha de control</span></div></Link>
</div> </div>
{hasPermission('inspection_findings.read') && <article className="panel upcoming-controls-panel"> {hasPermission('inspection_findings.read') && <article className="panel upcoming-controls-panel">
<div className="panel-heading"><div><span className="eyebrow">AGENDA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos abiertos con una fecha de verificación definida.</p></div><Link className="text-link" to="/hallazgos/planificacion">Planificar verificaciones <Icon name="chevron" size={15} /></Link></div> <div className="panel-heading"><div><span className="eyebrow">AGENDA TÉCNICA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos abiertos con una fecha de verificación definida. Este seguimiento técnico permite verificar regularización y detectar reincidencias.</p></div><Link className="text-link" to="/hallazgos/planificacion">Planificar verificaciones <Icon name="chevron" size={15} /></Link></div>
<div className="activity-list"> <div className="activity-list">
{summary.upcomingControls.length === 0 && <p className="muted">Todavía no hay controles programados.</p>} {summary.upcomingControls.length === 0 && <p className="muted">Todavía no hay controles programados.</p>}
{summary.upcomingControls.map((control) => <Link className="activity-item control-item" key={control.id} to={`/hallazgos/${control.id}`}><span className={`activity-dot ${control.nextControlOn < todayDate() ? 'overdue-dot' : ''}`} /><div><strong>{control.title}</strong><p>{control.assetCode} · {control.assetName} · {control.actCode}</p></div><time>{formatDateOnly(control.nextControlOn)}</time></Link>)} {summary.upcomingControls.map((control) => <Link className="activity-item control-item" key={control.id} to={`/hallazgos/${control.id}`}><span className={`activity-dot ${control.nextControlOn < todayDate() ? 'overdue-dot' : ''}`} /><div><strong>{control.title}</strong><p>{control.assetCode} · {control.assetName} · {control.actCode}</p></div><time>{formatDateOnly(control.nextControlOn)}</time></Link>)}
@@ -61,15 +61,16 @@ export function DashboardPage() {
<div className="dashboard-grid"> <div className="dashboard-grid">
<article className="panel"> <article className="panel">
<div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Últimos cambios</h2></div>{hasPermission('audit.read') && <Link className="text-link" to="/admin/audit">Ver auditoría <Icon name="chevron" size={15} /></Link>}</div> <div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD DE CAMPO</span><h2>Últimas actividades de los inspectores</h2><p className="section-copy">Acciones operativas realizadas por inspectores asignados.</p></div></div>
<div className="activity-list">{summary.recentAudit.length === 0 ? <p className="muted">Todavía no hay actividad registrada.</p> : summary.recentAudit.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Sistema'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div> <div className="activity-list">{summary.recentInspectorActivity.length === 0 ? <p className="muted">Todavía no hay actividad operativa de inspectores registrada.</p> : summary.recentInspectorActivity.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Inspector'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div>
</article> </article>
<article className="panel quick-panel"> <article className="panel quick-panel">
<span className="eyebrow">ACCESOS RÁPIDOS</span><h2>Administración</h2> <span className="eyebrow">ACCESOS RÁPIDOS</span><h2>Administración</h2>
<div className="quick-links"> <div className="quick-links">
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Inventario operativo organizado por empresa y área</small></span><Icon name="chevron" /></Link> <Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link>
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Tipos y campos configurables</small></span><Icon name="chevron" /></Link>} {hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
{hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>}
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>} {hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
</div> </div>
<div className="system-footnote">API {health?.version ?? '—'} · Base {health?.database === 'ok' ? 'operativa' : 'sin verificar'}</div> <div className="system-footnote">API {health?.version ?? '—'} · Base {health?.database === 'ok' ? 'operativa' : 'sin verificar'}</div>
@@ -100,8 +100,6 @@ export function InspectionVisitsPage() {
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)} {statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
</nav> </nav>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un recorrido, varias actas.</strong> La inspección representa la salida a un Área/Operadora y puede generar múltiples actas durante el recorrido. El inicio y cierre en campo siguen siendo exclusivos de la APK.</p></div>
<form className="toolbar survey-toolbar" onSubmit={applySearch}> <form className="toolbar survey-toolbar" onSubmit={applySearch}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label> <label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={updateFilter} /> <OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={updateFilter} />
+69 -204
View File
@@ -18,36 +18,35 @@ import type {
InventoryStructureParent, InventoryStructureParent,
} from '../lib/inventoryStructureApi'; } from '../lib/inventoryStructureApi';
const KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [
{ kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 }, { kind: 'DEPARTAMENTO', label: 'Departamento' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 }, { kind: 'AREA', label: 'Área' },
{ kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 }, { kind: 'YACIMIENTO', label: 'Yacimiento' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', help: 'Debe pertenecer a una Instalación.', step: 4 }, { kind: 'INSTALACION', label: 'Instalación' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación' },
{ kind: 'EMPRESA', label: 'Empresa' },
]; ];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = { const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
area: 'YACIMIENTO', departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
}; };
const parentLabelByKind: Partial<Record<InventoryStructureKind,string>> = {
function kindLabel(kind: InventoryStructureKind): string { AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación',
return KINDS.find((item) => item.kind === kind)?.label ?? kind; };
} function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; }
export function InventoryCreatePage() { export function InventoryCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const contextParentId = searchParams.get('parentId'); const contextParentId = searchParams.get('parentId');
const [options, setOptions] = useState<InventoryStructureOptions | null>(null); const [options, setOptions] = useState<InventoryStructureOptions | null>(null);
const [kind, setKind] = useState<InventoryStructureKind>('AREA'); const [kind, setKind] = useState<InventoryStructureKind>('DEPARTAMENTO');
const [parents, setParents] = useState<InventoryStructureParent[]>([]); const [parents, setParents] = useState<InventoryStructureParent[]>([]);
const [parentSearch, setParentSearch] = useState(''); const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState(''); const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState(''); const [familyId, setFamilyId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null); const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [code, setCode] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [code, setCode] = useState('');
const [commonName, setCommonName] = useState(''); const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -55,215 +54,81 @@ export function InventoryCreatePage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
getInventoryStructureOptions() getInventoryStructureOptions().then(async (loaded) => {
.then(async (loaded) => { setOptions(loaded);
setOptions(loaded); if (contextParentId) {
if (contextParentId) { const parent = await getAsset(contextParentId);
const parent = await getAsset(contextParentId); const inferred = childKindByParentType[parent.type.code.toLowerCase()];
const inferred = childKindByParentType[parent.type.code.toLowerCase()]; if (inferred) { setKind(inferred); setParentId(parent.id); }
if (inferred) { }
setKind(inferred); }).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
setParentId(parent.id);
}
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]); }, [contextParentId]);
useEffect(() => { const requiresParent = Boolean(parentLabelByKind[kind]);
if (kind === 'AREA') { const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
setParents([]); const parentLabel = parentLabelByKind[kind] ?? '';
setParentId('');
return;
}
const timer = window.setTimeout(() => {
listInventoryStructureParents(kind, parentSearch)
.then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id === contextParentId)) {
setParentId(contextParentId);
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}, 180);
return () => window.clearTimeout(timer);
}, [kind, parentSearch, contextParentId]);
const selectedParent = parents.find((item) => item.id === parentId) ?? null; useEffect(() => {
if (!requiresParent) { setParents([]); setParentId(''); return; }
const timer = window.setTimeout(() => {
listInventoryStructureParents(kind,parentSearch).then((loaded) => {
setParents(loaded);
if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId);
}).catch((requestError) => setError(errorMessage(requestError)));
},150);
return () => window.clearTimeout(timer);
}, [kind,parentSearch,contextParentId,requiresParent]);
const selectedParent = parents.find((item) => item.id===parentId) ?? null;
const families = useMemo(() => { const families = useMemo(() => {
if (!options) return [] as InventoryFamily[]; if (!options) return [] as InventoryFamily[];
if (kind === 'INSTALACION') return options.installationFamilies; if (kind==='INSTALACION') return options.installationFamilies;
if (kind === 'SUBINSTALACION') { if (kind==='SUBINSTALACION') {
const parentFamilyId = selectedParent?.inventoryFamily?.id; const parentFamilyId=selectedParent?.inventoryFamily?.id;
return parentFamilyId return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : [];
? options.subinstallationFamilies.filter((item) => item.parentFamilyId === parentFamilyId)
: [];
} }
return []; return [];
}, [options, kind, selectedParent]); },[options,kind,selectedParent]);
const selectedFamily = families.find((item) => item.id === familyId) ?? null; const selectedFamily=families.find((item)=>item.id===familyId) ?? null;
useEffect(() => { useEffect(() => {
if (!familyId) { if (!familyId) { setFamilyFindings(null); return; }
setFamilyFindings(null); getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError)));
return; },[familyId]);
}
getInventoryFamilyFindings(familyId)
.then(setFamilyFindings)
.catch((requestError) => setError(errorMessage(requestError)));
}, [familyId]);
useEffect(() => { useEffect(() => {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') setFamilyId(''); if (!requiresFamily) setFamilyId('');
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId(''); if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId('');
}, [kind, familyId, families]); },[kind,requiresFamily,families,familyId]);
const chooseKind = (next: InventoryStructureKind) => { const changeKind=(next:InventoryStructureKind) => {
setKind(next); setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); setError('');
setParentId('');
setParentSearch('');
setFamilyId('');
setFamilyFindings(null);
setError('');
}; };
const save=async(event:FormEvent) => {
const save = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
const requiresParent = kind !== 'AREA'; if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; }
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION'; if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; }
if (requiresParent && !parentId) { setSaving(true); setError('');
setError(`Seleccioná el ${kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'} padre.`);
return;
}
if (requiresFamily && !familyId) {
setError(`Seleccioná la familia de ${kindLabel(kind).toLowerCase()}.`);
return;
}
setSaving(true);
setError('');
try { try {
const created = await createInventoryStructure({ const created=await createInventoryStructure({
kind, kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null,
code: code.trim() || null, code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null,
name: name.trim(),
commonName: commonName.trim() || null,
parentId: parentId || null,
familyId: familyId || null,
description: description.trim() || null,
}); });
navigate(`/inventarios/${created.id}`, { replace: true }); navigate(`/inventarios/${created.id}`,{replace:true});
} catch (requestError) { } catch(requestError) { setError(errorMessage(requestError)); }
setError(errorMessage(requestError)); finally { setSaving(false); }
} finally {
setSaving(false);
}
}; };
if (loading) return <LoadingBlock label="Preparando alta de Inventario…" />; if (loading) return <LoadingBlock label="Preparando alta…" />;
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
const currentStep = KINDS.find((item) => item.kind === kind)?.step ?? 1;
return <section className="narrow-section asset-detail-page"> return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario"> <nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del Inventario"><Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong></nav>
<Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong> <div className="page-heading asset-editor-heading"><div><span className="eyebrow">CARGA MANUAL</span><h1>Nuevo registro</h1><p>Para crear sólo necesitás ubicación, clasificación cuando corresponda y nombre. DH genera el código automáticamente.</p></div></div>
</nav>
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">INVENTARIO</span>
<h1>Agregar a la estructura</h1>
<p>La estructura oficial es Área Yacimiento Instalación Subinstalación. Elegí el nivel y el sistema te guía con los vínculos válidos.</p>
</div>
</div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}>
<div className="form-section">
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Sólo se pueden crear los cuatro niveles estructurales definidos para DH.</p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 }}>
{KINDS.map((item) => <button
key={item.kind}
type="button"
className={`button ${kind === item.kind ? 'primary' : 'secondary'}`}
onClick={() => chooseKind(item.kind)}
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
>
<strong>{item.step}. {item.label}</strong>
<small>{item.help}</small>
</button>)}
</div>
<div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="layers" />
<p><strong>Ruta:</strong> {KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
</div>
</div>
</div>
<form className="panel form-panel" onSubmit={save}> <form className="panel form-panel" onSubmit={save}>
{kind !== 'AREA' && <div className="form-section"> <div className="form-section"><div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Departamento Área Yacimiento Instalación Subinstalación. Empresa queda fuera del árbol físico.</p></div><label className="field"><span>Tipo</span><select value={kind} onChange={(event)=>changeKind(event.target.value as InventoryStructureKind)}>{KINDS.map((item)=><option key={item.kind} value={item.kind}>{item.label}</option>)}</select></label></div>
<div><h2>2. Ubicación en la estructura</h2><p className="section-copy">Primero elegí el {parentLabel} al que pertenece este registro.</p></div> {requiresParent && <div className="form-section"><div><h2>2. Ubicación</h2><p className="section-copy">Elegí el {parentLabel.toLowerCase()} concreto.</p></div><label className="field"><span>Buscar {parentLabel.toLowerCase()}</span><input value={parentSearch} onChange={(event)=>setParentSearch(event.target.value)} placeholder={`Buscar ${parentLabel.toLowerCase()}`} /></label><label className="field"><span>{parentLabel} <em>obligatorio</em></span><select value={parentId} onChange={(event)=>{setParentId(event.target.value);setFamilyId('');}} required><option value="">Seleccionar</option>{parents.map((parent)=><option key={parent.id} value={parent.id}>{parent.name} · {parent.code}</option>)}</select></label></div>}
<label className="field"> {requiresFamily && <div className="form-section"><div><h2>3. Clasificación técnica</h2><p className="section-copy">Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.</p></div>{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación elegida todavía no tiene clasificación técnica.</Alert> : <label className="field"><span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span><select value={familyId} onChange={(event)=>setFamilyId(event.target.value)} required><option value="">Seleccionar</option>{families.map((family)=><option key={family.id} value={family.id}>{family.name}</option>)}</select></label>}{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && <Alert>No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}. Configuralos primero en Configuración de Inventarios.</Alert>}{selectedFamily && <div className="context-create-banner"><Icon name="alert" /><div><strong>{selectedFamily.name}</strong><span>{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}</span>{familyFindings && familyFindings.items.length>0 && <ul style={{margin:'8px 0 0',paddingLeft:18}}>{familyFindings.items.slice(0,6).map((item)=><li key={item.id}>{item.title}</li>)}{familyFindings.items.length>6 && <li>+ {familyFindings.items.length-6} más</li>}</ul>}</div></div>}</div>}
<span>Buscar {parentLabel.toLowerCase()}</span> <div className="form-section"><div><h2>{requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre</h2><p className="section-copy">Los datos técnicos se completan después, en la ficha del elemento.</p></div><label className="field"><span>Nombre <em>obligatorio</em></span><input autoFocus value={name} onChange={(event)=>setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label><details style={{marginTop:12}}><summary style={{cursor:'pointer',fontWeight:700}}>Datos opcionales</summary><div style={{marginTop:14}}><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event)=>setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejalo vacío para generar automáticamente" /></label><label className="field"><span>Nombre habitual</span><input value={commonName} onChange={(event)=>setCommonName(event.target.value)} maxLength={200} /></label></div><label className="field"><span>Descripción</span><textarea value={description} onChange={(event)=>setDescription(event.target.value)} rows={2} maxLength={4000} /></label></div></details></div>
<input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar por nombre o código de ${parentLabel.toLowerCase()}`} /> <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || (requiresFamily && !familyId)}><Icon name="check" />{saving?'Creando…':`Crear ${kindLabel(kind)}`}</button></div>
</label>
<label className="field">
<span>{parentLabel} padre <em>obligatorio</em></span>
<select value={parentId} onChange={(event) => { setParentId(event.target.value); setFamilyId(''); }} required>
<option value="">Seleccionar {parentLabel.toLowerCase()}</option>
{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code}{parent.inventoryFamily ? ` · ${parent.inventoryFamily.name}` : ''}</option>)}
</select>
<small>No se permiten saltos de nivel ni padres incompatibles.</small>
</label>
</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="form-section">
<div><h2>3. Familia técnica</h2><p className="section-copy">La familia no crea otro nivel. Sirve para aplicar exactamente los Hallazgos del Excel que corresponden.</p></div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una familia técnica F3.1. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
<span>Familia de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
<option value="">Seleccionar familia</option>
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
</select>
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
</label>}
{selectedFamily && <div className="context-create-banner" style={{ alignItems: 'flex-start' }}>
<Icon name="alert" />
<div style={{ flex: 1 }}>
<strong>Hallazgos asociados automáticamente</strong>
<span>{familyFindings ? `${familyFindings.count} controles del Excel para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
{familyFindings && familyFindings.items.length > 0 && <ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{familyFindings.items.slice(0, 7).map((item) => <li key={item.id}>{item.title}</li>)}
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} hallazgos más</strong></li>}
</ul>}
</div>
</div>}
{selectedFamily && selectedFamily.informationLabels.length > 0 && <div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Información técnica esperada:</strong> {selectedFamily.informationLabels.join(' · ')}</p>
</div>}
</div>}
<div className="form-section">
<div><h2>{kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4'}. Identificación</h2><p className="section-copy">Usá el nombre real de campo. El código DH puede generarse automáticamente.</p></div>
<div className="form-grid">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
<label className="field"><span>Código DH <em>opcional</em></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejar vacío para generar automáticamente" /><small>Si no lo informás, DH genera un código único.</small></label>
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder="Nombre usado por los inspectores en campo" /></label>
</div>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
</div>
<div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={saving || !name.trim() || (kind !== 'AREA' && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
<Icon name="check" />{saving ? 'Creando…' : `Crear ${kindLabel(kind)}`}
</button>
</div>
</form> </form>
</section>; </section>;
} }