Compare commits

..
Author SHA1 Message Date
admin 3495b33157 CI: replicar preflight API aislado del deploy VPS 2026-09-07 15:48:40 -03:00
admin 115a6a2ed8 F3.1 hotfix: ejecutar contrato visual dentro del job WEB 2026-09-07 15:47:30 -03:00
admin 4796581532 F3.1 hotfix: mover contrato visual a CI WEB 2026-09-07 15:47:16 -03:00
admin 1d3979fda3 F3.1 hotfix: aislar tests API del filesystem WEB 2026-09-07 15:47:02 -03:00
admin 72e75fab2f F3.1 · Inventario estructural, catálogo contextual y conciliación cronológica (#18)
Integra F3.1 con jerarquía estructural de Inventario, familias técnicas/Otro, catálogo contextual, merges append-only, perfil de Inspector, entrega documental al Inspector y APK Android 0.11.0. Todas las barreras API/WEB/DB/Docker/Android quedaron verdes antes del merge.
2026-09-07 15:39:26 -03:00
admin 29d541a274 F3.1 Android: disparar build final 0.11.0 2026-09-07 15:29:44 -03:00
admin d9a23c4c80 F3.1 Android: ejecutar validación final de APK 0.11.0 2026-09-07 15:29:13 -03:00
admin f2cb276785 F3.1 CI: validar entrega documental al Inspector 2026-09-07 15:26:14 -03:00
admin 37e4b840d9 F3.1 WEB: mostrar entregas documentales al Inspector 2026-09-07 15:25:40 -03:00
admin 463734f5b0 F3.1: enviar copia del Acta al Inspector responsable 2026-09-07 15:24:27 -03:00
admin f334f2baaa F3.1: agregar Inspector al outbox documental 2026-09-07 15:23:28 -03:00
admin e689ab57ec F3.1 CI: usar nombres reales del merge de catálogo 2026-09-07 15:20:20 -03:00
admin e54fa4503a F3.1 WEB: administrar merge cronológico de Hallazgos de catálogo 2026-09-07 15:15:58 -03:00
admin 810ac860c7 F3.1 WEB: cliente para conciliación del catálogo de Hallazgos 2026-09-07 15:15:19 -03:00
admin a4b5bdd3a9 F3.1 CI: corregir asserts PostgreSQL y cubrir merge de catálogo 2026-09-07 15:12:52 -03:00
admin a02a2dc4a8 F3.1: corregir contrato de contexto móvil estructural 2026-09-07 14:46:48 -03:00
admin 9455254e6a F3.1: estabilizar tipos del dossier fusionado 2026-09-07 14:46:04 -03:00
admin 7329cee080 F3.1 APK: usar catálogo familiar y limitar Hallazgos a niveles técnicos 2026-09-07 14:44:30 -03:00
admin 76b688ac82 F3.1: usar familia técnica y exponer merge de catálogo 2026-09-07 14:43:43 -03:00
admin 5fc239c664 F3.1: registrar resolver y merge del catálogo 2026-09-07 14:43:14 -03:00
admin 6f335ab482 F3.1: implementar merge cronológico del catálogo 2026-09-07 14:42:50 -03:00
admin a521d0fec7 F3.1: resolver Hallazgos por familia técnica 2026-09-07 14:42:09 -03:00
admin 48b6962d93 F3.1: agregar DTO de merge de Hallazgos de catálogo 2026-09-07 14:41:47 -03:00
admin aa26dc2227 F3.1: agregar merge auditable del catálogo de Hallazgos 2026-09-07 14:41:34 -03:00
admin 0e14157a44 F3.1 CI: compilar APK estructural 0.11.0 2026-09-07 14:39:03 -03:00
admin 2acc3e095b F3.1 Android: versionar APK 0.11.0 2026-09-07 14:38:47 -03:00
admin 9d5da0522d F3.1 Android: activar flujo estructural dentro de la inspección 2026-09-07 14:37:59 -03:00
admin e443031bda F3.1 Android: crear flujo estructural intuitivo de inspección 2026-09-07 14:37:30 -03:00
admin 3dba89f55a F3.1 Android: mantener compatibilidad durante reemplazo de Inventario 2026-09-07 14:35:58 -03:00
admin dcc2e56b3e F3.1 Android: clasificar y conciliar altas de campo 2026-09-07 14:31:47 -03:00
admin 51a2789bbd F3.1 Android: agregar familias y merge de Inventario 2026-09-07 14:30:52 -03:00
admin 1212a21ea3 F3.1 APK: usar alta estructural y familia contextual 2026-09-07 14:29:23 -03:00
admin e296f18ed1 F3.1 APK: registrar flujo estructural de campo 2026-09-07 14:29:02 -03:00
admin 782b0150c5 F3.1 APK: alinear alta de campo con estructura y familias 2026-09-07 14:28:39 -03:00
admin f92611eaa1 F3.1 APK: incluir familia técnica en alta de campo 2026-09-07 14:27:45 -03:00
admin e7a3faa912 F3.1: probar perfil, dossier y alta Otro 2026-09-07 14:26:05 -03:00
admin 3478c98f11 F3.1 CI: validar migraciones en PostgreSQL real 2026-09-07 14:25:47 -03:00
admin 9e58a5fa9e F3.1: agregar Otro para Instalación y Subinstalación 2026-09-07 14:24:51 -03:00
admin bd71e74df6 F3.1 WEB: gestionar merge desde el expediente 2026-09-07 14:23:37 -03:00
admin 636431f936 F3.1 WEB: agregar contrato de merge cronológico 2026-09-07 14:22:28 -03:00
admin dafb57d706 F3.1: servir dossier cronológico agregado 2026-09-07 14:20:47 -03:00
admin a450665a6d F3.1: registrar dossier unificado por merge 2026-09-07 14:20:22 -03:00
admin 9645d29ad8 F3.1: unificar dossier cronológico tras merges 2026-09-07 14:19:54 -03:00
admin f5b8ae38e3 F3.1 WEB: editar perfil personal completo del Inspector 2026-09-07 14:16:46 -03:00
admin b996a979d6 F3.1 WEB: agregar icono de email 2026-09-07 14:15:58 -03:00
admin ebc4c303e4 F3.1 WEB: ampliar alta de usuarios e Inspector 2026-09-07 14:15:23 -03:00
admin 5d066d1819 F3.1 WEB: agregar contrato de perfil personal 2026-09-07 14:14:50 -03:00
admin ff5cf080e7 F3.1: persistir perfil personal y exigir email al Inspector 2026-09-07 14:13:43 -03:00
admin 4e2c7f85ef F3.1: ampliar edición del perfil de usuario 2026-09-07 14:12:09 -03:00
admin 9ece0c1100 F3.1: ampliar alta administrativa de usuario 2026-09-07 14:11:47 -03:00
admin 066b387f2b F3.1: agregar datos personales al usuario 2026-09-07 14:11:21 -03:00
admin a3fbc17cb3 F3.1: ampliar perfil personal del inspector 2026-09-07 14:10:53 -03:00
admin fd94200fc5 F3.1: probar merge cronológico sin pérdida de historia 2026-09-07 14:09:24 -03:00
admin 8896e8d183 F3.1: registrar merge cronológico de Inventario 2026-09-07 14:08:55 -03:00
admin 516b310c48 F3.1: exponer merge cronológico en plataforma y APK 2026-09-07 14:08:38 -03:00
admin 08291acc8d F3.1: implementar merge cronológico de Inventario 2026-09-07 14:08:22 -03:00
admin 7d041dd3b2 F3.1: agregar DTO de fusión de Inventario 2026-09-07 14:07:01 -03:00
admin 34dcd68482 F3.1: agregar merge cronológico de Inventario 2026-09-07 14:06:47 -03:00
admin a76da6787c F3.1: corregir build Docker de alta estructural 2026-09-07 14:01:14 -03:00
admin 3ce85d8d2e F3.1 CI: validar Inventario estructural y catálogo 2026-09-07 10:23:20 -03:00
admin e5b522b667 F3.1: versionar WEB 0.21.0-1 2026-09-07 10:22:36 -03:00
admin 31fc355e98 F3.1: alinear versión de paquete API 2026-09-07 10:22:26 -03:00
admin af6ccfee4c F3.1: versionar API 0.24.0-1 2026-09-07 10:22:11 -03:00
admin 8582274643 F3.1: probar estructura de Inventario y catálogo revisado 2026-09-07 10:22:02 -03:00
admin 6102740647 F3.1: permitir consulta contextual desde Inventario 2026-09-07 10:21:12 -03:00
admin dec8908bbe F3.1 WEB: activar alta guiada y retirar rutas de Relevamientos 2026-09-07 10:20:45 -03:00
admin 4902e97b51 F3.1 WEB: retirar Relevamientos de navegación activa 2026-09-07 10:20:14 -03:00
admin d5bd5777f1 F3.1 WEB: crear alta guiada de Inventario 2026-09-07 10:19:40 -03:00
admin f34d6fe521 F3.1 WEB: agregar cliente de Inventario estructural 2026-09-07 10:18:19 -03:00
admin 5d2fec0f25 F3.1: integrar catálogo por familia técnica 2026-09-07 10:18:00 -03:00
admin 0f94a20298 F3.1: exponer Hallazgos por familia técnica 2026-09-07 10:17:34 -03:00
admin f3af297a71 F3.1: consultar Hallazgos asociados por familia 2026-09-07 10:17:19 -03:00
admin 765d48f694 F3.1: exponer padres válidos para cada nivel 2026-09-07 10:16:07 -03:00
admin 0f2885c562 F3.1: listar padres válidos para alta guiada 2026-09-07 10:15:47 -03:00
admin ab3e699926 F3.1: limitar cambio de Asset a familia técnica 2026-09-07 10:13:01 -03:00
admin 90c049a3c6 F3.1: sincronizar catálogo por familia técnica 2026-09-07 10:10:14 -03:00
admin 63528b1eff F3.1: integrar Inventario estructural 2026-09-07 10:09:17 -03:00
admin 344699ac75 F3.1: exponer alta guiada de Inventario 2026-09-07 10:08:59 -03:00
admin 620e6f07c4 F3.1: crear servicio guiado de Inventario estructural 2026-09-07 10:08:47 -03:00
admin 14d9467c0e F3.1: definir alta guiada de estructura de Inventario 2026-09-07 10:07:46 -03:00
admin 4f1e24ef83 F3.1: aceptar familia técnica en altas de Inventario 2026-09-07 10:06:53 -03:00
admin c4796adb39 F3.1: asociar Inventario con familia técnica 2026-09-07 10:06:27 -03:00
admin f387645ff9 F3.1: migrar Inventario estructural y recargar catálogo 2026-09-07 10:06:03 -03:00
admin da2598c03d F3.1: registrar estructura y catálogo desde Excel revisado 2026-09-07 10:01:57 -03:00
admin 26ec07864a docs: conservar README 2026-09-05 22:21:47 -03:00
admin 65cb28299d docs: mantener README estable 2026-09-05 22:21:39 -03:00
admin f2f9ccaa72 docs: no-op README 2026-09-05 22:21:34 -03:00
admin c7fd1f8583 docs: estabilizar README 2026-09-05 22:21:28 -03:00
admin c50360e732 docs: simplificar README 2026-09-05 22:21:21 -03:00
admin 387357b96c docs: restaurar README operativo 2026-09-05 22:21:13 -03:00
admin eb47a3d66f docs: registrar política operativa de reset seguro 2026-09-05 22:21:04 -03:00
66 changed files with 5661 additions and 170 deletions
+3 -1
View File
@@ -1,4 +1,5 @@
name: Android APK
# F3.1: este workflow genera la APK debug verificable de la rama antes de promoverla.
on:
push:
@@ -6,6 +7,7 @@ on:
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
paths:
- 'android-app/**'
- '.github/workflows/android.yml'
@@ -55,7 +57,7 @@ jobs:
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F2.4-0.10.3-debug
name: DH-Inspeccion-F3.1-0.11.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 14
+12
View File
@@ -47,6 +47,8 @@ jobs:
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
contract:
@@ -62,5 +64,15 @@ jobs:
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API preflight
run: |
set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+205
View File
@@ -0,0 +1,205 @@
name: F3.1 Inventory CI
on:
push:
branches:
- 'feature/f3-1*'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck 2>&1 | tee typecheck.log
- name: Tests
run: npm test 2>&1 | tee test.log
- name: Build
run: npm run build 2>&1 | tee build.log
- name: Diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: f3-1-api-diagnostics
path: |
api-v3/typecheck.log
api-v3/test.log
api-v3/build.log
if-no-files-found: warn
retention-days: 3
migrations:
name: DB · F3.1 on PostgreSQL 16/PostGIS
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: dhv2_owner
POSTGRES_PASSWORD: owner_test_password
POSTGRES_DB: dhv2_ci
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U dhv2_owner -d dhv2_ci"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_NAME: dhv2_ci
DB_MIGRATION_USER: dhv2_owner
DB_MIGRATION_PASSWORD: owner_test_password
DB_APP_USER: dhv2_app
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: PostgreSQL client
run: |
command -v psql >/dev/null || {
sudo apt-get update
sudo apt-get install -y postgresql-client
}
- name: Prepare roles and extensions
env:
PGPASSWORD: owner_test_password
run: |
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U dhv2_owner -d dhv2_ci <<'SQL'
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
CREATE ROLE dhv2_app LOGIN PASSWORD 'app_test_password';
END IF;
END
$$;
GRANT CONNECT ON DATABASE dhv2_ci TO dhv2_app;
GRANT USAGE ON SCHEMA public TO dhv2_app;
CREATE TABLE IF NOT EXISTS typeorm_migrations (
id SERIAL PRIMARY KEY,
timestamp bigint NOT NULL,
name varchar NOT NULL
);
INSERT INTO typeorm_migrations (timestamp,name)
VALUES (1788652800000,'ResetProductionOperationalData1788652800000');
SQL
- run: npm ci
- run: npm run build
- name: Run every pending migration
run: npm run migration:run
- name: Assert no pending migrations
run: |
npm run migration:show | tee /tmp/migrations.txt
grep -Fq 'Pending migrations: no' /tmp/migrations.txt
- name: Assert F3.1 structure, merges and Inspector profile
env:
PGPASSWORD: owner_test_password
run: |
set -Eeuo pipefail
query() {
psql -At -v ON_ERROR_STOP=1 \
-h 127.0.0.1 \
-U dhv2_owner \
-d dhv2_ci \
-c "$1"
}
assert_eq() {
local expected="$1"
local sql="$2"
local actual
actual="$(query "$sql")"
if [ "$actual" != "$expected" ]; then
echo "ASSERT FAILED"
echo "SQL: $sql"
echo "Expected: $expected"
echo "Actual: $actual"
exit 1
fi
}
assert_eq 15 "SELECT count(*) FROM inventory_families WHERE level='INSTALLATION' AND is_active=true"
assert_eq 124 "SELECT count(*) FROM inventory_families WHERE level='SUBINSTALLATION' AND is_active=true"
assert_eq 16 "SELECT count(*) FROM inventory_families WHERE source_reference LIKE 'SYSTEM:F3.1:OTHER%'"
assert_eq 1 "SELECT count(*) FROM finding_categories WHERE lower(code)='app26r2' AND is_active=true"
assert_eq 1 "SELECT count(*) FROM finding_categories WHERE lower(code)='app26' AND is_active=false"
assert_eq 1 "SELECT count(*) FROM information_schema.tables WHERE table_name='asset_merges'"
assert_eq 1 "SELECT count(*) FROM pg_trigger WHERE tgname='trg_asset_merges_append_only' AND NOT tgisinternal"
assert_eq 1 "SELECT count(*) FROM information_schema.tables WHERE table_name='finding_catalog_item_merges'"
assert_eq 1 "SELECT count(*) FROM pg_trigger WHERE tgname='trg_finding_catalog_item_merges_append_only' AND NOT tgisinternal"
assert_eq 4 "SELECT count(*) FROM information_schema.columns WHERE table_name='users' AND column_name IN ('dni','phone','job_title','employee_number')"
assert_eq 2 "SELECT count(*) FROM pg_trigger WHERE tgname IN ('trg_user_roles_inspector_email','trg_users_inspector_email') AND NOT tgisinternal"
assert_eq 1 "SELECT count(*) FROM information_schema.columns WHERE table_name='inspection_document_deliveries' AND column_name='recipient_user_id'"
assert_eq 1 "SELECT count(*) FROM pg_constraint WHERE conname='chk_inspection_document_deliveries_recipient_kind' AND pg_get_constraintdef(oid) LIKE '%INSPECTOR%'"
assert_eq 1 "SELECT count(*) FROM pg_constraint WHERE conname='fk_inspection_document_deliveries_recipient_user'"
assert_eq 230 "SELECT count(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.data_origin='IMPORT' AND a.source_name='Tablas de yacimiento(1).xlsx' AND lower(t.code)='yacimiento'"
assert_eq 0 "SELECT count(*) FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)"
web:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck 2>&1 | tee typecheck.log
- name: Build
run: npm run build 2>&1 | tee build.log
- name: Diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: f3-1-web-diagnostics
path: |
web-v2/typecheck.log
web-v2/build.log
if-no-files-found: warn
retention-days: 3
docker:
needs: [api, web, migrations]
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+6 -31
View File
@@ -1,35 +1,10 @@
# DH Inspección V2 · D5.6.2
Hotfix de mantenimiento de Fase D para accesos Android y contrato de regresión de cierre.
# DH Inspección V2
Versión: **0.19.5-1**
Fase: **D5.5.1 · Corrección de staging de planificación**
Repositorio del sistema DH Inspección V2.
D5.5.1 conserva íntegramente D5.5 y corrige únicamente el contrato de pruebas de staging. D5.5 consolida la preparación de visitas desde oficina antes del trabajo de campo. La visita queda anclada explícitamente a Área y Organización operadora, y el sistema genera un checklist auditable con antecedentes, respuestas vencidas, controles vencidos y próximos controles.
## Estructura
## Alcance D5.5
- selección web Área → Organización operadora vigente;
- fecha y equipo inspector dentro del plan;
- checklist automático versionado y append-only;
- respuestas de empresa vencidas y controles de verificación vencidos;
- próximos controles dentro de 30 días de la fecha planificada;
- antecedentes históricos del mismo contexto;
- incorporación automática de registros accionables;
- alta manual de registros preventivos del mismo Área/Operadora;
- exclusiones sólo mediante motivo auditable;
- reincorporación sin borrar la exclusión histórica;
- visitas de verificación integradas al mismo contrato de planificación;
- cambio de contexto o fecha invalida el checklist hasta regeneración explícita;
- inicio de visita exclusivamente desde APK mediante la política móvil ya existente.
## Migración
`1789063200000-phase-d5-5-web-planning-checklist.ts`
`PhaseD55WebPlanningChecklist1789063200000`
## Despliegue
El despliegue debe partir exactamente de API/WEB **0.19.4-1** y ejecutar `scripts/deploy-phase-d5-5.sh`. Antes de modificar producción, el script compila API y web en staging y exige **252 tests** aprobados.
- `api-v3/`: API v3.
- `web-v2/`: aplicación web.
- `scripts/`: utilidades operativas y de despliegue.
- `docs/`: documentación técnica.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 17
versionName = "0.10.3"
versionCode = 18
versionName = "0.11.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -150,6 +150,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun createFieldAsset(
type: FieldType,
parentId: String?,
familyId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
@@ -162,10 +163,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La inspección debe estar en curso para dar de alta Inventario."
return
}
if (type.familyRequired && familyId == null) {
error = "Elegí una familia técnica o la opción Otro / no catalogado."
return
}
launchBusy {
val request = CreateFieldInventoryRequest(
typeId = type.id,
parentId = parentId,
familyId = familyId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
@@ -175,11 +181,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
deviceCapturedAt = Instant.now().toString(),
)
selectedFieldAsset = repository.createFieldAsset(visitId, request)
notice = "Inventario creado con GPS. Falta la fotografía obligatoria."
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
}
}
fun mergeCreatedFieldAsset(canonicalAssetId: String, reason: String) {
val visitId = visit?.id ?: return
val source = selectedFieldAsset?.asset ?: return
if (canonicalAssetId.isBlank()) {
error = "Elegí el registro existente que se conservará."
return
}
if (reason.trim().length < 8) {
error = "Explicá brevemente por qué se trata de un duplicado."
return
}
launchBusy {
val result = repository.mergeFieldAsset(
visitId = visitId,
assetId = source.id,
canonicalAssetId = canonicalAssetId,
reason = reason,
)
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(visitId, result.canonical.id)
}
}
}
fun uploadFieldPhoto(
file: File,
latitude: Double,
@@ -214,11 +214,24 @@ data class FieldAttributeDefinition(
val sortOrder: Int = 0,
)
data class FieldInventoryFamily(
val id: String,
val code: String,
val name: String,
val level: String,
val informationLabels: List<String> = emptyList(),
val sourceReference: String? = null,
val isOther: Boolean = false,
)
data class FieldType(
val id: String,
val code: String,
val name: String,
val description: String? = null,
val structuralKind: String? = null,
val families: List<FieldInventoryFamily> = emptyList(),
val familyRequired: Boolean = false,
val attributes: List<FieldAttributeDefinition> = emptyList(),
)
@@ -246,6 +259,7 @@ data class FieldAssetDetail(
data class CreateFieldInventoryRequest(
val typeId: String,
val parentId: String? = null,
val familyId: String? = null,
val code: String? = null,
val name: String,
val commonName: String? = null,
@@ -263,6 +277,19 @@ data class FieldPhotoResponse(
val capture: CaptureStatus,
)
data class MergeFieldInventoryRequest(
val canonicalAssetId: String,
val reason: String,
)
data class FieldInventoryMergeResult(
val source: AssetSummary,
val canonical: AssetSummary,
val sourceVersionNumber: Int,
val reparentedChildIds: List<String> = emptyList(),
val historyPolicy: String,
)
interface DhApi {
@POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@@ -322,6 +349,14 @@ interface DhApi {
@Body request: CreateFieldInventoryRequest,
): FieldAssetDetail
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/merge")
suspend fun mergeFieldAsset(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Body request: MergeFieldInventoryRequest,
): FieldInventoryMergeResult
@Multipart
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/photos")
suspend fun uploadFieldPhoto(
@@ -465,6 +500,20 @@ class DhRepository(context: Context) {
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
}
suspend fun mergeFieldAsset(
visitId: String,
assetId: String,
canonicalAssetId: String,
reason: String,
): FieldInventoryMergeResult = authorized { session ->
api.mergeFieldAsset(
"Bearer ${session.accessToken}",
visitId,
assetId,
MergeFieldInventoryRequest(canonicalAssetId, reason.trim()),
)
}
suspend fun uploadFieldPhoto(
visitId: String,
assetId: String,
@@ -0,0 +1,738 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class F3GeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun F3VisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
if (inventoryMode) {
F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
} else {
F3VisitOverview(model, onInventory = { inventoryMode = true })
}
}
@Composable
private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
val visit = model.visit ?: return
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
Text(statusLabel(visit.status), fontWeight = FontWeight.Bold)
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
visit.instructions?.takeIf { it.isNotBlank() }?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text("Instrucciones", fontWeight = FontWeight.Bold)
Text(it)
}
}
}
F3MessageStrip(model)
if (visit.status == "PLANNED") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Button(
onClick = { model.startVisit() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
}
}
}
if (visit.status == "IN_PROGRESS") {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar altas, fotografías y Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
}
F3ChecklistCard(visit)
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (visit.planningAssets.none { it.included }) {
Text("Sin elementos planificados.")
}
visit.planningAssets.filter { it.included }.forEach { asset ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun F3ChecklistCard(visit: VisitDetail) {
val checklist = visit.checklist
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
if (checklist.stale) {
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
LaunchedEffect(visit.id) {
model.searchInventory("")
model.loadFieldTypes(null)
}
LaunchedEffect(model.fieldTypes) {
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = model.fieldTypes.firstOrNull()?.id
selectedFamilyId = null
attributeValues.clear()
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
fun resetCreateForm() {
name = ""
commonName = ""
selectedFamilyId = null
attributeValues.clear()
}
val createWithLocation: () -> Unit = {
val type = selectedType
if (type != null) {
scope.launch {
runCatching { currentF3Geo(context) }
.onSuccess { geo ->
model.createFieldAsset(
type = type,
parentId = parentId,
familyId = selectedFamilyId,
name = name,
commonName = commonName,
attributes = buildF3Attributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
}
}
}
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
if (allowed) createWithLocation()
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<F3GeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeF3Exif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentF3Geo(context) }.onSuccess { geo ->
val (file, uri) = newF3Photo(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
}
}
val photoPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
val camera = result[Manifest.permission.CAMERA] == true || f3HasPermission(context, Manifest.permission.CAMERA)
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f3HasLocation(context)
if (camera && location) beginPhoto()
}
val selectedCapture = model.selectedFieldAsset
val mergeSource = selectedCapture?.takeIf {
it.capture.captureRequired &&
it.asset.type?.let { type -> f3TypeCode(type) in setOf("instalacion", "subinstalacion") } == true
}
val mergeCandidates = remember(model.inventory, mergeSource) {
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
candidate.id != mergeSource.asset.id &&
candidate.informationStatus != "INACTIVE" &&
candidate.type?.id == mergeSource.asset.type?.id &&
candidate.parent?.id == mergeSource.asset.parent?.id &&
!candidate.captureRequired
}
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Volver") }
Column(horizontalAlignment = Alignment.End) {
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación → Subinstalación", style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
if (selectedCapture != null) {
F3CaptureCard(
detailName = selectedCapture.asset.name,
captureRequired = selectedCapture.capture.captureRequired,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = {
val permissions = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (f3HasPermission(context, Manifest.permission.CAMERA) && f3HasLocation(context)) beginPhoto()
else photoPermissionLauncher.launch(permissions)
},
onClose = { model.clearSelectedFieldAsset() },
)
}
if (mergeSource != null) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Lo acabás de crear y ya existía?", fontWeight = FontWeight.Bold)
Text(
"Podés fusionar esta alta de campo con el registro existente. La ficha nueva no se borra: queda como alias histórico con fecha, inspector e inspección.",
style = MaterialTheme.typography.bodySmall,
)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = mergeSearch,
onValueChange = { mergeSearch = it },
label = { Text("Buscar posible duplicado") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = {
mergeCandidateId = null
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
}) { Text("Buscar") }
}
if (mergeCandidates.isEmpty()) {
Text("No hay coincidencias compatibles en esta búsqueda.", style = MaterialTheme.typography.bodySmall)
} else {
mergeCandidates.take(8).forEach { candidate ->
AssistChip(
onClick = { mergeCandidateId = candidate.id },
label = {
Text(
if (mergeCandidateId == candidate.id) "${candidate.code} · ${candidate.name}"
else "${candidate.code} · ${candidate.name}",
)
},
)
}
}
OutlinedTextField(
value = mergeReason,
onValueChange = { mergeReason = it },
label = { Text("Motivo de la fusión") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
val target = mergeCandidateId ?: return@Button
model.mergeCreatedFieldAsset(target, mergeReason)
},
enabled = mergeCandidateId != null && mergeReason.trim().length >= 8 && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Fusionar y conservar el registro existente") }
}
}
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre o código") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Alta en campo", fontWeight = FontWeight.Bold)
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
}
if (showCreate) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = "Área de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Área") }
}
if (model.fieldTypes.isEmpty()) {
Text("Este nivel no admite más hijos estructurales.")
} else {
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
}
if (selectedType?.familyRequired == true) {
Text("Familia técnica", fontWeight = FontWeight.Bold)
Text(
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
}
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(model.inventory, key = { it.id }) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
onUseParent = {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
showCreate = true
selectedTypeId = null
resetCreateForm()
model.loadFieldTypes(item.id)
},
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
}
@Composable
private fun F3CaptureCard(
detailName: String,
captureRequired: Boolean,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(detailName, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else {
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
}
}
}
}
@Composable
private fun F3InventoryCard(
item: FieldInventoryItem,
onInspect: () -> Unit,
onUseParent: () -> Unit,
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(item.name, fontWeight = FontWeight.SemiBold)
Text(structureLabel(typeCode), style = MaterialTheme.typography.bodySmall)
}
Text(item.code, style = MaterialTheme.typography.bodySmall)
item.commonName?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodySmall)
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
}
}
if (canHaveChild) {
OutlinedButton(onClick = onUseParent, modifier = Modifier.fillMaxWidth()) { Text(childLabel) }
}
}
}
}
@Composable
private fun F3MessageStrip(model: MainViewModel) {
val error = model.error
val notice = model.notice
if (error != null || notice != null) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
colors = CardDefaults.cardColors(
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
}
}
}
private fun f3TypeCode(summary: com.korexlabs.dhinspeccion.data.AssetSummary): String =
(summary.typeName ?: summary.name).trim().lowercase()
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
private fun structureLabel(typeCode: String): String = when (typeCode) {
"area" -> "Área"
"yacimiento" -> "Yacimiento"
"instalacion" -> "Instalación"
"subinstalacion" -> "Subinstalación"
else -> typeCode.ifBlank { "Inventario" }
}
private fun statusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceF3Attribute(definition, raw)
}.toMap()
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun f3HasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun f3HasLocation(context: Context): Boolean =
f3HasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!f3HasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
private fun newF3Photo(context: Context): Pair<File, Uri> {
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
directory.mkdirs()
val file = File.createTempFile("DH_F3_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeF3Exif(file: File, geo: F3GeoSnapshot) {
val now = Instant.now()
val exif = ExifInterface(file)
exif.setLatLong(geo.latitude, geo.longitude)
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
exif.saveAttributes()
}
private fun f3ShortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -0,0 +1,30 @@
package com.korexlabs.dhinspeccion.ui
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.FieldType
/**
* Compatibilidad transitoria con la pantalla F2.x que queda compilada pero ya no
* será la ruta activa en F3.1. El flujo nuevo siempre envía familyId cuando el
* nivel estructural lo requiere.
*/
fun MainViewModel.createFieldAsset(
type: FieldType,
parentId: String?,
name: String,
commonName: String?,
attributes: Map<String, Any?>,
latitude: Double,
longitude: Double,
accuracyM: Double?,
) = createFieldAsset(
type = type,
parentId = parentId,
familyId = null,
name = name,
commonName = commonName,
attributes = attributes,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
@@ -91,6 +91,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> DhApp(model)
}
}
@@ -192,7 +193,11 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.23.0-1",
"version": "0.24.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -12,6 +12,9 @@ import {
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
@@ -23,13 +26,38 @@ export class CreateUserDto {
username!: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
)
@IsOptional()
@Matches(/^\d{7,11}$/)
dni?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(80)
employeeNumber?: string | null;
@IsString()
@MinLength(12)
@MaxLength(128)
@@ -8,6 +8,9 @@ import {
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateUserDto {
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
@@ -20,13 +23,38 @@ export class UpdateUserDto {
username?: string;
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email?: string | null;
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
)
@IsOptional()
@Matches(/^\d{7,11}$/)
dni?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(80)
employeeNumber?: string | null;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsOptional()
@IsString()
@@ -41,6 +41,10 @@ export interface AdministrativeUserView {
id: string;
username: string;
email: string | null;
dni: string | null;
phone: string | null;
jobTitle: string | null;
employeeNumber: string | null;
firstName: string;
lastName: string;
status: UserStatus;
@@ -72,6 +76,13 @@ function roleSelectionInvalid(): BadRequestException {
});
}
function inspectorEmailRequired(): BadRequestException {
return new BadRequestException({
code: 'INSPECTOR_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
});
}
@Injectable()
export class UsersService {
constructor(
@@ -96,6 +107,10 @@ export class UsersService {
OR user_account.email ILIKE $${parameters.length}
OR user_account.first_name ILIKE $${parameters.length}
OR user_account.last_name ILIKE $${parameters.length}
OR user_account.dni ILIKE $${parameters.length}
OR user_account.phone ILIKE $${parameters.length}
OR user_account.job_title ILIKE $${parameters.length}
OR user_account.employee_number ILIKE $${parameters.length}
)
`);
}
@@ -115,6 +130,10 @@ export class UsersService {
user_account.id,
user_account.username,
user_account.email,
user_account.dni,
user_account.phone,
user_account.job_title AS "jobTitle",
user_account.employee_number AS "employeeNumber",
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
@@ -176,9 +195,14 @@ export class UsersService {
try {
return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds);
this.assertInspectorHasEmail(roles, dto.email ?? null);
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
dni: dto.dni ?? null,
phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null,
employeeNumber: dto.employeeNumber ?? null,
passwordHash,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
@@ -227,6 +251,10 @@ export class UsersService {
if (
dto.username === undefined &&
dto.email === undefined &&
dto.dni === undefined &&
dto.phone === undefined &&
dto.jobTitle === undefined &&
dto.employeeNumber === undefined &&
dto.firstName === undefined &&
dto.lastName === undefined
) {
@@ -240,6 +268,9 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
throw inspectorEmailRequired();
}
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
@@ -247,6 +278,10 @@ export class UsersService {
if (dto.email !== undefined) {
user.email = dto.email?.trim().toLowerCase() || null;
}
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
if (dto.jobTitle !== undefined) user.jobTitle = dto.jobTitle ?? null;
if (dto.employeeNumber !== undefined) user.employeeNumber = dto.employeeNumber ?? null;
if (dto.firstName !== undefined) user.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
@@ -379,6 +414,7 @@ export class UsersService {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -417,6 +453,12 @@ export class UsersService {
return roles;
}
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
private async insertUserRoles(
manager: EntityManager,
userId: string,
@@ -455,6 +497,10 @@ export class UsersService {
user_account.id,
user_account.username,
user_account.email,
user_account.dni,
user_account.phone,
user_account.job_title AS "jobTitle",
user_account.employee_number AS "employeeNumber",
user_account.first_name AS "firstName",
user_account.last_name AS "lastName",
user_account.status,
@@ -491,7 +537,7 @@ export class UsersService {
private userConflict(): ConflictException {
return new ConflictException({
code: 'USER_ALREADY_EXISTS',
message: 'El usuario o email ya está registrado',
message: 'El usuario, email o DNI ya está registrado',
});
}
}
@@ -22,12 +22,23 @@ import { AssetOperationalRelationsService } from './asset-operational-relations.
import { AssetRegistryController } from './asset-registry.controller';
import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
@Module({
imports: [AuditModule],
controllers: [
AssetTypesController,
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryMergeController,
FieldInventoryMergeController,
AssetGeometriesController,
MapAssetsController,
AssetHistoryController,
@@ -40,6 +51,10 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
providers: [
AssetTypesService,
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
AssetMediaService,
@@ -52,6 +67,8 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
exports: [
AssetHistoryService,
AssetsService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetMediaService,
FieldDiscoveryInspectionLinkService,
+7 -3
View File
@@ -16,6 +16,7 @@ import type {
RequestWithContext,
} from '../common/http/request-context';
import { AssetsService } from './assets.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
@@ -31,7 +32,10 @@ import { ChangeAssetContextDto } from './dto/change-asset-context.dto';
@Controller('assets')
export class AssetsController {
constructor(private readonly assets: AssetsService) {}
constructor(
private readonly assets: AssetsService,
private readonly mergedDossier: MergedInventoryDossierService,
) {}
@Get()
@RequirePermissions('assets.read')
@@ -131,8 +135,8 @@ export class AssetsController {
'inspection_evidence.read',
'inspection_communications.read',
)
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.assets.dossier(id);
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise<unknown> {
return this.mergedDossier.dossier(id);
}
@Get(':id/context-history')
@@ -42,6 +42,10 @@ export class CreateAssetDto {
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: string | null;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string | null;
@@ -0,0 +1,56 @@
import { Transform } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export const INVENTORY_STRUCTURE_KINDS = [
'AREA',
'YACIMIENTO',
'INSTALACION',
'SUBINSTALACION',
] as const;
export type InventoryStructureKind = typeof INVENTORY_STRUCTURE_KINDS[number];
export class CreateInventoryStructureDto {
@IsIn(INVENTORY_STRUCTURE_KINDS)
kind!: InventoryStructureKind;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : null)
@IsString()
@MaxLength(120)
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
code?: string | null;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(200)
commonName?: string | null;
@IsOptional()
@IsUUID('4')
parentId?: string | null;
@IsOptional()
@IsUUID('4')
familyId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class MergeInventoryAssetDto {
@IsUUID('4')
canonicalAssetId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(8)
@MaxLength(2000)
reason!: string;
}
@@ -0,0 +1,14 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families')
export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {}
@Get(':familyId/findings')
@RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
return this.families.findings(familyId);
}
}
@@ -0,0 +1,39 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {}
async findings(familyId: string) {
const [family] = await this.dataSource.query(`
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(`
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 };
}
}
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
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 { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
import { InventoryMergeService } from './inventory-merge.service';
@Controller('assets')
export class InventoryMergeController {
constructor(private readonly merges: InventoryMergeService) {}
@Get(':id/merge-status')
@RequirePermissions('assets.read')
status(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.merges.status(id);
}
@Post(':id/merge')
@RequirePermissions('assets.update')
merge(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: MergeInventoryAssetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.merges.merge(id, dto, principal, request);
}
}
@Controller('inspection-visits/:visitId/field-inventory')
export class FieldInventoryMergeController {
constructor(private readonly merges: InventoryMergeService) {}
@Post(':assetId/merge')
@RequirePermissions('assets.create', 'inspections.execute')
mergeFieldDiscovery(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Body() dto: MergeInventoryAssetDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.merges.mergeFromVisit(visitId, assetId, dto, principal, request);
}
}
@@ -0,0 +1,432 @@
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 {
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
import { AssetHistoryService } from './asset-history.service';
type MergeableAssetRow = {
id: string;
code: string;
name: string;
typeCode: string;
parentId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null;
informationStatus: string;
};
type MergeRow = {
id: string;
sourceAssetId: string;
canonicalAssetId: string;
reason: string;
mergedAt: Date;
mergedBy: string | null;
source: string;
requestId: string | null;
};
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable()
export class InventoryMergeService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async status(assetId: string) {
const requested = await this.loadAsset(this.dataSource.manager, assetId, false);
const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, assetId);
const canonical = canonicalId === assetId
? requested
: await this.loadAsset(this.dataSource.manager, canonicalId, false);
const chain = await this.mergeChain(assetId);
const aliases = await this.aliasesFor(canonicalId);
return {
requested,
isMerged: canonicalId !== assetId,
canonical,
chain,
aliases,
};
}
async merge(
sourceAssetId: string,
dto: MergeInventoryAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction((manager) =>
this.mergeInTransaction(manager, sourceAssetId, dto, principal, request),
);
}
async mergeFromVisit(
visitId: string,
sourceAssetId: string,
dto: MergeInventoryAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
const [gate] = (await this.dataSource.query(`
SELECT
visit.status,
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.visit_id=visit.id
AND member.user_id=$3::uuid
AND member.included=true
) OR visit.lead_inspector_user_id=$3::uuid AS assigned,
EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id=visit.id
AND discovery.asset_id=$2::uuid
) AS "createdInVisit"
FROM inspection_visits visit
WHERE visit.id=$1::uuid
`, [visitId, sourceAssetId, principal.userId])) as Array<{
status: string;
assigned: boolean;
createdInVisit: boolean;
}>;
if (!gate) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
if (gate.status !== 'IN_PROGRESS') throw new ConflictException({
code: 'FIELD_MERGE_VISIT_NOT_IN_PROGRESS',
message: 'Sólo se puede conciliar Inventario mientras la inspección está en curso',
});
if (!gate.assigned) throw new ConflictException({
code: 'FIELD_MERGE_INSPECTOR_NOT_ASSIGNED',
message: 'El inspector no está asignado a esta inspección',
});
if (!gate.createdInVisit) throw new ConflictException({
code: 'FIELD_MERGE_SOURCE_MUST_BE_DISCOVERY',
message: 'Desde la APK sólo se puede fusionar un alta nacida en esta inspección',
});
return this.merge(sourceAssetId, dto, principal, request);
}
private async mergeInTransaction(
manager: EntityManager,
sourceAssetId: string,
dto: MergeInventoryAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const existing = await this.currentMerge(manager, sourceAssetId);
if (existing) throw new ConflictException({
code: 'INVENTORY_ALREADY_MERGED',
message: 'Este registro ya fue fusionado',
canonicalAssetId: existing.canonicalAssetId,
});
const canonicalAssetId = await this.resolveCanonicalId(manager, dto.canonicalAssetId);
if (canonicalAssetId === sourceAssetId) throw new BadRequestException({
code: 'INVENTORY_MERGE_CYCLE',
message: 'La fusión generaría un ciclo entre registros',
});
const ids = [sourceAssetId, canonicalAssetId].sort();
const locked = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.parent_id AS "parentId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId",
asset.information_status AS "informationStatus"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=ANY($1::uuid[])
ORDER BY asset.id
FOR UPDATE OF asset
`, [ids])) as MergeableAssetRow[];
const source = locked.find((row) => row.id === sourceAssetId);
const canonical = locked.find((row) => row.id === canonicalAssetId);
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical);
const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId)
: null;
const targetParentCanonical = canonical.parentId
? await this.resolveCanonicalId(manager, canonical.parentId)
: null;
if (sourceParentCanonical !== targetParentCanonical) throw new BadRequestException({
code: 'INVENTORY_MERGE_PARENT_MISMATCH',
message: 'Los duplicados deben pertenecer al mismo padre estructural (considerando fusiones previas)',
});
if (source.typeCode.toLowerCase() === 'instalacion') {
const invalidChildren = await manager.query(`
SELECT child.id,child.code,child.name
FROM assets child
WHERE child.parent_id=$1::uuid
AND child.information_status<>'INACTIVE'
AND (
child.inventory_family_id IS NULL
OR $2::uuid IS NULL
OR NOT EXISTS (
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=child.inventory_family_id
AND rule.parent_family_id=$2::uuid
)
)
ORDER BY child.name,child.code
LIMIT 20
`, [source.id, canonical.inventoryFamilyId]) as Array<{ id: string; code: string; name: string }>;
if (invalidChildren.length > 0) throw new ConflictException({
code: 'INVENTORY_MERGE_CHILD_FAMILY_CONFLICT',
message: 'Hay Subinstalaciones que no son compatibles con la familia de la Instalación canónica',
data: invalidChildren,
});
}
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id),
]);
const sourceLabel = principal.transport === 'bearer' ? 'ANDROID' : 'WEB';
const [mergeRecord] = (await manager.query(`
INSERT INTO asset_merges (
source_asset_id,canonical_asset_id,reason,merged_by,source,request_id,
source_snapshot,canonical_snapshot
) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::varchar,$7::jsonb,$8::jsonb)
RETURNING id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
`, [
source.id,
canonical.id,
dto.reason,
principal.userId,
sourceLabel,
request.requestId,
sourceSnapshot,
canonicalSnapshot,
])) as MergeRow[];
const children = (await manager.query(`
SELECT id FROM assets
WHERE parent_id=$1::uuid AND information_status<>'INACTIVE'
ORDER BY id FOR UPDATE
`, [source.id])) as Array<{ id: string }>;
const reparentedChildIds: string[] = [];
for (const child of children) {
await manager.query(`
UPDATE asset_context_history
SET valid_until=CURRENT_TIMESTAMP,
end_reason=$2::text,
ended_by=$3::uuid,
ended_at=CURRENT_TIMESTAMP
WHERE asset_id=$1::uuid AND valid_until IS NULL
`, [child.id, `Reparentado por fusión cronológica ${source.code}${canonical.code}`, principal.userId]);
await manager.query(`
UPDATE assets
SET parent_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [child.id, canonical.id, principal.userId]);
const versionNumber = await this.history.capture(
manager,
child.id,
AssetVersionChangeType.UPDATED,
principal,
request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
)
SELECT id,parent_id,operational_area_id,operator_company_id,CURRENT_TIMESTAMP,
$2::text,$3::integer,$4::varchar,$5::varchar,$6::uuid
FROM assets WHERE id=$1::uuid
`, [
child.id,
`Reparentado por fusión cronológica ${source.code}${canonical.code}`,
versionNumber,
sourceLabel,
request.requestId,
principal.userId,
]);
reparentedChildIds.push(child.id);
}
await manager.query(`
UPDATE assets
SET information_status='INACTIVE',updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [source.id, principal.userId]);
const sourceVersionNumber = await this.history.capture(
manager,
source.id,
AssetVersionChangeType.STATUS_CHANGED,
principal,
request,
);
const result = {
merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name },
canonical: { id: canonical.id, code: canonical.code, name: canonical.name },
sourceVersionNumber,
reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_UPDATED,
entityType: 'asset_merge',
entityId: mergeRecord.id,
beforeData: sourceSnapshot,
afterData: canonicalSnapshot,
metadata: {
operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id,
canonicalAssetId: canonical.id,
reason: dto.reason,
sourceVersionNumber,
reparentedChildIds,
historicalReferencesRewritten: false,
},
}, manager);
return result;
}
private validatePair(source: MergeableAssetRow, canonical: MergeableAssetRow): void {
const sourceType = source.typeCode.toLowerCase();
const targetType = canonical.typeCode.toLowerCase();
if (!MERGEABLE_TYPES.has(sourceType) || sourceType !== targetType) throw new BadRequestException({
code: 'INVENTORY_MERGE_TYPE_INVALID',
message: 'Sólo se pueden fusionar Instalaciones entre sí o Subinstalaciones entre sí',
});
if (source.informationStatus === 'INACTIVE') throw new ConflictException({
code: 'INVENTORY_MERGE_SOURCE_INACTIVE',
message: 'El registro de origen ya está inactivo',
});
if (canonical.informationStatus === 'INACTIVE') throw new ConflictException({
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo',
});
if (!source.operationalAreaId || !source.operatorCompanyId
|| source.operationalAreaId !== canonical.operationalAreaId
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
message: 'Los registros deben pertenecer a la misma Área y Operadora',
});
}
}
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
const lockSql = lock ? 'FOR UPDATE OF asset' : '';
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.parent_id AS "parentId",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId",
asset.information_status AS "informationStatus"
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid ${lockSql}
`, [id])) as MergeableAssetRow[];
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
return rows[0];
}
private async currentMerge(manager: EntityManager, sourceAssetId: string): Promise<MergeRow | null> {
const rows = (await manager.query(`
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
FROM asset_merges WHERE source_asset_id=$1::uuid
`, [sourceAssetId])) as MergeRow[];
return rows[0] ?? null;
}
private async resolveCanonicalId(manager: EntityManager, assetId: string): Promise<string> {
const rows = (await manager.query(`
WITH RECURSIVE chain AS (
SELECT source_asset_id,canonical_asset_id,1 AS depth
FROM asset_merges WHERE source_asset_id=$1::uuid
UNION ALL
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,chain.depth+1
FROM asset_merges merge_record
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
WHERE chain.depth<32
)
SELECT canonical_asset_id AS id FROM chain ORDER BY depth DESC LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? assetId;
}
private async mergeChain(assetId: string): Promise<MergeRow[]> {
return (await this.dataSource.query(`
WITH RECURSIVE chain AS (
SELECT id,source_asset_id,canonical_asset_id,reason,merged_at,merged_by,source,request_id,1 AS depth
FROM asset_merges WHERE source_asset_id=$1::uuid
UNION ALL
SELECT merge_record.id,merge_record.source_asset_id,merge_record.canonical_asset_id,
merge_record.reason,merge_record.merged_at,merge_record.merged_by,merge_record.source,merge_record.request_id,
chain.depth+1
FROM asset_merges merge_record
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
WHERE chain.depth<32
)
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
FROM chain ORDER BY depth
`, [assetId])) as MergeRow[];
}
private async aliasesFor(canonicalAssetId: string) {
return this.dataSource.query(`
WITH RECURSIVE aliases AS (
SELECT source_asset_id,canonical_asset_id,reason,merged_at,1 AS depth
FROM asset_merges WHERE canonical_asset_id=$1::uuid
UNION ALL
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,
merge_record.reason,merge_record.merged_at,aliases.depth+1
FROM asset_merges merge_record
JOIN aliases ON merge_record.canonical_asset_id=aliases.source_asset_id
WHERE aliases.depth<32
)
SELECT alias.source_asset_id AS id,asset.code,asset.name,alias.reason,
alias.merged_at AS "mergedAt",alias.depth
FROM aliases alias
JOIN assets asset ON asset.id=alias.source_asset_id
ORDER BY alias.merged_at,alias.depth
`, [canonicalAssetId]);
}
private async snapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
const rows = (await manager.query(`
SELECT TO_JSONB(asset) || JSONB_BUILD_OBJECT(
'typeCode',type.code,
'typeName',type.name,
'inventoryFamily',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 snapshot
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
WHERE asset.id=$1::uuid
`, [assetId])) as Array<{ snapshot: Record<string, unknown> }>;
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
return rows[0].snapshot;
}
}
@@ -0,0 +1,36 @@
import { Body, Controller, Get, Param, Post, Query, 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 { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service';
@Controller('inventory-structure')
export class InventoryStructureController {
constructor(private readonly inventoryStructure: InventoryStructureService) {}
@Get()
@RequirePermissions('assets.read')
options() {
return this.inventoryStructure.options();
}
@Get('parents/:kind')
@RequirePermissions('assets.read')
parents(
@Param('kind') kind: string,
@Query('search') search?: string,
) {
return this.inventoryStructure.parents(kind, search);
}
@Post()
@RequirePermissions('assets.create')
create(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.inventoryStructure.create(dto, principal, request);
}
}
@@ -0,0 +1,372 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
};
type ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null;
};
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
AREA: null,
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
};
const FAMILY_LEVEL_BY_KIND: Partial<Record<InventoryStructureKind, FamilyRow['level']>> = {
INSTALACION: 'INSTALLATION',
SUBINSTALACION: 'SUBINSTALLATION',
};
@Injectable()
export class InventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async options() {
const types = (await this.dataSource.query(`
SELECT id,code,name
FROM asset_types
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
AND is_active=true
ORDER BY CASE lower(code)
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
`)) as StructureTypeRow[];
if (types.length !== 4) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La estructura del Inventario todavía no está completamente configurada',
});
}
const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
return {
levels: [
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
],
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
};
}
async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre',
});
}
const expectedType = PARENT_TYPE_BY_KIND[kind];
const parameters: unknown[] = [expectedType];
let searchSql = '';
if (search?.trim()) {
parameters.push(`%${search.trim()}%`);
searchSql = `AND (asset.code ILIKE $2 OR asset.name ILIKE $2 OR COALESCE(asset.common_name,'') ILIKE $2)`;
}
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 family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
) END AS "inventoryFamily",
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent.id,'code',parent.code,'name',parent.name
) END AS parent
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 assets parent ON parent.id=asset.parent_id
WHERE lower(type.code)=lower($1::text)
AND asset.information_status<>'INACTIVE'
${searchSql}
ORDER BY asset.name,asset.code
LIMIT 80
`, parameters);
return { data: rows };
}
async create(
dto: CreateInventoryStructureDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
try {
return await this.dataSource.transaction(async (manager) => {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent?.operationalAreaId ?? null;
const operatorCompanyId = parent?.operatorCompanyId ?? null;
const inserted = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
code,
dto.name,
dto.commonName ?? null,
dto.description ?? null,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
'Inventario estructural F3.1',
`inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro estructural');
const versionNumber = await this.history.capture(
manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [
id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
'Alta guiada de Inventario estructural F3.1',
versionNumber,
request.requestId,
principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',
entityId: id,
afterData: created as unknown as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro de Inventario con ese código',
});
}
throw error;
}
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const rows = (await manager.query(`
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[];
if (!rows[0]) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
message: `El nivel ${kind} no está configurado`,
});
}
return rows[0];
}
private async requireParent(
manager: EntityManager,
kind: InventoryStructureKind,
parentId: string | null,
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) {
throw new BadRequestException({
code: 'INVENTORY_AREA_MUST_BE_ROOT',
message: 'Un Área se crea como registro raíz y no puede tener padre',
});
}
return null;
}
if (!parentId) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
});
}
const rows = (await manager.query(`
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"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
FOR KEY SHARE
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
if (parent.typeCode.toLowerCase() !== expectedType) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
const expectedLevel = FAMILY_LEVEL_BY_KIND[kind];
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Área y Yacimiento no llevan familia técnica',
});
return null;
}
if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`,
});
const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'La familia técnica no corresponde al nivel seleccionado',
});
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada',
});
}
return family;
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const readable = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
private async loadView(manager: EntityManager, id: string) {
const rows = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
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 family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
) END AS "inventoryFamily",
asset.created_at AS "createdAt",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 inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
@@ -0,0 +1,164 @@
import { Injectable } from '@nestjs/common';
import { AssetsService } from './assets.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
for (const item of items) {
const id = String(item.id ?? '');
if (!id || seen.has(id)) continue;
seen.add(id);
result.push(item);
}
return result;
}
function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[] {
return [...items].sort((a, b) => {
const first = fieldCandidates.map((field) => b[field]).find(Boolean) ?? 0;
const second = fieldCandidates.map((field) => a[field]).find(Boolean) ?? 0;
return new Date(String(first)).getTime() - new Date(String(second)).getTime();
});
}
@Injectable()
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly merges: InventoryMergeService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
const aliases = (mergeStatus.aliases ?? []) as LooseRecord[];
const inventoryIds = [canonical.id, ...aliases.map((alias) => alias.id)]
.filter(Boolean)
.map(String)
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
return { assetId, identity, dossier };
}));
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
...entry,
historicalInventory: {
id: identity.id,
code: identity.code,
name: identity.name,
commonName: identity.commonName ?? null,
isCanonical: identity.id === canonical.id,
},
});
const collect = (key: string): LooseRecord[] => dedupeById(
dossiers.flatMap(({ identity, dossier }) =>
((dossier[key] ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity)),
),
);
const visits = sortDesc(collect('visits'), ['actualStartedAt', 'plannedStartAt', 'createdAt']);
const acts = sortDesc(collect('acts'), ['occurredAt', 'createdAt']);
const findings = sortDesc(collect('findings'), ['createdAt', 'updatedAt']);
const evidence = sortDesc(collect('evidence'), ['capturedAt', 'createdAt']);
const communications = sortDesc(collect('communications'), ['occurredAt', 'createdAt']);
const verificationResults = sortDesc(collect('verificationResults'), ['verifiedAt', 'resultRecordedAt']);
const documents = sortDesc(collect('documents'), ['documentDate', 'linkedAt']);
const inspectionReports = sortDesc(collect('inspectionReports'), ['generatedAt']);
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
const versions = sortDesc(collect('versions'), ['occurredAt']);
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
...event,
meta: {
...(event.meta ?? {}),
historicalInventory: {
id: identity.id,
code: identity.code,
name: identity.name,
isCanonical: identity.id === canonical.id,
},
},
})),
);
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
for (const alias of aliases) {
timeline.push({
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
kind: 'MERGE',
occurredAt: alias.mergedAt,
title: `Inventario fusionado · ${String(alias.code)}`,
description: alias.reason,
meta: {
sourceAssetId: alias.id,
sourceCode: alias.code,
sourceName: alias.name,
canonicalAssetId: canonical.id,
canonicalCode: canonical.code,
canonicalName: canonical.name,
historyPreserved: true,
},
});
}
timeline.sort((a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime());
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
return {
asset: {
id: canonical.id,
code: canonical.code,
name: canonical.name,
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
},
requestedAsset: {
id: requested.id,
code: requested.code,
name: requested.name,
},
merge: {
requestedWasMerged: requested.id !== canonical.id,
canonicalAssetId: canonical.id,
aliases,
chain: mergeStatus.chain ?? [],
aggregatedInventoryIds: inventoryIds,
historyPolicy: 'CHRONOLOGICAL_AGGREGATION_WITH_ORIGINAL_IDENTITY',
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 1000),
};
}
}
+5 -1
View File
@@ -33,6 +33,7 @@ export enum AssetDataOrigin {
@Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus'])
@Index('idx_assets_data_origin', ['dataOrigin'])
@@ -53,6 +54,9 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@Column({ type: 'varchar', length: 120 })
code!: string;
@@ -112,4 +116,4 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'provenance_updated_by', type: 'uuid', nullable: true })
provenanceUpdatedBy!: string | null;
}
}
@@ -19,6 +19,18 @@ export class User extends TimestampedEntity {
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
dni!: string | null;
@Column({ type: 'varchar', length: 40, nullable: true })
phone!: string | null;
@Column({ name: 'job_title', type: 'varchar', length: 160, nullable: true })
jobTitle!: string | null;
@Column({ name: 'employee_number', type: 'varchar', length: 80, nullable: true })
employeeNumber!: string | null;
@Column({ name: 'password_hash', type: 'text', select: false })
passwordHash!: string;
@@ -0,0 +1,359 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
F31_FINDING_GROUPS,
F31_INSTALLATION_FAMILIES,
F31_SUBINSTALLATION_FAMILIES,
F31_UNIVERSAL_FINDINGS,
} from '../../reference-data/f3-1-inventory-excel';
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
function findingKey(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function findingCode(title: string): string {
return `APP26R2-${createHash('sha1').update(findingKey(title)).digest('hex').slice(0, 12).toUpperCase()}`;
}
type IdRow = { id: string };
type CountRow = { total: string };
export class PhaseF31InventoryStructureCatalog1789844400000 implements MigrationInterface {
name = 'PhaseF31InventoryStructureCatalog1789844400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO source_documents (document_type,document_number,title,issuer,notes)
SELECT 'SPREADSHEET','DH-F31-APP','APLICACION APP(2).xlsx','Dirección de Hidrocarburos',
'F3.1: revisión estructural. Hoja1 define Instalaciones/Subinstalaciones; Hoja2 define catálogo contextual.'
WHERE NOT EXISTS (
SELECT 1 FROM source_documents
WHERE document_number='DH-F31-APP' AND issuer='Dirección de Hidrocarburos'
)
`);
await queryRunner.query(`
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
SELECT 'subinstalacion','Subinstalación',
'Unidad física subordinada a una Instalación. Su familia técnica determina el catálogo contextual de Hallazgos.',
false,true,'GENERIC'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='subinstalacion')
`);
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)='subinstalacion' AND lower(parent.code)='instalacion'
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
CREATE TABLE inventory_families (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(120) NOT NULL UNIQUE,
name varchar(240) NOT NULL,
level varchar(24) NOT NULL,
legacy_type_code varchar(120),
information_labels jsonb NOT NULL DEFAULT '[]'::jsonb,
source_reference varchar(240),
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_inventory_families_level CHECK (level IN ('INSTALLATION','SUBINSTALLATION')),
CONSTRAINT chk_inventory_families_information_labels CHECK (jsonb_typeof(information_labels)='array')
)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_families_level_active
ON inventory_families(level,is_active,name)
`);
await queryRunner.query(`
CREATE TABLE inventory_family_parent_rules (
child_family_id uuid PRIMARY KEY,
parent_family_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_family_parent_child FOREIGN KEY (child_family_id)
REFERENCES inventory_families(id) ON DELETE CASCADE,
CONSTRAINT fk_inventory_family_parent_parent FOREIGN KEY (parent_family_id)
REFERENCES inventory_families(id) ON DELETE CASCADE,
CONSTRAINT chk_inventory_family_parent_distinct CHECK (child_family_id <> parent_family_id)
)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_family_parent_parent
ON inventory_family_parent_rules(parent_family_id,child_family_id)
`);
await queryRunner.query(`
ALTER TABLE assets ADD COLUMN inventory_family_id uuid
`);
await queryRunner.query(`
ALTER TABLE assets ADD CONSTRAINT fk_assets_inventory_family
FOREIGN KEY (inventory_family_id) REFERENCES inventory_families(id) ON DELETE RESTRICT
`);
await queryRunner.query(`
CREATE INDEX idx_assets_inventory_family ON assets(inventory_family_id)
`);
await queryRunner.query(`
CREATE TABLE finding_catalog_item_inventory_families (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
catalog_item_id uuid NOT NULL,
inventory_family_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_finding_catalog_item_inventory_family UNIQUE (catalog_item_id,inventory_family_id),
CONSTRAINT fk_finding_catalog_family_item FOREIGN KEY (catalog_item_id)
REFERENCES finding_catalog_items(id) ON DELETE CASCADE,
CONSTRAINT fk_finding_catalog_family_family FOREIGN KEY (inventory_family_id)
REFERENCES inventory_families(id) ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX idx_finding_catalog_family_family
ON finding_catalog_item_inventory_families(inventory_family_id,catalog_item_id)
`);
for (const family of F31_INSTALLATION_FAMILIES) {
await queryRunner.query(`
INSERT INTO inventory_families
(code,name,level,legacy_type_code,information_labels,source_reference,is_active)
VALUES ($1,$2,'INSTALLATION',$3,'[]'::jsonb,$4,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,
legacy_type_code=EXCLUDED.legacy_type_code,
source_reference=EXCLUDED.source_reference,
is_active=true,
updated_at=CURRENT_TIMESTAMP
`, [family.code, family.name, family.legacyTypeCode, `APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`]);
}
for (const family of F31_SUBINSTALLATION_FAMILIES) {
await queryRunner.query(`
INSERT INTO inventory_families
(code,name,level,legacy_type_code,information_labels,source_reference,is_active)
VALUES ($1,$2,'SUBINSTALLATION',$3,$4::jsonb,$5,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,
legacy_type_code=EXCLUDED.legacy_type_code,
information_labels=EXCLUDED.information_labels,
source_reference=EXCLUDED.source_reference,
is_active=true,
updated_at=CURRENT_TIMESTAMP
`, [
family.code,
family.name,
family.legacyTypeCode,
JSON.stringify(family.informationLabels),
`APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`,
]);
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT child.id,parent.id
FROM inventory_families child CROSS JOIN inventory_families parent
WHERE child.code=$1 AND parent.code=$2
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
`, [family.code, family.parentCode]);
}
await queryRunner.query(`
UPDATE finding_categories SET is_active=false, updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26'
`);
await queryRunner.query(`
INSERT INTO finding_categories(code,name,sort_order,is_active)
SELECT 'APP26R2','Aplicación APP 2026 · revisión estructural',261,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26r2')
`);
await queryRunner.query(`
UPDATE finding_categories
SET name='Aplicación APP 2026 · revisión estructural', sort_order=261,
is_active=true, updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2'
`);
const categoryRows = (await queryRunner.query(
`SELECT id FROM finding_categories WHERE lower(code)='app26r2' LIMIT 1`,
)) as IdRow[];
const categoryId = categoryRows[0]?.id;
if (!categoryId) throw new Error('F3.1 could not resolve APP26R2 category');
const groupByKey = new Map(F31_FINDING_GROUPS.map((group) => [group.key, group]));
const titlesByFamily = new Map<string, Map<string,string>>();
const addFamilyTitle = (familyCode: string, title: string): void => {
const clean = title.trim();
if (!clean || /^idem\b/i.test(clean) || clean.toUpperCase()==='HALLAZGOS') return;
const key = findingKey(clean);
const map = titlesByFamily.get(familyCode) ?? new Map<string,string>();
if (!map.has(key)) map.set(key, clean);
titlesByFamily.set(familyCode, map);
};
const seedFamily = (family: { code: string; groupKeys: string[]; directFindings: string[] }): void => {
for (const title of F31_UNIVERSAL_FINDINGS) addFamilyTitle(family.code, title);
for (const groupKey of family.groupKeys) {
const group = groupByKey.get(groupKey);
if (!group) throw new Error(`F3.1 missing finding group ${groupKey}`);
for (const title of group.items) addFamilyTitle(family.code, title);
}
for (const title of family.directFindings) addFamilyTitle(family.code, title);
};
for (const family of F31_INSTALLATION_FAMILIES) seedFamily(family);
for (const family of F31_SUBINSTALLATION_FAMILIES) seedFamily(family);
const allTitles = new Map<string,string>();
for (const titles of titlesByFamily.values()) {
for (const [key,title] of titles) if (!allTitles.has(key)) allTitles.set(key,title);
}
const orderedTitles = [...allTitles.entries()].sort((a,b) => a[1].localeCompare(b[1],'es'));
const itemIdByKey = new Map<string,string>();
let sourceNumber = 1;
for (const [key,title] of orderedTitles) {
const code = findingCode(title);
let rows = (await queryRunner.query(
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`,
[code],
)) as IdRow[];
if (!rows[0]?.id) {
rows = (await queryRunner.query(`
INSERT INTO finding_catalog_items
(category_id,code,source_number,title,import_note,revision,is_active)
VALUES ($1::uuid,$2::varchar,$3::integer,$4::varchar,$5::text,1,true)
RETURNING id
`, [
categoryId,
code,
sourceNumber,
title,
'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado',
])) as IdRow[];
} else {
await queryRunner.query(`
UPDATE finding_catalog_items SET
category_id=$2::uuid,source_number=$3::integer,title=$4::varchar,
import_note=$5::text,is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [rows[0].id, categoryId, sourceNumber, title, 'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado']);
}
const itemId = rows[0]?.id;
if (!itemId) throw new Error(`F3.1 could not create catalog item ${title}`);
itemIdByKey.set(key,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:F3.1'
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;
}
await queryRunner.query(`DELETE FROM finding_catalog_item_inventory_families`);
const familyMeta = [
...F31_INSTALLATION_FAMILIES,
...F31_SUBINSTALLATION_FAMILIES,
];
for (const family of familyMeta) {
const familyRows = (await queryRunner.query(
`SELECT id FROM inventory_families WHERE code=$1 LIMIT 1`,
[family.code],
)) as IdRow[];
const familyId = familyRows[0]?.id;
if (!familyId) throw new Error(`F3.1 missing inventory family ${family.code}`);
const titles = titlesByFamily.get(family.code) ?? new Map<string,string>();
for (const key of titles.keys()) {
const itemId = itemIdByKey.get(key);
if (!itemId) continue;
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING
`, [itemId,familyId]);
}
const legacyRows = (await queryRunner.query(
`SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`,
[family.legacyTypeCode],
)) as IdRow[];
const legacyTypeId = legacyRows[0]?.id;
if (legacyTypeId) {
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
VALUES ($1::uuid,'F3.1: compatibilidad con activos técnicos históricos usando catálogo APP26R2')
ON CONFLICT (asset_type_id) DO UPDATE SET
reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP
`, [legacyTypeId]);
for (const key of titles.keys()) {
const itemId = itemIdByKey.get(key);
if (!itemId) continue;
await queryRunner.query(`
INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id)
VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING
`, [itemId,legacyTypeId]);
}
}
}
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
SELECT id,'F3.1: las altas estructurales usan familia técnica contextual; sin familia no se sugiere catálogo.'
FROM asset_types WHERE lower(code) IN ('instalacion','subinstalacion')
ON CONFLICT (asset_type_id) DO UPDATE SET
reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1',[appRole])) as unknown[];
if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT ON TABLE inventory_families TO ${applicationRole}`);
await queryRunner.query(`GRANT SELECT ON TABLE inventory_family_parent_rules TO ${applicationRole}`);
await queryRunner.query(`GRANT SELECT ON TABLE finding_catalog_item_inventory_families TO ${applicationRole}`);
const familyCounts = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION')::text AS installations,
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')::text AS subinstallations
FROM inventory_families WHERE is_active=true
`)) as Array<{ installations: string; subinstallations: string }>;
if (Number(familyCounts[0]?.installations ?? 0) !== F31_INSTALLATION_FAMILIES.length) {
throw new Error('F3.1 verification failed: installation-family count mismatch');
}
if (Number(familyCounts[0]?.subinstallations ?? 0) !== F31_SUBINSTALLATION_FAMILIES.length) {
throw new Error('F3.1 verification failed: subinstallation-family count mismatch');
}
const catalogCounts = (await queryRunner.query(`
SELECT COUNT(*)::text AS total
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='app26r2' AND item.is_active=true
`)) as CountRow[];
if (Number(catalogCounts[0]?.total ?? 0) !== orderedTitles.length) {
throw new Error('F3.1 verification failed: APP26R2 catalog count mismatch');
}
// eslint-disable-next-line no-console
console.log(
`[F3.1] families=${F31_INSTALLATION_FAMILIES.length}+${F31_SUBINSTALLATION_FAMILIES.length}; groups=${F31_FINDING_GROUPS.length}; catalog=${orderedTitles.length}`,
);
}
public async down(): Promise<void> {
throw new Error(
'F3.1 recarga catálogo y agrega familias estructurales; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.',
);
}
}
@@ -0,0 +1,107 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF31FamilyCatalogSync1789844500000 implements MigrationInterface {
name = 'PhaseF31FamilyCatalogSync1789844500000';
public async up(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 TRIGGER trg_assets_inventory_family_catalog
AFTER INSERT OR UPDATE OF inventory_family_id ON assets
FOR EACH ROW EXECUTE FUNCTION sync_asset_inventory_family_catalog()
`);
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;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_inventory_family_mapping_assets
AFTER INSERT OR DELETE ON finding_catalog_item_inventory_families
FOR EACH ROW EXECUTE FUNCTION sync_inventory_family_mapping_assets()
`);
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
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_mapping_assets ON finding_catalog_item_inventory_families');
await queryRunner.query('DROP FUNCTION IF EXISTS sync_inventory_family_mapping_assets()');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_inventory_family_catalog ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS sync_asset_inventory_family_catalog()');
}
}
@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF31AssetChronologicalMerge1789844600000 implements MigrationInterface {
name = 'PhaseF31AssetChronologicalMerge1789844600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE asset_merges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source_asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
canonical_asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
reason text NOT NULL,
merged_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
merged_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
source varchar(32) NOT NULL,
request_id varchar(128) NULL,
source_snapshot jsonb NOT NULL,
canonical_snapshot jsonb NOT NULL,
CONSTRAINT chk_asset_merges_distinct CHECK (source_asset_id <> canonical_asset_id),
CONSTRAINT uq_asset_merges_source UNIQUE (source_asset_id)
)
`);
await queryRunner.query(`CREATE INDEX idx_asset_merges_canonical ON asset_merges(canonical_asset_id)`);
await queryRunner.query(`CREATE INDEX idx_asset_merges_merged_at ON asset_merges(merged_at)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION protect_asset_merges_append_only()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'asset_merges es append-only; una fusión no puede editarse ni borrarse';
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_asset_merges_append_only
BEFORE UPDATE OR DELETE ON asset_merges
FOR EACH ROW EXECUTE FUNCTION protect_asset_merges_append_only()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_merges_append_only ON asset_merges');
await queryRunner.query('DROP FUNCTION IF EXISTS protect_asset_merges_append_only()');
await queryRunner.query('DROP TABLE IF EXISTS asset_merges');
}
}
@@ -0,0 +1,77 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF31UserProfile1789844700000 implements MigrationInterface {
name = 'PhaseF31UserProfile1789844700000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE users ADD COLUMN dni varchar(32) NULL`);
await queryRunner.query(`ALTER TABLE users ADD COLUMN phone varchar(40) NULL`);
await queryRunner.query(`ALTER TABLE users ADD COLUMN job_title varchar(160) NULL`);
await queryRunner.query(`ALTER TABLE users ADD COLUMN employee_number varchar(80) NULL`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_users_dni_not_null
ON users(dni) WHERE dni IS NOT NULL
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_inspector_email_on_role()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE selected_role_code varchar(80);
DECLARE selected_email varchar(320);
BEGIN
SELECT code INTO selected_role_code FROM roles WHERE id=NEW.role_id;
IF selected_role_code='inspector' THEN
SELECT email INTO selected_email FROM users WHERE id=NEW.user_id;
IF selected_email IS NULL OR btrim(selected_email)='' THEN
RAISE EXCEPTION 'INSPECTOR_EMAIL_REQUIRED';
END IF;
END IF;
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_user_roles_inspector_email
BEFORE INSERT OR UPDATE ON user_roles
FOR EACH ROW EXECUTE FUNCTION enforce_inspector_email_on_role()
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION prevent_inspector_email_removal()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF (NEW.email IS NULL OR btrim(NEW.email)='') AND EXISTS (
SELECT 1
FROM user_roles user_role
JOIN roles role ON role.id=user_role.role_id
WHERE user_role.user_id=NEW.id AND role.code='inspector'
) THEN
RAISE EXCEPTION 'INSPECTOR_EMAIL_REQUIRED';
END IF;
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_users_inspector_email
BEFORE UPDATE OF email ON users
FOR EACH ROW EXECUTE FUNCTION prevent_inspector_email_removal()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_users_inspector_email ON users');
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspector_email_removal()');
await queryRunner.query('DROP TRIGGER IF EXISTS trg_user_roles_inspector_email ON user_roles');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inspector_email_on_role()');
await queryRunner.query('DROP INDEX IF EXISTS uq_users_dni_not_null');
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS employee_number');
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS job_title');
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS phone');
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS dni');
}
}
@@ -0,0 +1,102 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
F31_INSTALLATION_FAMILIES,
F31_UNIVERSAL_FINDINGS,
} from '../../reference-data/f3-1-inventory-excel';
const INSTALLATION_OTHER_CODE = 'F31-INST-OTRO';
function subOtherCode(parentCode: string): string {
return `F31-SUB-OTRO-${createHash('sha1').update(parentCode).digest('hex').slice(0, 12).toUpperCase()}`;
}
export class PhaseF31OtherInventoryFamilies1789844800000 implements MigrationInterface {
name = 'PhaseF31OtherInventoryFamilies1789844800000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1::varchar,'Otro / no catalogado','INSTALLATION',NULL,'[]'::jsonb,
'SYSTEM:F3.1:OTHER',true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
`, [INSTALLATION_OTHER_CODE]);
for (const parent of F31_INSTALLATION_FAMILIES) {
const code = subOtherCode(parent.code);
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1::varchar,'Otro / no catalogado','SUBINSTALLATION',NULL,'[]'::jsonb,
$2::varchar,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
`, [code, `SYSTEM:F3.1:OTHER:${parent.code}`]);
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT child.id,parent.id
FROM inventory_families child
JOIN inventory_families parent ON parent.code=$2::varchar
WHERE child.code=$1::varchar
ON CONFLICT (child_family_id) DO UPDATE
SET parent_family_id=EXCLUDED.parent_family_id
`, [code, parent.code]);
}
// Si la Instalación misma es "Otro", también necesita una opción "Otro" hija.
const rootSubOther = subOtherCode(INSTALLATION_OTHER_CODE);
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1::varchar,'Otro / no catalogado','SUBINSTALLATION',NULL,'[]'::jsonb,
'SYSTEM:F3.1:OTHER:F31-INST-OTRO',true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
`, [rootSubOther]);
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
SELECT child.id,parent.id
FROM inventory_families child
JOIN inventory_families parent ON parent.code=$2::varchar
WHERE child.code=$1::varchar
ON CONFLICT (child_family_id) DO UPDATE
SET parent_family_id=EXCLUDED.parent_family_id
`, [rootSubOther, INSTALLATION_OTHER_CODE]);
const otherCodes = [
INSTALLATION_OTHER_CODE,
...F31_INSTALLATION_FAMILIES.map((parent) => subOtherCode(parent.code)),
rootSubOther,
];
for (const familyCode of otherCodes) {
for (const title of F31_UNIVERSAL_FINDINGS) {
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item.id,family.id
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
CROSS JOIN inventory_families family
WHERE lower(category.code)='app26r2'
AND lower(btrim(item.title))=lower(btrim($2::text))
AND family.code=$1::varchar
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`, [familyCode, title]);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const codes = [
INSTALLATION_OTHER_CODE,
...F31_INSTALLATION_FAMILIES.map((parent) => subOtherCode(parent.code)),
subOtherCode(INSTALLATION_OTHER_CODE),
];
await queryRunner.query(`
DELETE FROM inventory_families
WHERE code=ANY($1::varchar[])
AND NOT EXISTS (SELECT 1 FROM assets asset WHERE asset.inventory_family_id=inventory_families.id)
`, [codes]);
}
}
@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF31FindingCatalogMerge1789844900000 implements MigrationInterface {
name = 'PhaseF31FindingCatalogMerge1789844900000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE finding_catalog_item_merges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
source_item_id uuid NOT NULL REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
canonical_item_id uuid NOT NULL REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
reason text NOT NULL,
merged_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
merged_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
request_id varchar(128) NULL,
source_snapshot jsonb NOT NULL,
canonical_snapshot jsonb NOT NULL,
CONSTRAINT chk_finding_catalog_item_merges_distinct CHECK (source_item_id <> canonical_item_id),
CONSTRAINT uq_finding_catalog_item_merges_source UNIQUE (source_item_id)
)
`);
await queryRunner.query(`CREATE INDEX idx_finding_catalog_item_merges_canonical ON finding_catalog_item_merges(canonical_item_id)`);
await queryRunner.query(`CREATE INDEX idx_finding_catalog_item_merges_at ON finding_catalog_item_merges(merged_at)`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION protect_finding_catalog_item_merges_append_only()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'finding_catalog_item_merges es append-only; una fusión de catálogo no puede editarse ni borrarse';
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_finding_catalog_item_merges_append_only
BEFORE UPDATE OR DELETE ON finding_catalog_item_merges
FOR EACH ROW EXECUTE FUNCTION protect_finding_catalog_item_merges_append_only()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_finding_catalog_item_merges_append_only ON finding_catalog_item_merges');
await queryRunner.query('DROP FUNCTION IF EXISTS protect_finding_catalog_item_merges_append_only()');
await queryRunner.query('DROP TABLE IF EXISTS finding_catalog_item_merges');
}
}
@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF31InspectorDocumentDelivery1789845000000 implements MigrationInterface {
name = 'PhaseF31InspectorDocumentDelivery1789845000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD COLUMN recipient_user_id uuid NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT fk_inspection_document_deliveries_recipient_user
FOREIGN KEY (recipient_user_id) REFERENCES users(id) ON DELETE SET NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT chk_inspection_document_deliveries_recipient_kind
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind
CHECK (recipient_kind IN ('COMPANY','OFFICE','DIRECTOR','INSPECTOR'))
`);
await queryRunner.query(`
CREATE INDEX idx_inspection_document_deliveries_recipient_user
ON inspection_document_deliveries(recipient_user_id)
WHERE recipient_user_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM inspection_document_deliveries
WHERE recipient_kind='INSPECTOR'
`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_document_deliveries_recipient_user`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT chk_inspection_document_deliveries_recipient_kind
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind
CHECK (recipient_kind IN ('COMPANY','OFFICE','DIRECTOR'))
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT IF EXISTS fk_inspection_document_deliveries_recipient_user
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP COLUMN IF EXISTS recipient_user_id
`);
}
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class MergeFindingCatalogItemDto {
@IsUUID('4')
canonicalItemId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(8)
@MaxLength(2000)
reason!: string;
}
@@ -0,0 +1,97 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable()
export class F3FindingCatalogResolverService {
constructor(
private readonly dataSource: DataSource,
private readonly legacyCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,
asset.inventory_family_id AS "familyId",
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel"
FROM assets asset
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [assetId]) as Array<{
id: string;
code: string;
name: string;
familyId: string | null;
familyCode: string | null;
familyName: string | null;
familyLevel: string | null;
}>;
if (!asset) {
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
}
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
const filters = [
'mapping.inventory_family_id=$1::uuid',
'item.is_active=true',
'category.is_active=true',
'merge_record.source_item_id IS NULL',
];
const params: unknown[] = [asset.familyId];
if (query.categoryId) {
params.push(query.categoryId);
filters.push(`category.id=$${params.length}::uuid`);
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const token = `$${params.length}`;
filters.push(`(item.title ILIKE ${token} OR COALESCE(item.legal_basis,'') ILIKE ${token} OR COALESCE(item.glossary,'') ILIKE ${token})`);
}
const where = filters.join(' AND ');
const [categories, items] = await Promise.all([
this.dataSource.query(`
SELECT DISTINCT category.id,category.code,category.name,category.sort_order AS "sortOrder"
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
LEFT JOIN finding_catalog_item_merges merge_record ON merge_record.source_item_id=item.id
WHERE ${where}
ORDER BY category.sort_order,category.name,category.id
`, params),
this.dataSource.query(`
SELECT item.id,item.category_id AS "categoryId",item.code,
item.source_number AS "sourceNumber",item.title,item.legal_basis AS "legalBasis",
item.glossary,item.suggested_severity AS "suggestedSeverity",item.revision,
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
LEFT JOIN finding_catalog_item_merges merge_record ON merge_record.source_item_id=item.id
WHERE ${where}
ORDER BY category.sort_order,item.source_number,item.code
`, params),
]);
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: {
id: asset.familyId,
code: asset.familyCode,
name: asset.familyName,
level: asset.familyLevel,
},
typeConfigured: true,
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`,
categories,
items,
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.',
},
};
}
}
@@ -0,0 +1,268 @@
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 { MergeFindingCatalogItemDto } from './dto/merge-finding-catalog-item.dto';
type CatalogItemRow = {
id: string;
categoryId: string;
categoryCode: string;
categoryName: string;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
importNote: string | null;
suggestedSeverity: number | null;
revision: number;
isActive: boolean;
};
@Injectable()
export class FindingCatalogMergeService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async status(itemId: string) {
const requested = await this.load(this.dataSource.manager, itemId, false);
const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, itemId);
const canonical = canonicalId === itemId
? requested
: await this.load(this.dataSource.manager, canonicalId, false);
const aliases = await this.dataSource.query(`
WITH RECURSIVE aliases AS (
SELECT source_item_id,canonical_item_id,reason,merged_at,1 AS depth
FROM finding_catalog_item_merges WHERE canonical_item_id=$1::uuid
UNION ALL
SELECT merge_record.source_item_id,merge_record.canonical_item_id,
merge_record.reason,merge_record.merged_at,aliases.depth+1
FROM finding_catalog_item_merges merge_record
JOIN aliases ON merge_record.canonical_item_id=aliases.source_item_id
WHERE aliases.depth<32
)
SELECT alias.source_item_id AS id,item.code,item.title,alias.reason,
alias.merged_at AS "mergedAt",alias.depth
FROM aliases alias
JOIN finding_catalog_items item ON item.id=alias.source_item_id
ORDER BY alias.merged_at,alias.depth
`, [canonicalId]);
return {
requested,
isMerged: canonicalId !== itemId,
canonical,
aliases,
};
}
async merge(
sourceItemId: string,
dto: MergeFindingCatalogItemDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const existing = await manager.query(`
SELECT canonical_item_id AS "canonicalItemId"
FROM finding_catalog_item_merges WHERE source_item_id=$1::uuid
`, [sourceItemId]) as Array<{ canonicalItemId: string }>;
if (existing[0]) {
throw new ConflictException({
code: 'FINDING_CATALOG_ALREADY_MERGED',
message: 'Este Hallazgo de catálogo ya fue fusionado',
canonicalItemId: existing[0].canonicalItemId,
});
}
const canonicalItemId = await this.resolveCanonicalId(manager, dto.canonicalItemId);
if (canonicalItemId === sourceItemId) {
throw new BadRequestException({
code: 'FINDING_CATALOG_MERGE_CYCLE',
message: 'La fusión generaría un ciclo en el catálogo',
});
}
const ids = [sourceItemId, canonicalItemId].sort();
const rows = await manager.query(`
SELECT item.id,item.category_id AS "categoryId",category.code AS "categoryCode",
category.name AS "categoryName",item.code,item.source_number AS "sourceNumber",
item.title,item.legal_basis AS "legalBasis",item.glossary,item.import_note AS "importNote",
item.suggested_severity AS "suggestedSeverity",item.revision,item.is_active AS "isActive"
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[])
ORDER BY item.id
FOR UPDATE OF item
`, [ids]) as CatalogItemRow[];
const source = rows.find((item) => item.id === sourceItemId);
const canonical = rows.find((item) => item.id === canonicalItemId);
if (!source || !canonical) throw new NotFoundException({
code: 'FINDING_CATALOG_ITEM_NOT_FOUND',
message: 'Hallazgo de catálogo no encontrado',
});
if (!source.isActive) throw new ConflictException({
code: 'FINDING_CATALOG_MERGE_SOURCE_INACTIVE',
message: 'El Hallazgo de origen ya está inactivo',
});
if (!canonical.isActive) throw new ConflictException({
code: 'FINDING_CATALOG_MERGE_CANONICAL_INACTIVE',
message: 'El Hallazgo canónico debe estar activo',
});
const sourceSnapshot = this.snapshot(source);
const canonicalSnapshot = this.snapshot(canonical);
// La aplicabilidad futura pasa al canónico; los Hallazgos emitidos nunca se reescriben.
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT $2::uuid,inventory_family_id
FROM finding_catalog_item_inventory_families
WHERE catalog_item_id=$1::uuid
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`, [source.id, canonical.id]);
await manager.query(`
INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id,created_by)
SELECT $2::uuid,asset_type_id,$3::uuid
FROM finding_catalog_item_asset_types
WHERE catalog_item_id=$1::uuid
ON CONFLICT (catalog_item_id,asset_type_id) DO NOTHING
`, [source.id, canonical.id, principal.userId]);
await manager.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset_id,$2::uuid,is_enabled,
'F3.1 merge catálogo: ' || COALESCE(reason,''),$3::uuid,$3::uuid
FROM finding_catalog_asset_overrides source_override
WHERE source_override.catalog_item_id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM finding_catalog_asset_overrides target_override
WHERE target_override.asset_id=source_override.asset_id
AND target_override.catalog_item_id=$2::uuid
)
`, [source.id, canonical.id, principal.userId]);
const [mergeRecord] = await manager.query(`
INSERT INTO finding_catalog_item_merges(
source_item_id,canonical_item_id,reason,merged_by,request_id,
source_snapshot,canonical_snapshot
) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::jsonb,$7::jsonb)
RETURNING id,source_item_id AS "sourceItemId",canonical_item_id AS "canonicalItemId",
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",request_id AS "requestId"
`, [
source.id,
canonical.id,
dto.reason,
principal.userId,
request.requestId,
sourceSnapshot,
canonicalSnapshot,
]) as Array<Record<string, unknown>>;
await manager.query(`
UPDATE finding_catalog_items
SET is_active=false,revision=revision+1,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [source.id]);
const updatedSource = await this.load(manager, source.id, false);
await manager.query(`
INSERT INTO finding_catalog_item_versions(
item_id,revision,snapshot,actor_user_id,actor_username
) VALUES ($1::uuid,$2::integer,$3::jsonb,$4::uuid,$5::varchar)
`, [
updatedSource.id,
updatedSource.revision,
this.snapshot(updatedSource),
principal.userId,
principal.username,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.FINDING_CATALOG_ITEM_UPDATED,
entityType: 'finding_catalog_item_merge',
entityId: String(mergeRecord.id),
beforeData: sourceSnapshot,
afterData: canonicalSnapshot,
metadata: {
operation: 'CHRONOLOGICAL_CATALOG_MERGE',
sourceItemId: source.id,
canonicalItemId: canonical.id,
reason: dto.reason,
historicalFindingsRewritten: false,
historicalProposalsRewritten: false,
futureApplicabilityMovedToCanonical: true,
},
}, manager);
return {
merge: mergeRecord,
source: updatedSource,
canonical,
historyPolicy: 'EMITTED_FINDINGS_PRESERVED',
};
});
}
private async resolveCanonicalId(manager: EntityManager, itemId: string): Promise<string> {
const rows = await manager.query(`
WITH RECURSIVE chain AS (
SELECT source_item_id,canonical_item_id,1 AS depth
FROM finding_catalog_item_merges WHERE source_item_id=$1::uuid
UNION ALL
SELECT merge_record.source_item_id,merge_record.canonical_item_id,chain.depth+1
FROM finding_catalog_item_merges merge_record
JOIN chain ON merge_record.source_item_id=chain.canonical_item_id
WHERE chain.depth<32
)
SELECT canonical_item_id AS id FROM chain ORDER BY depth DESC LIMIT 1
`, [itemId]) as Array<{ id: string }>;
return rows[0]?.id ?? itemId;
}
private async load(manager: EntityManager, itemId: string, lock: boolean): Promise<CatalogItemRow> {
const lockSql = lock ? 'FOR UPDATE OF item' : '';
const rows = await manager.query(`
SELECT item.id,item.category_id AS "categoryId",category.code AS "categoryCode",
category.name AS "categoryName",item.code,item.source_number AS "sourceNumber",
item.title,item.legal_basis AS "legalBasis",item.glossary,item.import_note AS "importNote",
item.suggested_severity AS "suggestedSeverity",item.revision,item.is_active AS "isActive"
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=$1::uuid ${lockSql}
`, [itemId]) as CatalogItemRow[];
if (!rows[0]) throw new NotFoundException({
code: 'FINDING_CATALOG_ITEM_NOT_FOUND',
message: 'Hallazgo de catálogo no encontrado',
});
return rows[0];
}
private snapshot(item: CatalogItemRow): Record<string, unknown> {
return {
id: item.id,
categoryId: item.categoryId,
categoryCode: item.categoryCode,
categoryName: item.categoryName,
code: item.code,
sourceNumber: item.sourceNumber,
title: item.title,
legalBasis: item.legalBasis,
glossary: item.glossary,
importNote: item.importNote,
suggestedSeverity: item.suggestedSeverity,
revision: item.revision,
isActive: item.isActive,
};
}
}
@@ -6,15 +6,22 @@ import { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.d
import { CreateFindingCategoryDto } from './dto/create-finding-category.dto';
import { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto';
import { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { MergeFindingCatalogItemDto } from './dto/merge-finding-catalog-item.dto';
import { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto';
import { ReviewFindingCatalogProposalDto } from './dto/review-finding-catalog-proposal.dto';
import { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto';
import { UpdateFindingCategoryDto } from './dto/update-finding-category.dto';
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
import { FindingCatalogService } from './finding-catalog.service';
@Controller('finding-catalog')
export class FindingCatalogController {
constructor(private readonly catalog: FindingCatalogService) {}
constructor(
private readonly catalog: FindingCatalogService,
private readonly applicableCatalog: F3FindingCatalogResolverService,
private readonly catalogMerges: FindingCatalogMergeService,
) {}
@Get()
@RequirePermissions('finding_catalog.read')
@@ -27,8 +34,8 @@ export class FindingCatalogController {
applicable(
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
@Query() query: ListFindingCatalogQueryDto,
) {
return this.catalog.listApplicableForAsset(assetId, query);
): Promise<unknown> {
return this.applicableCatalog.listApplicableForAsset(assetId, query);
}
@Get('admin')
@@ -123,6 +130,25 @@ export class FindingCatalogController {
return this.catalog.createItem(dto, principal, request);
}
@Get('items/:id/merge-status')
@RequirePermissions('finding_catalog.manage')
mergeStatus(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
): Promise<unknown> {
return this.catalogMerges.status(id);
}
@Post('items/:id/merge')
@RequirePermissions('finding_catalog.manage')
mergeItem(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: MergeFindingCatalogItemDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<unknown> {
return this.catalogMerges.merge(id, dto, principal, request);
}
@Patch('items/:id')
@RequirePermissions('finding_catalog.manage')
updateItem(
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { FindingCatalogController } from './finding-catalog.controller';
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
import { FindingCatalogService } from './finding-catalog.service';
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
import {
InspectionActFindingsController,
InspectionFindingsController,
@@ -26,9 +28,16 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
],
providers: [
FindingCatalogService,
F3FindingCatalogResolverService,
FindingCatalogMergeService,
InspectionFindingsService,
InspectionEvidenceService,
],
exports: [FindingCatalogService, InspectionFindingsService],
exports: [
FindingCatalogService,
F3FindingCatalogResolverService,
FindingCatalogMergeService,
InspectionFindingsService,
],
})
export class InspectionFindingsModule {}
@@ -1,40 +1,470 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { DataSource } 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, AuditSource } from '../database/entities';
import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditService } from '../audit/audit.service';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditAction, AuditSource } from '../database/entities';
import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
export interface DeliveryRow { id:string; actId:string; reportId:string|null; documentKind:'ACT_PDF'|'REPORT_WORD'; recipientKind:'COMPANY'|'OFFICE'|'DIRECTOR'; recipientAssetId:string|null; recipientEmail:string|null; status:string; attempts:number; actCode:string; reportCode:string|null; }
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
export interface DeliveryRow {
id: string;
actId: string;
reportId: string | null;
documentKind: 'ACT_PDF' | 'REPORT_WORD';
recipientKind: DeliveryRecipientKind;
recipientAssetId: string | null;
recipientUserId: string | null;
recipientEmail: string | null;
status: string;
attempts: number;
actCode: string;
reportCode: string | null;
}
@Injectable()
export class InspectionDocumentDeliveryService {
constructor(private readonly dataSource:DataSource, private readonly pdf:InspectionActPdfService, private readonly word:InspectionReportWordService, private readonly smtp:SmtpDeliveryService, private readonly audit:AuditService, private readonly config:ConfigService){}
constructor(
private readonly dataSource: DataSource,
private readonly pdf: InspectionActPdfService,
private readonly word: InspectionReportWordService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
private readonly config: ConfigService,
) {}
async settings(){ const [row]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail",updated_at AS "updatedAt" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null;updatedAt:Date}>; return {...row,smtpConfigured:this.smtp.configured(),mailFrom:this.config.get<string>('MAIL_FROM')??null}; }
async updateSettings(dto:UpdateDocumentDeliverySettingsDto,principal:AuthPrincipal,request:RequestWithContext){ const before=await this.settings(); await this.dataSource.query(`UPDATE institutional_delivery_settings SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=1`,[dto.officeEmail??null,dto.directorEmail??null,principal.userId]); const after=await this.settings(); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_SETTINGS_UPDATED,entityType:'institutional_delivery_settings',entityId:'1',beforeData:before,afterData:after}); return after; }
async list(){ const data=await this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",recipient.name AS "recipientAssetName" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id ORDER BY d.created_at DESC LIMIT 200`); return {data}; }
async dispatchForAct(actId:string):Promise<void>{ await this.pdf.ensure(actId).catch(()=>undefined); const [report]=await this.dataSource.query(`SELECT id FROM inspection_reports WHERE act_id=$1`,[actId]) as Array<{id:string}>; if(report)await this.word.ensure(report.id); await this.ensureRows(actId,report?.id??null); const rows=await this.rowsForAct(actId); for(const row of rows)await this.attempt(row).catch(()=>undefined); }
async retry(id:string,principal:AuthPrincipal,request:RequestWithContext):Promise<DeliveryRow>{ const row=await this.load(id); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_RETRY_REQUESTED,entityType:'inspection_document_delivery',entityId:id,metadata:{actId:row.actId,documentKind:row.documentKind,recipientKind:row.recipientKind}}); await this.refreshRecipient(row); await this.attempt(await this.load(id)); return this.load(id); }
async retryPending(principal:AuthPrincipal,request:RequestWithContext){ const rows=await this.dataSource.query(`SELECT id FROM inspection_document_deliveries WHERE status<>'SENT' ORDER BY created_at ASC LIMIT 100`) as Array<{id:string}>; for(const item of rows)await this.retry(item.id,principal,request).catch(()=>undefined); return {processed:rows.length}; }
private async ensureRows(actId:string,reportId:string|null){ const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; const companies=await this.dataSource.query(`SELECT DISTINCT company.id,profile.notification_email AS email FROM inspection_act_assets link JOIN assets asset ON asset.id=link.asset_id JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id JOIN assets company ON company.id=COALESCE(asset.operator_company_id,CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END) LEFT JOIN organization_profiles profile ON profile.asset_id=company.id WHERE link.act_id=$1 AND link.included=true`,[actId]) as Array<{id:string;email:string|null}>;
for(const company of companies)await this.upsertRow(actId,reportId,'ACT_PDF','COMPANY',company.id,company.email);
await this.upsertRow(actId,reportId,'ACT_PDF','OFFICE',null,settings?.officeEmail??null);
if(reportId)await this.upsertRow(actId,reportId,'REPORT_WORD','DIRECTOR',null,settings?.directorEmail??null);
async settings() {
const [row] = await this.dataSource.query(`
SELECT
office_email AS "officeEmail",
director_email AS "directorEmail",
updated_at AS "updatedAt"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{
officeEmail: string | null;
directorEmail: string | null;
updatedAt: Date;
}>;
return {
...row,
smtpConfigured: this.smtp.configured(),
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
};
}
private async upsertRow(actId:string,reportId:string|null,documentKind:string,recipientKind:string,recipientAssetId:string|null,recipientEmail:string|null){ await this.dataSource.query(`INSERT INTO inspection_document_deliveries (act_id,report_id,document_kind,recipient_kind,recipient_asset_id,recipient_key,recipient_email,status) VALUES ($1,$2,$3,$4,$5,COALESCE($5::uuid,'00000000-0000-0000-0000-000000000000'::uuid),$6,$7) ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),recipient_email=COALESCE(inspection_document_deliveries.recipient_email,EXCLUDED.recipient_email),updated_at=CURRENT_TIMESTAMP`,[actId,reportId,documentKind,recipientKind,recipientAssetId,recipientEmail,recipientEmail?'PENDING':'WAITING_RECIPIENT']); }
private async rowsForAct(actId:string){ return this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,a.code AS "actCode",r.code AS "reportCode" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id WHERE d.act_id=$1 ORDER BY d.created_at`,[actId]) as Promise<DeliveryRow[]>; }
private async load(id:string){ const [row]=await this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,a.code AS "actCode",r.code AS "reportCode" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id WHERE d.id=$1`,[id]) as DeliveryRow[]; if(!row)throw new NotFoundException({code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada'}); return row; }
private async refreshRecipient(row:DeliveryRow){ let email:string|null=null; if(row.recipientKind==='COMPANY'&&row.recipientAssetId){ const [company]=await this.dataSource.query(`SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1`,[row.recipientAssetId]) as Array<{email:string|null}>; email=company?.email??null; } else { const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; email=row.recipientKind==='OFFICE'?settings?.officeEmail??null:settings?.directorEmail??null; } await this.dataSource.query(`UPDATE inspection_document_deliveries SET recipient_email=$2,status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND status<>'SENT'`,[row.id,email]); }
private async attempt(row:DeliveryRow){ if(row.status==='SENT')return; if(!row.recipientEmail){await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');return;} if(!this.smtp.configured()){await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');return;} let attachment:{filename:string;mimeType:string;content:Buffer}; try{ if(row.documentKind==='ACT_PDF'){await this.pdf.ensure(row.actId); const file=await this.pdf.content(row.actId); attachment={filename:file.originalName,mimeType:file.mimeType,content:file.buffer};}else{if(!row.reportId)throw new Error('Informe no vinculado'); await this.word.ensure(row.reportId); const file=await this.word.content(row.reportId); const {readFile}=await import('node:fs/promises'); attachment={filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath)};}}catch(error){await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error?error.message:'Documento no disponible');return;}
await this.dataSource.query(`UPDATE inspection_document_deliveries SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[row.id]); try{ const label=row.documentKind==='ACT_PDF'?`Acta ${row.actCode}`:`Informe ${row.reportCode??''}`; const sent=await this.smtp.send({to:row.recipientEmail,subject:`DH Inspección · ${label}`,text:row.documentKind==='ACT_PDF'?`Se adjunta el acta cerrada e inmutable ${row.actCode}.`:`Se adjunta el informe Word automático ${row.reportCode??''} para revisión del Director de Hidrocarburos.`,attachment}); await this.dataSource.query(`UPDATE inspection_document_deliveries SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[row.id,sent.messageId]); await this.audit.record({action:AuditAction.DOCUMENT_DELIVERY_SENT,entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,afterData:{recipientKind:row.recipientKind,documentKind:row.documentKind,status:'SENT'},metadata:{actId:row.actId,reportId:row.reportId}}); }catch(error){await this.setStatus(row.id,'FAILED',error instanceof Error?error.message:'Error de entrega');}
async updateSettings(
dto: UpdateDocumentDeliverySettingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const before = await this.settings();
await this.dataSource.query(`
UPDATE institutional_delivery_settings
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=1
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
const after = await this.settings();
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.DOCUMENT_DELIVERY_SETTINGS_UPDATED,
entityType: 'institutional_delivery_settings',
entityId: '1',
beforeData: before,
afterData: after,
});
return after;
}
async list() {
const data = await this.dataSource.query(`
SELECT
d.id,
d.act_id AS "actId",
d.report_id AS "reportId",
d.document_kind AS "documentKind",
d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",
d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",
d.status,
d.attempts,
d.last_attempt_at AS "lastAttemptAt",
d.sent_at AS "sentAt",
d.provider_message_id AS "providerMessageId",
d.last_error AS "lastError",
d.created_at AS "createdAt",
a.code AS "actCode",
r.code AS "reportCode",
recipient.name AS "recipientAssetName",
CASE
WHEN recipient_user.id IS NULL THEN NULL
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
END AS "recipientUserName"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
ORDER BY d.created_at DESC
LIMIT 200
`);
return { data };
}
async dispatchForAct(actId: string): Promise<void> {
await this.pdf.ensure(actId).catch(() => undefined);
const [report] = await this.dataSource.query(
`SELECT id FROM inspection_reports WHERE act_id=$1`,
[actId],
) as Array<{ id: string }>;
if (report) await this.word.ensure(report.id);
await this.ensureRows(actId, report?.id ?? null);
const rows = await this.rowsForAct(actId);
for (const row of rows) await this.attempt(row).catch(() => undefined);
}
async retry(
id: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<DeliveryRow> {
const row = await this.load(id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.DOCUMENT_DELIVERY_RETRY_REQUESTED,
entityType: 'inspection_document_delivery',
entityId: id,
metadata: {
actId: row.actId,
documentKind: row.documentKind,
recipientKind: row.recipientKind,
recipientUserId: row.recipientUserId,
},
});
await this.refreshRecipient(row);
await this.attempt(await this.load(id));
return this.load(id);
}
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
const rows = await this.dataSource.query(`
SELECT id
FROM inspection_document_deliveries
WHERE status<>'SENT'
ORDER BY created_at ASC
LIMIT 100
`) as Array<{ id: string }>;
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
return { processed: rows.length };
}
private async ensureRows(actId: string, reportId: string | null) {
const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
const companies = await this.dataSource.query(`
SELECT DISTINCT company.id,profile.notification_email AS email
FROM inspection_act_assets link
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
JOIN assets company ON company.id=COALESCE(
asset.operator_company_id,
CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END
)
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE link.act_id=$1 AND link.included=true
`, [actId]) as Array<{ id: string; email: string | null }>;
const [inspector] = await this.dataSource.query(`
SELECT
inspector.id,
inspector.email
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
WHERE act.id=$1
`, [actId]) as Array<{ id: string; email: string | null }>;
for (const company of companies) {
await this.upsertRow({
actId,
reportId,
documentKind: 'ACT_PDF',
recipientKind: 'COMPANY',
recipientAssetId: company.id,
recipientUserId: null,
recipientKey: company.id,
recipientEmail: company.email,
});
}
await this.upsertRow({
actId,
reportId,
documentKind: 'ACT_PDF',
recipientKind: 'OFFICE',
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.officeEmail ?? null,
});
if (inspector) {
await this.upsertRow({
actId,
reportId,
documentKind: 'ACT_PDF',
recipientKind: 'INSPECTOR',
recipientAssetId: null,
recipientUserId: inspector.id,
recipientKey: inspector.id,
recipientEmail: inspector.email,
});
}
if (reportId) {
await this.upsertRow({
actId,
reportId,
documentKind: 'REPORT_WORD',
recipientKind: 'DIRECTOR',
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.directorEmail ?? null,
});
}
}
private async upsertRow(input: {
actId: string;
reportId: string | null;
documentKind: 'ACT_PDF' | 'REPORT_WORD';
recipientKind: DeliveryRecipientKind;
recipientAssetId: string | null;
recipientUserId: string | null;
recipientKey: string;
recipientEmail: string | null;
}) {
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
await this.dataSource.query(`
INSERT INTO inspection_document_deliveries (
act_id,report_id,document_kind,recipient_kind,
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET
report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),
recipient_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id),
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
recipient_email=CASE
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
ELSE EXCLUDED.recipient_email
END,
status=CASE
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
ELSE inspection_document_deliveries.status
END,
updated_at=CURRENT_TIMESTAMP
`, [
input.actId,
input.reportId,
input.documentKind,
input.recipientKind,
input.recipientAssetId,
input.recipientUserId,
input.recipientKey,
input.recipientEmail,
initialStatus,
]);
}
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
return this.dataSource.query(`
SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
a.code AS "actCode",r.code AS "reportCode"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.act_id=$1
ORDER BY d.created_at
`, [actId]) as Promise<DeliveryRow[]>;
}
private async load(id: string): Promise<DeliveryRow> {
const [row] = await this.dataSource.query(`
SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
a.code AS "actCode",r.code AS "reportCode"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.id=$1
`, [id]) as DeliveryRow[];
if (!row) {
throw new NotFoundException({
code: 'DOCUMENT_DELIVERY_NOT_FOUND',
message: 'Entrega documental no encontrada',
});
}
return row;
}
private async refreshRecipient(row: DeliveryRow) {
let email: string | null = null;
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
const [company] = await this.dataSource.query(`
SELECT notification_email AS email
FROM organization_profiles
WHERE asset_id=$1
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(`
SELECT email
FROM users
WHERE id=$1 AND is_active=true
`, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null;
} else {
const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
email = row.recipientKind === 'OFFICE'
? settings?.officeEmail ?? null
: settings?.directorEmail ?? null;
}
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET recipient_email=$2,
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1 AND status<>'SENT'
`, [row.id, email]);
}
private async attempt(row: DeliveryRow) {
if (row.status === 'SENT') return;
if (!row.recipientEmail) {
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado');
return;
}
if (!this.smtp.configured()) {
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado');
return;
}
let attachment: { filename: string; mimeType: string; content: Buffer };
try {
if (row.documentKind === 'ACT_PDF') {
await this.pdf.ensure(row.actId);
const file = await this.pdf.content(row.actId);
attachment = {
filename: file.originalName,
mimeType: file.mimeType,
content: file.buffer,
};
} else {
if (!row.reportId) throw new Error('Informe no vinculado');
await this.word.ensure(row.reportId);
const file = await this.word.content(row.reportId);
const { readFile } = await import('node:fs/promises');
attachment = {
filename: file.originalName,
mimeType: file.mimeType,
content: await readFile(file.filePath),
};
}
} catch (error) {
await this.setStatus(
row.id,
'WAITING_ARTIFACT',
error instanceof Error ? error.message : 'Documento no disponible',
);
return;
}
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id]);
try {
const label = row.documentKind === 'ACT_PDF'
? `Acta ${row.actCode}`
: `Informe ${row.reportCode ?? ''}`;
const text = row.documentKind === 'REPORT_WORD'
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
: row.recipientKind === 'INSPECTOR'
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({
to: row.recipientEmail,
subject: `DH Inspección · ${label}`,
text,
attachment,
});
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id, sent.messageId]);
await this.audit.record({
action: AuditAction.DOCUMENT_DELIVERY_SENT,
entityType: 'inspection_document_delivery',
entityId: row.id,
source: AuditSource.SYSTEM,
actorUserId: null,
actorUsername: null,
requestId: null,
ip: null,
userAgent: null,
beforeData: null,
afterData: {
recipientKind: row.recipientKind,
recipientUserId: row.recipientUserId,
documentKind: row.documentKind,
status: 'SENT',
},
metadata: { actId: row.actId, reportId: row.reportId },
});
} catch (error) {
await this.setStatus(
row.id,
'FAILED',
error instanceof Error ? error.message : 'Error de entrega',
);
}
}
private async setStatus(id: string, status: string, error: string) {
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [id, status, error.slice(0, 500)]);
}
private async setStatus(id:string,status:string,error:string){await this.dataSource.query(`UPDATE inspection_document_deliveries SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[id,status,error.slice(0,500)]);}
}
@@ -21,6 +21,10 @@ export class CreateFieldInventoryDto {
@IsUUID('4')
parentId?: string;
@IsOptional()
@IsUUID('4')
familyId?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : undefined)
@IsString()
@@ -0,0 +1,220 @@
import {
BadRequestException,
ConflictException,
Injectable,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AssetHistoryService } from '../asset-master/asset-history.service';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetVersionChangeType, AuditAction } from '../database/entities';
import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
import { FieldInventoryService } from './field-inventory.service';
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isOther: boolean;
};
type ParentRow = {
id: string;
typeCode: string;
inventoryFamilyId: string | null;
};
type FieldTypesBase = {
context: {
area: { id: string; code: string; name: string };
operatorCompany: { id: string; code: string; name: string };
inspection: { id: string; code: string; status: string };
};
parent: { id: string; code: string; name: string };
data: Array<Record<string, unknown> & { id: string; code: string; name: string }>;
};
const STRUCTURAL_CHILD: Record<string, string | undefined> = {
area: 'yacimiento',
yacimiento: 'instalacion',
instalacion: 'subinstalacion',
};
@Injectable()
export class F3FieldInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly fieldInventory: FieldInventoryService,
private readonly history: AssetHistoryService,
private readonly audit: AuditService,
) {}
async types(
visitId: string,
parentId: string | undefined,
principal: AuthPrincipal,
) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.context.area.id;
const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode
? base.data.filter((type) => type.code.toLowerCase() === expectedTypeCode)
: [];
const families = await this.familiesFor(parent, expectedTypeCode);
return {
...base,
data: data.map((type) => ({
...type,
structuralKind: type.code.toUpperCase(),
families,
familyRequired: type.code.toLowerCase() === 'instalacion' || type.code.toLowerCase() === 'subinstalacion',
})),
};
}
async create(
visitId: string,
dto: CreateFieldInventoryDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const options = await this.types(visitId, dto.parentId, principal);
const selectedType = options.data.find((type) => type.id === dto.typeId);
if (!selectedType) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_STRUCTURE_TYPE_INVALID',
message: 'El tipo elegido no corresponde al siguiente nivel estructural permitido',
});
}
const typeCode = String(selectedType.code).toLowerCase();
const familyRequired = typeCode === 'instalacion' || typeCode === 'subinstalacion';
const families = (selectedType.families ?? []) as FamilyRow[];
if (familyRequired && !dto.familyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
message: 'Elegí la familia técnica o la opción Otro / no catalogado',
});
}
if (!familyRequired && dto.familyId) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
message: 'Este nivel estructural no utiliza familia técnica',
});
}
const family = dto.familyId
? families.find((item) => item.id === dto.familyId)
: null;
if (dto.familyId && !family) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_FAMILY_INVALID',
message: 'La familia técnica no es válida para el padre seleccionado',
});
}
const created = await this.fieldInventory.create(visitId, dto, principal, request) as {
asset: { id: string; code: string; name: string };
[key: string]: unknown;
};
if (!family) return created;
await this.dataSource.transaction(async (manager) => {
const [current] = await manager.query(`
SELECT inventory_family_id AS "inventoryFamilyId"
FROM assets WHERE id=$1::uuid FOR UPDATE
`, [created.asset.id]) as Array<{ inventoryFamilyId: string | null }>;
if (!current) throw new ConflictException({
code: 'FIELD_INVENTORY_CREATED_ASSET_MISSING',
message: 'No se pudo clasificar el Inventario recién creado',
});
if (current.inventoryFamilyId === family.id) return;
await manager.query(`
UPDATE assets
SET inventory_family_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [created.asset.id, family.id, principal.userId]);
const versionNumber = await this.history.capture(
manager,
created.asset.id,
AssetVersionChangeType.UPDATED,
principal,
request,
);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_UPDATED,
entityType: 'asset',
entityId: created.asset.id,
beforeData: { inventoryFamilyId: current.inventoryFamilyId },
afterData: {
inventoryFamilyId: family.id,
inventoryFamilyCode: family.code,
inventoryFamilyName: family.name,
},
metadata: {
operation: 'FIELD_INVENTORY_FAMILY_ASSIGNED',
visitId,
versionNumber,
isOtherFamily: family.isOther,
},
}, manager);
});
return this.fieldInventory.detail(visitId, created.asset.id, principal);
}
private async parent(parentId: string): Promise<ParentRow> {
const rows = await this.dataSource.query(`
SELECT asset.id,type.code AS "typeCode",asset.inventory_family_id AS "inventoryFamilyId"
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 ParentRow[];
if (!rows[0]) throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_NOT_AVAILABLE',
message: 'El padre estructural elegido no está disponible',
});
return rows[0];
}
private async familiesFor(parent: ParentRow, expectedTypeCode: string | undefined): Promise<FamilyRow[]> {
if (expectedTypeCode === 'instalacion') {
return this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels",
source_reference AS "sourceReference",
(source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
FROM inventory_families
WHERE level='INSTALLATION' AND is_active=true
ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code
`) as Promise<FamilyRow[]>;
}
if (expectedTypeCode === 'subinstalacion') {
if (!parent.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones',
});
}
return this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
FROM inventory_family_parent_rules rule
JOIN inventory_families family ON family.id=rule.child_family_id
WHERE rule.parent_family_id=$1::uuid
AND family.level='SUBINSTALLATION'
AND family.is_active=true
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
}
return [];
}
}
@@ -2,7 +2,7 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException }
import { DataSource } from 'typeorm';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto';
import { FindingCatalogService } from '../inspection-findings/finding-catalog.service';
import { F3FindingCatalogResolverService } from '../inspection-findings/f3-finding-catalog-resolver.service';
import { InspectionFindingsService } from '../inspection-findings/inspection-findings.service';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateFieldFindingDto } from './dto/create-field-finding.dto';
@@ -32,7 +32,7 @@ interface FieldFindingGate {
export class FieldFindingsService {
constructor(
private readonly dataSource: DataSource,
private readonly catalog: FindingCatalogService,
private readonly catalog: F3FindingCatalogResolverService,
private readonly findings: InspectionFindingsService,
) {}
@@ -112,6 +112,8 @@ export class FieldFindingsService {
company.code AS "companyCode",
company.name AS "companyName",
asset.id AS "assetId",
asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId",
asset.operational_area_id AS "assetAreaId",
asset.operator_company_id AS "assetCompanyId",
EXISTS (
@@ -158,6 +160,7 @@ export class FieldFindingsService {
LEFT JOIN assets company ON company.id = visit.operator_company_id
LEFT JOIN assets asset ON asset.id = $2::uuid
AND asset.information_status <> 'INACTIVE'
LEFT JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
WHERE visit.id = $1::uuid
`, [visitId, assetId, principal.userId]) as Array<{
visitId: string;
@@ -170,6 +173,8 @@ export class FieldFindingsService {
companyCode: string | null;
companyName: string | null;
assetId: string | null;
assetTypeCode: string | null;
inventoryFamilyId: string | null;
assetAreaId: string | null;
assetCompanyId: string | null;
assigned: boolean;
@@ -186,7 +191,7 @@ export class FieldFindingsService {
if (row.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FIELD_FINDING_VISIT_NOT_IN_PROGRESS',
message: 'Los hallazgos sólo pueden registrarse cuando la inspección está en curso',
message: 'Los Hallazgos sólo pueden registrarse cuando la inspección está en curso',
});
}
if (!row.assigned) {
@@ -207,6 +212,18 @@ export class FieldFindingsService {
message: 'Registro de Inventario no encontrado',
});
}
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
throw new BadRequestException({
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
});
}
if (!row.inventoryFamilyId) {
throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos',
});
}
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
@@ -216,7 +233,7 @@ export class FieldFindingsService {
if (!row.selected) {
throw new ConflictException({
code: 'FIELD_FINDING_INVENTORY_NOT_SELECTED',
message: 'Seleccioná el Inventario dentro de la inspección antes de registrar un hallazgo',
message: 'Seleccioná el Inventario dentro de la inspección antes de registrar un Hallazgo',
});
}
@@ -230,7 +247,7 @@ export class FieldFindingsService {
if (!readyForFinding) {
throw new ConflictException({
code: 'FIELD_FINDING_CAPTURE_REQUIRED',
message: 'Antes del hallazgo, el Inventario creado en campo debe tener GPS y al menos una foto',
message: 'Antes del Hallazgo, el Inventario creado en campo debe tener GPS y al menos una foto',
capture: { captureRequired, hasGeometry, creationGpsCaptured, fieldPhotoCount },
});
}
@@ -264,7 +281,7 @@ export class FieldFindingsService {
if (rows.length === 0) {
throw new ConflictException({
code: 'FIELD_FINDING_DRAFT_ACT_REQUIRED',
message: 'La inspección no tiene un Acta borrador abierta para registrar hallazgos',
message: 'La inspección no tiene un Acta borrador abierta para registrar Hallazgos',
});
}
if (rows.length > 1) {
@@ -18,11 +18,15 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
import { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
import { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
import { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
import { F3FieldInventoryStructureService } from './f3-field-inventory-structure.service';
import { FieldInventoryService } from './field-inventory.service';
@Controller('inspection-visits/:visitId/field-inventory')
export class FieldInventoryController {
constructor(private readonly fieldInventory: FieldInventoryService) {}
constructor(
private readonly fieldInventory: FieldInventoryService,
private readonly structure: F3FieldInventoryStructureService,
) {}
@Get()
@RequirePermissions('assets.read', 'inspections.execute')
@@ -40,8 +44,8 @@ export class FieldInventoryController {
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Query() query: ListFieldInventoryQueryDto,
@CurrentAuth() principal: AuthPrincipal,
) {
return this.fieldInventory.types(visitId, query.parentId, principal);
): Promise<unknown> {
return this.structure.types(visitId, query.parentId, principal);
}
@Get(':assetId')
@@ -71,8 +75,8 @@ export class FieldInventoryController {
@Body() dto: CreateFieldInventoryDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.fieldInventory.create(visitId, dto, principal, request);
): Promise<unknown> {
return this.structure.create(visitId, dto, principal, request);
}
@Post(':assetId/photos')
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AssetMasterModule } from '../asset-master/asset-master.module';
import { AuditModule } from '../audit/audit.module';
import { InspectionFindingsModule } from '../inspection-findings/inspection-findings.module';
import { F3FieldInventoryStructureService } from './f3-field-inventory-structure.service';
import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller';
@@ -12,6 +13,11 @@ import { InspectionVisitsService } from './inspection-visits.service';
@Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [InspectionVisitsService, FieldInventoryService, FieldFindingsService],
providers: [
InspectionVisitsService,
FieldInventoryService,
F3FieldInventoryStructureService,
FieldFindingsService,
],
})
export class InspectionVisitsModule {}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.23.0-1';
export const API_PHASE = 'F2.3';
export const API_VERSION = '0.24.0-1';
export const API_PHASE = 'F3.1';
@@ -0,0 +1,54 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const migration = readFileSync(
'src/database/migrations/1789844600000-phase-f3-1-asset-chronological-merge.ts',
'utf8',
);
const service = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const controller = readFileSync('src/asset-master/inventory-merge.controller.ts', 'utf8');
test('F3.1 registra cada fusión como evento append-only con snapshots', () => {
assert.match(migration, /CREATE TABLE asset_merges/);
assert.match(migration, /source_snapshot jsonb NOT NULL/);
assert.match(migration, /canonical_snapshot jsonb NOT NULL/);
assert.match(migration, /UNIQUE \(source_asset_id\)/);
assert.match(migration, /BEFORE UPDATE OR DELETE ON asset_merges/);
assert.match(migration, /append-only/);
});
test('F3.1 sólo permite fusionar Instalaciones o Subinstalaciones compatibles', () => {
assert.match(service, /MERGEABLE_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(service, /INVENTORY_MERGE_TYPE_INVALID/);
assert.match(service, /INVENTORY_MERGE_CONTEXT_MISMATCH/);
assert.match(service, /INVENTORY_MERGE_PARENT_MISMATCH/);
assert.match(service, /INVENTORY_MERGE_CHILD_FAMILY_CONFLICT/);
});
test('F3.1 preserva referencias históricas y sólo reubica hijos actuales', () => {
assert.match(service, /historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED'/);
assert.match(service, /historicalReferencesRewritten: false/);
assert.doesNotMatch(service, /UPDATE\s+inspection_findings/i);
assert.doesNotMatch(service, /UPDATE\s+inspection_act_assets/i);
assert.doesNotMatch(service, /UPDATE\s+inspection_visit_assets/i);
assert.match(service, /Reparentado por fusión cronológica/);
assert.match(service, /AssetVersionChangeType\.UPDATED/);
});
test('F3.1 expone estado/merge en WEB y conciliación de alta nacida en la inspección para APK', () => {
assert.match(controller, /:id\/merge-status/);
assert.match(controller, /:id\/merge/);
assert.match(controller, /:assetId\/merge/);
assert.match(controller, /assets\.create/);
assert.match(controller, /inspections\.execute/);
assert.match(service, /FIELD_MERGE_SOURCE_MUST_BE_DISCOVERY/);
assert.match(service, /assertMobileInspector\(principal\)/);
});
test('F3.1 resuelve cadenas de merge hacia un canónico final y detecta ciclos', () => {
assert.match(service, /WITH RECURSIVE chain/);
assert.match(service, /INVENTORY_MERGE_CYCLE/);
assert.match(service, /aliasesFor/);
});
@@ -0,0 +1,54 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import {
F31_FINDING_GROUPS,
F31_INSTALLATION_FAMILIES,
F31_SUBINSTALLATION_FAMILIES,
F31_UNIVERSAL_FINDINGS,
} from '../../src/reference-data/f3-1-inventory-excel';
test('F3.1 conserva exactamente la estructura interpretada del Excel entregado', () => {
assert.equal(F31_FINDING_GROUPS.length, 19);
assert.equal(F31_INSTALLATION_FAMILIES.length, 14);
assert.equal(F31_SUBINSTALLATION_FAMILIES.length, 109);
assert.deepEqual(F31_UNIVERSAL_FINDINGS, [
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
]);
});
test('F3.1 mantiene cada Subinstalación ligada a una familia de Instalación válida', () => {
const installationCodes = new Set(F31_INSTALLATION_FAMILIES.map((item) => item.code));
for (const subinstallation of F31_SUBINSTALLATION_FAMILIES) {
assert.equal(installationCodes.has(subinstallation.parentCode), true, subinstallation.code);
assert.equal(subinstallation.level, 'SUBINSTALLATION');
}
});
test('F3.1 retira APP26 como catálogo activo sin borrar los Hallazgos históricos', () => {
const migration = readFileSync('src/database/migrations/1789844400000-phase-f3-1-inventory-structure-catalog.ts', 'utf8');
assert.match(migration, /UPDATE finding_categories SET is_active=false/);
assert.match(migration, /lower\(code\)='app26'/);
assert.match(migration, /APP26R2/);
assert.doesNotMatch(migration, /DELETE FROM finding_catalog_items/);
assert.doesNotMatch(migration, /DELETE FROM inspection_findings/);
});
test('F3.1 materializa familia técnica separada del tipo estructural', () => {
const migration = readFileSync('src/database/migrations/1789844400000-phase-f3-1-inventory-structure-catalog.ts', 'utf8');
assert.match(migration, /CREATE TABLE inventory_families/);
assert.match(migration, /ADD COLUMN inventory_family_id uuid/);
assert.match(migration, /finding_catalog_item_inventory_families/);
assert.match(migration, /'subinstalacion','Subinstalación'/);
});
test('F3.1 fuerza Área → Yacimiento → Instalación → Subinstalación en servidor', () => {
const service = readFileSync('src/asset-master/inventory-structure.service.ts', 'utf8');
assert.match(service, /YACIMIENTO: 'area'/);
assert.match(service, /INSTALACION: 'yacimiento'/);
assert.match(service, /SUBINSTALACION: 'instalacion'/);
assert.match(service, /INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID/);
});
@@ -0,0 +1,51 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const userMigration = readFileSync(
'src/database/migrations/1789844700000-phase-f3-1-user-profile.ts',
'utf8',
);
const usersService = readFileSync('src/administration/users/users.service.ts', 'utf8');
const dossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const otherMigration = readFileSync(
'src/database/migrations/1789844800000-phase-f3-1-other-inventory-families.ts',
'utf8',
);
test('F3.1 amplía el perfil personal y protege email del Inspector también en DB', () => {
assert.match(userMigration, /ADD COLUMN dni/);
assert.match(userMigration, /ADD COLUMN phone/);
assert.match(userMigration, /ADD COLUMN job_title/);
assert.match(userMigration, /ADD COLUMN employee_number/);
assert.match(userMigration, /uq_users_dni_not_null/);
assert.match(userMigration, /INSPECTOR_EMAIL_REQUIRED/);
assert.match(userMigration, /trg_user_roles_inspector_email/);
assert.match(userMigration, /trg_users_inspector_email/);
});
test('F3.1 exige email al crear o asignar el rol Inspector', () => {
assert.match(usersService, /assertInspectorHasEmail\(roles, dto\.email/);
assert.match(usersService, /assertInspectorHasEmail\(roles, before\.email\)/);
assert.match(usersService, /Los usuarios con rol Inspector deben tener un email válido/);
assert.match(usersService, /dto\.email !== undefined && !dto\.email && before\.roles\.some/);
});
test('F3.1 dossier canónico agrega alias sin borrar identidad histórica', () => {
assert.match(dossier, /inventoryIds = \[canonical\.id, \.\.\.aliases\.map/);
assert.match(dossier, /Promise\.all\(inventoryIds\.map/);
assert.match(dossier, /historicalInventory/);
assert.match(dossier, /kind: 'MERGE'/);
assert.match(dossier, /CHRONOLOGICAL_AGGREGATION_WITH_ORIGINAL_IDENTITY/);
assert.match(dossier, /requestedWasMerged/);
});
test('F3.1 crea familias Otro válidas y asociadas a controles universales', () => {
assert.match(otherMigration, /F31-INST-OTRO/);
assert.match(otherMigration, /Otro \/ no catalogado/);
assert.match(otherMigration, /SUBINSTALLATION/);
assert.match(otherMigration, /inventory_family_parent_rules/);
assert.match(otherMigration, /F31_UNIVERSAL_FINDINGS/);
assert.match(otherMigration, /finding_catalog_item_inventory_families/);
});
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx"
APP="$ROOT/web-v2/src/app/App.tsx"
PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
for file in "$LAYOUT" "$APP" "$PAGE"; do
test -f "$file" || {
echo "F3.1 WEB contract: falta $file"
exit 1
}
done
if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then
echo "F3.1 WEB contract: Relevamientos no debe volver al menú principal"
exit 1
fi
grep -Fq 'path="/relevamiento/*"' "$APP"
grep -Fq 'Navigate to="/inventarios" replace' "$APP"
grep -Fq 'path="/inventarios/nuevo"' "$APP"
grep -Fq 'InventoryCreatePage' "$APP"
for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$PAGE"
done
if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then
echo "F3.1 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural"
exit 1
fi
grep -Fq 'getInventoryFamilyFindings' "$PAGE"
echo 'F3.1 WEB contract: OK'
+5 -11
View File
@@ -14,13 +14,11 @@ import { UserDetailPage } from '../pages/UserDetailPage';
import { UsersPage } from '../pages/UsersPage';
import { AssetsPage } from '../pages/AssetsPage';
import { AssetEditorPage } from '../pages/AssetEditorPage';
import { InventoryCreatePage } from '../pages/InventoryCreatePage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { SurveyCampaignsPage } from '../pages/SurveyCampaignsPage';
import { SurveyCampaignEditorPage } from '../pages/SurveyCampaignEditorPage';
import { SurveyExecutionPage } from '../pages/SurveyExecutionPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
import { InspectionVisitEditorPage } from '../pages/InspectionVisitEditorPage';
import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
@@ -37,8 +35,8 @@ import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPag
import { FieldBriefingsPage } from '../pages/FieldBriefingsPage';
const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage })));
const DocumentDeliveryPage = lazy(() => import('../pages/DocumentDeliveryPage').then((module) => ({ default: module.DocumentDeliveryPage })));
export function App() {
return <Suspense fallback={<div className="loading-block"><span className="spinner" />Cargando módulo</div>}><Routes>
<Route path="/login" element={<LoginPage />} />
@@ -55,14 +53,10 @@ export function App() {
</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={<AssetEditorPage />} /><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 element={<PermissionRoute permission="surveys.read" />}>
<Route path="/relevamiento" element={<SurveyCampaignsPage />} />
<Route path="/relevamiento/:id" element={<SurveyCampaignEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="surveys.read_reports" />}><Route path="/relevamiento/objetivos/:targetId" element={<SurveyExecutionPage />} /></Route>
<Route element={<PermissionRoute permission="surveys.manage" />}><Route path="/relevamiento/nuevo" element={<SurveyCampaignEditorPage />} /></Route>
<Route path="/relevamiento" element={<Navigate to="/inventarios" replace />} />
<Route path="/relevamiento/*" element={<Navigate to="/inventarios" replace />} />
<Route element={<PermissionRoute permission="inspections.read" />}>
<Route path="/inspecciones" element={<InspectionVisitsPage />} />
<Route path="/inspecciones/:id" element={<InspectionVisitEditorPage />} />
+2 -1
View File
@@ -1,7 +1,7 @@
export type IconName =
| 'home' | 'map' | 'layers' | 'calendar' | 'clipboard' | 'alert'
| 'history' | 'users' | 'shield' | 'audit' | 'logout' | 'menu'
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload';
| 'plus' | 'search' | 'edit' | 'chevron' | 'check' | 'key' | 'upload' | 'mail';
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
const paths: Record<IconName, React.ReactNode> = {
@@ -24,6 +24,7 @@ export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
check: <path d="m5 12 4 4L19 6"/>,
key: <><circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M15 8l3 3M17 6l2 2"/></>,
upload: <><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/></>,
mail: <><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></>,
};
return (
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+2 -2
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.20.0-4';
export const APP_PHASE = 'Fase F1.4 · Briefing contextual antes de salir';
export const APP_VERSION = '0.21.0-1';
export const APP_PHASE = 'Fase F3.1 · Inventario estructural y catálogo contextual';
+129 -21
View File
@@ -1,12 +1,32 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { formatDate, formatDateOnly } from '../../lib/format';
import { getAssetDossier } from '../../lib/api';
import type { AssetDossier, AssetDossierTimelineEvent } from '../../lib/api';
import { getAsset, getAssetDossier } from '../../lib/api';
import type { AssetDetail, AssetDossier, AssetDossierTimelineEvent, AssetListItem } from '../../lib/api';
import {
getInventoryMergeStatus,
mergeInventoryAsset,
searchInventoryMergeCandidates,
} from '../../lib/inventoryMergeApi';
import type { InventoryMergeStatus } from '../../lib/inventoryMergeApi';
type View = 'timeline' | 'findings' | 'documents';
type ExtendedTimelineEvent = Omit<AssetDossierTimelineEvent, 'kind'> & {
kind: AssetDossierTimelineEvent['kind'] | 'MERGE';
};
type ExtendedDossier = Omit<AssetDossier, 'timeline'> & {
timeline: ExtendedTimelineEvent[];
requestedAsset?: { id: string; code: string; name: string };
merge?: {
requestedWasMerged: boolean;
canonicalAssetId: string;
aliases: Array<{ id: string; code: string; name: string; reason: string; mergedAt: string; depth: number }>;
historyPolicy: string;
};
};
function findingStatusLabel(status: string) {
if (status === 'OPEN') return 'Abierto';
@@ -28,48 +48,118 @@ function actStatusLabel(status: string) {
return labels[status] ?? status;
}
function timelineLabel(event: AssetDossierTimelineEvent) {
const labels: Record<AssetDossierTimelineEvent['kind'], string> = {
function timelineLabel(event: ExtendedTimelineEvent) {
const labels: Record<string, string> = {
INVENTORY_CHANGE: 'Inventario', INSPECTION: 'Inspección', ACT: 'Acta', FINDING: 'Hallazgo', FINDING_CLOSED: 'Cierre',
COMMUNICATION: 'Seguimiento', PHOTO: 'Fotografía', DOCUMENT: 'Documento', REPORT: 'Informe', SOURCE_DOCUMENT: 'Documento fuente', VERIFICATION: 'Verificación',
COMMUNICATION: 'Seguimiento', PHOTO: 'Fotografía', DOCUMENT: 'Documento', REPORT: 'Informe', SOURCE_DOCUMENT: 'Documento fuente', VERIFICATION: 'Verificación', MERGE: 'Fusión',
};
return labels[event.kind];
return labels[event.kind] ?? event.kind;
}
function timelineIcon(event: AssetDossierTimelineEvent) {
function timelineIcon(event: ExtendedTimelineEvent) {
if (event.kind === 'FINDING' || event.kind === 'FINDING_CLOSED') return 'alert' as const;
if (event.kind === 'INSPECTION' || event.kind === 'VERIFICATION') return 'calendar' as const;
if (event.kind === 'INVENTORY_CHANGE') return 'history' as const;
if (event.kind === 'INVENTORY_CHANGE' || event.kind === 'MERGE') return 'history' as const;
return 'clipboard' as const;
}
export function AssetDossierPanel({ assetId }: { assetId: string }) {
const [dossier, setDossier] = useState<AssetDossier | null>(null);
const { hasPermission } = useAuth();
const [dossier, setDossier] = useState<ExtendedDossier | null>(null);
const [asset, setAsset] = useState<AssetDetail | null>(null);
const [mergeStatus, setMergeStatus] = useState<InventoryMergeStatus | null>(null);
const [view, setView] = useState<View>('timeline');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showMerge, setShowMerge] = useState(false);
const [mergeSearch, setMergeSearch] = useState('');
const [candidates, setCandidates] = useState<AssetListItem[]>([]);
const [canonicalId, setCanonicalId] = useState('');
const [mergeReason, setMergeReason] = useState('');
const [merging, setMerging] = useState(false);
useEffect(() => {
const load = () => {
setLoading(true);
setError('');
getAssetDossier(assetId)
.then(setDossier)
Promise.all([
getAssetDossier(assetId),
getAsset(assetId),
getInventoryMergeStatus(assetId),
])
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier);
setAsset(loadedAsset);
setMergeStatus(loadedMerge);
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [assetId]);
};
useEffect(() => { load(); }, [assetId]);
const mergeable = Boolean(
asset &&
['instalacion', 'subinstalacion'].includes(asset.type.code.toLowerCase()) &&
!mergeStatus?.isMerged &&
hasPermission('assets.update'),
);
useEffect(() => {
if (!showMerge || !mergeable || !asset) {
setCandidates([]);
return;
}
const timer = window.setTimeout(() => {
searchInventoryMergeCandidates(asset, mergeSearch)
.then(setCandidates)
.catch((requestError) => setError(errorMessage(requestError)));
}, 220);
return () => window.clearTimeout(timer);
}, [showMerge, mergeable, asset, mergeSearch]);
const pendingVerification = useMemo(() => dossier?.findings.filter((finding) => finding.status === 'OPEN' && finding.companyResponseReceivedOn && finding.nextControlOn).length ?? 0, [dossier]);
const executeMerge = async () => {
if (!asset || !canonicalId || mergeReason.trim().length < 8) return;
const target = candidates.find((candidate) => candidate.id === canonicalId);
if (!target) return;
const accepted = window.confirm(
`¿Fusionar ${asset.code} · ${asset.name} en ${target.code} · ${target.name}?\n\n` +
'El registro actual quedará inactivo y trazable. Las referencias históricas NO se reescriben. Esta fusión queda registrada como evento cronológico permanente.',
);
if (!accepted) return;
setMerging(true);
setError('');
try {
const result = await mergeInventoryAsset(asset.id, canonicalId, mergeReason.trim());
window.location.assign(`/inventarios/${result.canonical.id}?tab=dossier`);
} catch (requestError) {
setError(errorMessage(requestError));
setMerging(false);
}
};
if (loading) return <div className="panel"><LoadingBlock label="Armando expediente técnico…" /></div>;
if (!dossier) return <Alert>{error || 'No se pudo cargar el expediente.'}</Alert>;
return <div className="asset-dossier-stack">
{error && <Alert>{error}</Alert>}
{mergeStatus?.isMerged && <article className="panel">
<div className="temporal-notice">
<Icon name="history" />
<p><strong>Registro fusionado.</strong> {mergeStatus.requested.code} · {mergeStatus.requested.name} conserva su historia, pero el registro vigente es <Link to={`/inventarios/${mergeStatus.canonical.id}?tab=dossier`}><strong>{mergeStatus.canonical.code} · {mergeStatus.canonical.name}</strong></Link>.</p>
</div>
</article>}
<article className="panel dossier-overview">
<div className="panel-heading">
<div><span className="eyebrow">EXPEDIENTE TÉCNICO</span><h2>{dossier.asset.name}</h2><p className="section-copy">{dossier.asset.code}{dossier.asset.commonName ? ` · Nombre habitual: ${dossier.asset.commonName}` : ''} · Inspecciones, actas, hallazgos, respuestas, evidencias, documentos y cambios reunidos en un solo lugar.</p></div>
</div>
{dossier.merge && dossier.merge.aliases.length > 0 && <div className="temporal-notice">
<Icon name="history" />
<p><strong>Expediente cronológico unificado:</strong> incluye {dossier.merge.aliases.length} registro{dossier.merge.aliases.length === 1 ? '' : 's'} fusionado{dossier.merge.aliases.length === 1 ? '' : 's'}. Cada evento mantiene visible la identidad de Inventario que tenía al momento de ocurrir.</p>
</div>}
<div className="dossier-metrics">
<div><small>Inspecciones</small><strong>{dossier.counters.inspections}</strong></div>
<div><small>Actas</small><strong>{dossier.counters.acts}</strong></div>
@@ -82,6 +172,20 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
{pendingVerification > 0 && <div className="temporal-notice"><Icon name="calendar" /><p><strong>{pendingVerification} hallazgo{pendingVerification === 1 ? '' : 's'} con verificación programada.</strong> Las fechas operativas pueden utilizarse para planificar próximos controles.</p></div>}
</article>
{mergeable && <article className="panel">
<div className="panel-heading">
<div><span className="eyebrow">CONCILIACIÓN</span><h2>¿Este registro está duplicado?</h2><p className="section-copy">Fusioná únicamente cuando ambas fichas representen la misma {asset?.type.name.toLowerCase()}. La historia anterior se conserva.</p></div>
<button type="button" className="button secondary" onClick={() => setShowMerge((current) => !current)}><Icon name="history" />{showMerge ? 'Cancelar fusión' : 'Fusionar duplicado'}</button>
</div>
{showMerge && <div className="form-section">
<label className="field"><span>Buscar registro canónico</span><input value={mergeSearch} onChange={(event) => { setMergeSearch(event.target.value); setCanonicalId(''); }} placeholder="Nombre o código…" /></label>
<label className="field"><span>Conservar como registro oficial</span><select value={canonicalId} onChange={(event) => setCanonicalId(event.target.value)}><option value="">Seleccionar registro</option>{candidates.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.code} · {candidate.name}</option>)}</select><small>Se muestran registros activos del mismo tipo, padre, Área y Operadora.</small></label>
<label className="field"><span>Motivo de la fusión <em>obligatorio</em></span><textarea rows={3} minLength={8} maxLength={2000} value={mergeReason} onChange={(event) => setMergeReason(event.target.value)} placeholder="Ej.: alta de campo duplicada; se confirmó que corresponde a la instalación existente…" /></label>
<Alert type="info">La fusión no cambia Actas ni Hallazgos históricos. Los hijos actuales se reubican al canónico con una nueva versión y el registro duplicado queda inactivo, nunca eliminado.</Alert>
<div className="form-actions"><button type="button" className="button primary" disabled={!canonicalId || mergeReason.trim().length < 8 || merging} onClick={executeMerge}>{merging ? 'Fusionando…' : 'Confirmar fusión cronológica'}</button></div>
</div>}
</article>}
<nav className="dossier-subtabs" aria-label="Vistas del expediente">
<button type="button" className={view === 'timeline' ? 'active' : ''} onClick={() => setView('timeline')}>Cronología</button>
<button type="button" className={view === 'findings' ? 'active' : ''} onClick={() => setView('findings')}>Inspecciones y hallazgos</button>
@@ -91,14 +195,18 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
{view === 'timeline' && <article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CRONOLOGÍA</span><h2>Línea de tiempo</h2><p className="section-copy">Cada evento conserva su origen y enlaza con el registro que lo generó cuando corresponde.</p></div><span className="count-pill">{dossier.timeline.length}</span></div>
{dossier.timeline.length === 0 ? <EmptyState title="Sin actividad histórica" text="Los eventos aparecerán a medida que el elemento sea inspeccionado o actualizado." /> : <div className="dossier-timeline">
{dossier.timeline.map((event) => <div className="dossier-timeline-item" key={event.id}>
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
<div className="dossier-timeline-body">
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
{event.description && <p>{event.description}</p>}
</div>
</div>)}
{dossier.timeline.map((event) => {
const historicalInventory = (event.meta?.historicalInventory ?? null) as { id?: string; code?: string; name?: string; isCanonical?: boolean } | null;
return <div className="dossier-timeline-item" key={event.id}>
<span className="dossier-timeline-icon"><Icon name={timelineIcon(event)} size={16} /></span>
<div className="dossier-timeline-body">
<div className="dossier-timeline-head"><span>{timelineLabel(event)}</span><time>{formatDate(event.occurredAt)}</time></div>
{event.href ? <Link to={event.href}><strong>{event.title}</strong></Link> : <strong>{event.title}</strong>}
{event.description && <p>{event.description}</p>}
{historicalInventory && !historicalInventory.isCanonical && <small className="block-muted">Registrado originalmente en {historicalInventory.code} · {historicalInventory.name}</small>}
</div>
</div>;
})}
</div>}
</article>}
@@ -1,10 +1,11 @@
import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { listFindingCatalogProposals, reviewFindingCatalogProposal } from '../../lib/api';
import type { FindingAdminCatalog, FindingCatalogProposal } from '../../lib/api';
import { mergeFindingCatalogItem } from '../../lib/findingCatalogMerge';
export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdminCatalog }) {
const [items, setItems] = useState<FindingCatalogProposal[]>([]);
@@ -14,6 +15,20 @@ export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdmi
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [mergeSourceId, setMergeSourceId] = useState('');
const [mergeCanonicalId, setMergeCanonicalId] = useState('');
const [mergeReason, setMergeReason] = useState('');
const [mergeBusy, setMergeBusy] = useState(false);
const [mergeError, setMergeError] = useState('');
const [mergeSuccess, setMergeSuccess] = useState('');
const activeCatalogItems = useMemo(
() => catalog.items.filter((item) => item.isActive && item.categoryActive),
[catalog.items],
);
const mergeSource = catalog.items.find((item) => item.id === mergeSourceId) ?? null;
const mergeCanonical = catalog.items.find((item) => item.id === mergeCanonicalId) ?? null;
const load = () => listFindingCatalogProposals({ status: 'PENDING' }).then(setItems);
useEffect(() => { load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); }, []);
@@ -38,11 +53,86 @@ export function FindingCatalogProposalsPanel({ catalog }: { catalog: FindingAdmi
}
};
if (loading) return <div className="panel"><LoadingBlock label="Cargando propuestas OTROS…" /></div>;
const mergeCatalog = async () => {
setMergeError(''); setMergeSuccess('');
if (!mergeSourceId || !mergeCanonicalId) {
setMergeError('Seleccioná el registro duplicado y el registro canónico.');
return;
}
if (mergeSourceId === mergeCanonicalId) {
setMergeError('El duplicado y el canónico deben ser registros distintos.');
return;
}
if (mergeReason.trim().length < 8) {
setMergeError('Indicá un motivo de al menos 8 caracteres para que la conciliación quede auditada.');
return;
}
const sourceLabel = mergeSource ? `${mergeSource.title} (${mergeSource.code})` : 'el duplicado';
const canonicalLabel = mergeCanonical ? `${mergeCanonical.title} (${mergeCanonical.code})` : 'el canónico';
if (!window.confirm(`¿Fusionar ${sourceLabel} dentro de ${canonicalLabel}?\n\nLos Hallazgos históricos conservarán su referencia original. El duplicado dejará de ofrecerse para inspecciones futuras.`)) return;
return <section className="panel finding-proposals-panel">
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{catalog.items.filter((item) => item.isActive && item.categoryActive).map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>;
setMergeBusy(true);
try {
await mergeFindingCatalogItem(mergeSourceId, {
canonicalItemId: mergeCanonicalId,
reason: mergeReason.trim(),
});
setMergeSuccess('Fusión registrada. El historial emitido quedó intacto y la aplicabilidad futura pasó al registro canónico.');
setMergeSourceId('');
setMergeCanonicalId('');
setMergeReason('');
window.setTimeout(() => window.location.reload(), 900);
} catch (requestError) {
setMergeError(errorMessage(requestError));
} finally {
setMergeBusy(false);
}
};
return <>
<section className="panel finding-proposals-panel">
<div className="panel-heading"><div><span className="eyebrow">PROPUESTAS DESDE CAMPO</span><h2>Hallazgos cargados como OTROS</h2><p className="section-copy">El hallazgo original queda intacto. Esta bandeja sirve para decidir si conviene sumar esa opción al catálogo para inspecciones futuras.</p></div><span className="count-pill">{items.length} pendientes</span></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{loading ? <LoadingBlock label="Cargando propuestas OTROS…" /> : items.length === 0 ? <EmptyState title="Sin propuestas pendientes" text="Los nuevos hallazgos cargados como OTROS aparecerán acá." /> : <div className="finding-proposal-list">{items.map((proposal) => <article className="finding-proposal-card" key={proposal.id}><div><span className="eyebrow">{proposal.assetTypeName} · {proposal.assetCode}</span><h3>{proposal.proposedTitle}</h3><p>{proposal.description}</p><small><Link to={`/hallazgos/${proposal.findingId}`}>{proposal.findingCode}</Link> · {proposal.assetName}{proposal.proposedSeverity ? ` · gravedad ${proposal.proposedSeverity}/10` : ''}</small></div><div className="finding-proposal-actions"><label className="field"><span>Coincide con</span><SearchableSelect value={matches[proposal.id] ?? ''} onChange={(event) => setMatches((current) => ({ ...current, [proposal.id]: event.target.value }))}><option value="">Seleccionar tipo existente</option>{activeCatalogItems.map((item) => <option value={item.id} key={item.id}>{item.title} · {item.code}</option>)}</SearchableSelect></label><div className="form-actions"><button type="button" className="button secondary" disabled={busyId === proposal.id} onClick={() => review(proposal, 'REJECT')}>Descartar</button><button type="button" className="button primary" disabled={busyId === proposal.id || !matches[proposal.id]} onClick={() => review(proposal, 'MATCH')}><Icon name="check" />Vincular</button></div><small>Si todavía no existe una opción adecuada, creala en el catálogo y luego vinculá esta propuesta.</small></div></article>)}</div>}
</section>
<section className="panel finding-proposals-panel">
<div className="panel-heading">
<div>
<span className="eyebrow">CONCILIACIÓN DE CATÁLOGO</span>
<h2>Fusionar Hallazgos duplicados</h2>
<p className="section-copy">Usá esta operación cuando dos opciones del catálogo representan el mismo control. El duplicado se desactiva para el futuro, pero los Hallazgos y Actas ya emitidos conservan exactamente la referencia con la que nacieron.</p>
</div>
<span className="count-pill">append-only</span>
</div>
{mergeError && <Alert>{mergeError}</Alert>}{mergeSuccess && <Alert type="success">{mergeSuccess}</Alert>}
<div className="form-grid">
<label className="field">
<span>Duplicado a retirar</span>
<SearchableSelect value={mergeSourceId} onChange={(event) => { setMergeSourceId(event.target.value); if (event.target.value === mergeCanonicalId) setMergeCanonicalId(''); }}>
<option value="">Seleccionar duplicado</option>
{activeCatalogItems.map((item) => <option key={item.id} value={item.id}>{item.categoryName} · {item.title} · {item.code}</option>)}
</SearchableSelect>
{mergeSource && <small>Uso histórico: {mergeSource.usageCount} hallazgo{mergeSource.usageCount === 1 ? '' : 's'} · revisión {mergeSource.revision}</small>}
</label>
<label className="field">
<span>Registro canónico que continuará vigente</span>
<SearchableSelect value={mergeCanonicalId} onChange={(event) => setMergeCanonicalId(event.target.value)} disabled={!mergeSourceId}>
<option value="">Seleccionar canónico</option>
{activeCatalogItems.filter((item) => item.id !== mergeSourceId).map((item) => <option key={item.id} value={item.id}>{item.categoryName} · {item.title} · {item.code}</option>)}
</SearchableSelect>
{mergeCanonical && <small>Uso histórico: {mergeCanonical.usageCount} hallazgo{mergeCanonical.usageCount === 1 ? '' : 's'} · revisión {mergeCanonical.revision}</small>}
</label>
</div>
<label className="field">
<span>Motivo de la fusión</span>
<textarea rows={3} maxLength={2000} value={mergeReason} onChange={(event) => setMergeReason(event.target.value)} placeholder="Ej.: duplicado detectado al normalizar el catálogo del Apéndice 26…" />
<small>Obligatorio. Queda guardado en el evento de conciliación y no puede modificarse después.</small>
</label>
<div className="catalog-policy-note"><Icon name="shield" /><span><strong>Política histórica.</strong> Esta acción no reescribe inspection_findings ni propuestas ya resueltas. Sólo consolida la aplicabilidad para inspecciones futuras.</span></div>
<div className="form-actions">
<button type="button" className="button primary" disabled={mergeBusy || !mergeSourceId || !mergeCanonicalId || mergeReason.trim().length < 8} onClick={mergeCatalog}><Icon name="check" />{mergeBusy ? 'Fusionando…' : 'Fusionar y conservar historial'}</button>
</div>
</section>
</>;
}
-1
View File
@@ -33,7 +33,6 @@ const master: NavItem[] = [
{ to: '/inventarios', label: 'Inventarios', icon: 'layers', permission: 'assets.read' },
{ to: '/mapa', label: 'Mapa', icon: 'map', permission: 'assets.read' },
{ to: '/importaciones', label: 'Importaciones', icon: 'upload', permission: 'asset_imports.read' },
{ to: '/relevamiento', label: 'Relevamientos', icon: 'clipboard', permission: 'surveys.read' },
];
const administration: NavItem[] = [
+62
View File
@@ -0,0 +1,62 @@
import { apiRequest } from './api';
export interface FindingCatalogMergeItem {
id: string;
categoryId: string;
categoryCode: string;
categoryName: string;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
importNote: string | null;
suggestedSeverity: number | null;
revision: number;
isActive: boolean;
}
export interface FindingCatalogMergeAlias {
id: string;
code: string;
title: string;
reason: string;
mergedAt: string;
depth: number;
}
export interface FindingCatalogMergeStatus {
requested: FindingCatalogMergeItem;
isMerged: boolean;
canonical: FindingCatalogMergeItem;
aliases: FindingCatalogMergeAlias[];
}
export interface FindingCatalogMergeResult {
merge: {
id: string;
sourceItemId: string;
canonicalItemId: string;
reason: string;
mergedAt: string;
mergedBy: string;
requestId: string;
};
source: FindingCatalogMergeItem;
canonical: FindingCatalogMergeItem;
historyPolicy: 'EMITTED_FINDINGS_PRESERVED';
}
export function getFindingCatalogMergeStatus(itemId: string) {
return apiRequest<FindingCatalogMergeStatus>(`/finding-catalog/items/${itemId}/merge-status`);
}
export function mergeFindingCatalogItem(
sourceItemId: string,
input: { canonicalItemId: string; reason: string },
) {
return apiRequest<FindingCatalogMergeResult>(`/finding-catalog/items/${sourceItemId}/merge`, {
method: 'POST',
body: JSON.stringify(input),
});
}
+86
View File
@@ -0,0 +1,86 @@
import { apiRequest, listAssets } from './api';
import type { AssetDetail, AssetListItem } from './api';
export interface InventoryMergeAlias {
id: string;
code: string;
name: string;
reason: string;
mergedAt: string;
depth: number;
}
export interface InventoryMergeStatus {
requested: {
id: string;
code: string;
name: string;
typeCode: string;
};
isMerged: boolean;
canonical: {
id: string;
code: string;
name: string;
typeCode: string;
};
chain: Array<{
id: string;
sourceAssetId: string;
canonicalAssetId: string;
reason: string;
mergedAt: string;
mergedBy: string | null;
source: string;
}>;
aliases: InventoryMergeAlias[];
}
export interface InventoryMergeResult {
merge: {
id: string;
sourceAssetId: string;
canonicalAssetId: string;
reason: string;
mergedAt: string;
};
source: { id: string; code: string; name: string };
canonical: { id: string; code: string; name: string };
sourceVersionNumber: number;
reparentedChildIds: string[];
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED';
}
export function getInventoryMergeStatus(assetId: string) {
return apiRequest<InventoryMergeStatus>(`/assets/${assetId}/merge-status`);
}
export function mergeInventoryAsset(
sourceAssetId: string,
canonicalAssetId: string,
reason: string,
) {
return apiRequest<InventoryMergeResult>(`/assets/${sourceAssetId}/merge`, {
method: 'POST',
body: JSON.stringify({ canonicalAssetId, reason }),
});
}
export async function searchInventoryMergeCandidates(
source: AssetDetail,
search = '',
): Promise<AssetListItem[]> {
if (!source.parent?.id || !source.operationalArea?.id || !source.operatorCompany?.id) return [];
const page = await listAssets({
page: 1,
pageSize: 80,
search: search.trim() || undefined,
typeId: source.type.id,
parentId: source.parent.id,
operationalAreaId: source.operationalArea.id,
operatorCompanyId: source.operatorCompany.id,
});
return page.data.filter((candidate) =>
candidate.id !== source.id && candidate.informationStatus !== 'INACTIVE',
);
}
+121
View File
@@ -0,0 +1,121 @@
import { apiRequest } from './api';
export type InventoryStructureKind = 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export interface InventoryFamily {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
}
export interface InventoryStructureLevel {
kind: InventoryStructureKind;
label: string;
type: { id: string; code: string; name: string };
parentKind: InventoryStructureKind | null;
requiresFamily: boolean;
}
export interface InventoryStructureOptions {
levels: InventoryStructureLevel[];
installationFamilies: InventoryFamily[];
subinstallationFamilies: InventoryFamily[];
}
export interface InventoryStructureParent {
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 {
id: string;
code: string;
sourceNumber: number;
title: string;
legalBasis: string | null;
glossary: string | null;
suggestedSeverity: number | null;
categoryId: string;
categoryCode: string;
categoryName: string;
}
export interface InventoryFamilyFindings {
family: {
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
};
items: InventoryFamilyFinding[];
count: number;
}
export interface CreatedInventoryStructure {
id: string;
code: string;
name: 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() {
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 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),
});
}
+47
View File
@@ -0,0 +1,47 @@
import { apiRequest } from './api';
import type { AdministrativeUser } from './api';
export interface AdministrativeUserProfile extends AdministrativeUser {
dni: string | null;
phone: string | null;
jobTitle: string | null;
employeeNumber: string | null;
}
export interface UserProfileInput {
username?: string;
email?: string | null;
dni?: string | null;
phone?: string | null;
jobTitle?: string | null;
employeeNumber?: string | null;
firstName?: string;
lastName?: string;
}
export interface CreateUserProfileInput extends UserProfileInput {
username: string;
firstName: string;
lastName: string;
password: string;
mustChangePassword: boolean;
roleIds: string[];
}
export function getUserProfile(id: string) {
return apiRequest<AdministrativeUserProfile>(`/users/${id}`);
}
export function createUserProfile(input: CreateUserProfileInput) {
return apiRequest<AdministrativeUserProfile>('/users', {
method: 'POST',
body: JSON.stringify(input),
});
}
export function updateUserProfile(id: string, input: UserProfileInput) {
return apiRequest<AdministrativeUserProfile>(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
}
+139 -11
View File
@@ -1,16 +1,144 @@
import { FormEvent, useEffect, useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getDocumentDeliverySettings, listDocumentDeliveries, retryDocumentDelivery, retryPendingDocumentDeliveries, updateDocumentDeliverySettings } from '../lib/api';
import {
getDocumentDeliverySettings,
listDocumentDeliveries,
retryDocumentDelivery,
retryPendingDocumentDeliveries,
updateDocumentDeliverySettings,
} from '../lib/api';
import type { DocumentDeliveryItem, DocumentDeliverySettings } from '../lib/api';
import { useAuth } from '../auth/AuthContext';
const statusLabel:Record<DocumentDeliveryItem['status'],string>={PENDING:'Pendiente',WAITING_RECIPIENT:'Falta destinatario',WAITING_TRANSPORT:'SMTP sin configurar',WAITING_ARTIFACT:'Documento pendiente',SENT:'Enviado',FAILED:'Error'};
export function DocumentDeliveryPage(){ const {hasPermission}=useAuth(); const canManage=hasPermission('document_delivery.manage'); const [settings,setSettings]=useState<DocumentDeliverySettings|null>(null); const [items,setItems]=useState<DocumentDeliveryItem[]>([]); const [officeEmail,setOfficeEmail]=useState(''); const [directorEmail,setDirectorEmail]=useState(''); const [loading,setLoading]=useState(true); const [saving,setSaving]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState('');
const load=async()=>{const [s,o]=await Promise.all([getDocumentDeliverySettings(),listDocumentDeliveries()]);setSettings(s);setOfficeEmail(s.officeEmail??'');setDirectorEmail(s.directorEmail??'');setItems(o.data);}; useEffect(()=>{load().catch(e=>setError(errorMessage(e))).finally(()=>setLoading(false));},[]);
const save=async(e:FormEvent)=>{e.preventDefault();setSaving(true);setError('');setSuccess('');try{const s=await updateDocumentDeliverySettings({officeEmail:officeEmail||null,directorEmail:directorEmail||null});setSettings(s);setSuccess('Destinatarios institucionales actualizados');}catch(err){setError(errorMessage(err));}finally{setSaving(false);}};
const retryAll=async()=>{setSaving(true);setError('');setSuccess('');try{const r=await retryPendingDocumentDeliveries();await load();setSuccess(`Se procesaron ${r.processed} entrega/s pendientes.`);}catch(err){setError(errorMessage(err));}finally{setSaving(false);}};
if(loading)return <LoadingBlock label="Cargando entrega documental…"/>; return <section className="narrow-section"><div className="page-heading"><div><span className="eyebrow">DOCUMENTOS</span><h1>Entrega documental</h1><p>Configurá los destinatarios institucionales y controlá el envío automático de Actas e Informes.</p></div>{canManage&&<button className="button secondary" onClick={retryAll} disabled={saving}><Icon name="history"/>Reintentar pendientes</button>}</div>{error&&<Alert>{error}</Alert>}{success&&<Alert type="success">{success}</Alert>}
<form className="panel form-panel" onSubmit={save}><div className="form-section"><div><h2>Destinatarios institucionales</h2><p className="section-copy">El email de cada empresa se configura dentro de su ficha de Inventario. Las credenciales SMTP permanecen fuera de la base de datos.</p></div><div className="form-grid"><label className="field"><span>Email de oficina</span><input type="email" value={officeEmail} onChange={e=>setOfficeEmail(e.target.value)} disabled={!canManage}/></label><label className="field"><span>Email del Director de Hidrocarburos</span><input type="email" value={directorEmail} onChange={e=>setDirectorEmail(e.target.value)} disabled={!canManage}/></label></div><div className="temporal-notice"><Icon name={settings?.smtpConfigured?'check':'alert'}/><p><strong>{settings?.smtpConfigured?'Servidor de correo configurado':'Servidor de correo pendiente'}</strong>{settings?.mailFrom?` · Remitente ${settings.mailFrom}`:' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'}</p></div>{canManage&&<div className="form-actions"><button className="button primary" disabled={saving}><Icon name="check"/>{saving?'Guardando…':'Guardar destinatarios'}</button></div>}</div></form>
<div className="panel"><div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2></div><span>{items.length}</span></div>{items.length===0?<div className="inline-empty">Todavía no existen entregas documentales.</div>:<div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th></th></tr></thead><tbody>{items.map(item=><tr key={item.id}><td><strong>{item.documentKind==='ACT_PDF'?item.actCode:item.reportCode}</strong><small className="block-muted">{item.documentKind==='ACT_PDF'?'Acta PDF':'Informe Word'}</small></td><td>{item.recipientAssetName??(item.recipientKind==='COMPANY'?'Empresa sin identificar':item.recipientKind==='OFFICE'?'Oficina':'Director')}<small className="block-muted">{item.recipientEmail??'Sin email configurado'}</small></td><td><span className={`status-badge ${item.status==='SENT'?'active':item.status==='FAILED'?'inactive':'pending'}`}>{statusLabel[item.status]}</span>{item.lastError&&<small className="block-muted">{item.lastError}</small>}</td><td>{item.attempts}</td><td>{canManage&&item.status!=='SENT'&&<button className="button text" type="button" disabled={saving} onClick={async()=>{setSaving(true);try{await retryDocumentDelivery(item.id);await load();}catch(err){setError(errorMessage(err));}finally{setSaving(false);}}}>Reintentar</button>}</td></tr>)}</tbody></table></div>}</div>
</section>; }
const statusLabel: Record<DocumentDeliveryItem['status'], string> = {
PENDING: 'Pendiente',
WAITING_RECIPIENT: 'Falta destinatario',
WAITING_TRANSPORT: 'SMTP sin configurar',
WAITING_ARTIFACT: 'Documento pendiente',
SENT: 'Enviado',
FAILED: 'Error',
};
type DeliveryWithInspector = DocumentDeliveryItem & {
recipientUserId?: string | null;
recipientUserName?: string | null;
};
function recipientLabel(item: DeliveryWithInspector): string {
if (item.recipientAssetName) return item.recipientAssetName;
if (item.recipientUserName) return item.recipientUserName;
const kind = String(item.recipientKind);
if (kind === 'COMPANY') return 'Empresa sin identificar';
if (kind === 'OFFICE') return 'Oficina';
if (kind === 'INSPECTOR') return 'Inspector responsable';
return 'Director';
}
export function DocumentDeliveryPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission('document_delivery.manage');
const [settings, setSettings] = useState<DocumentDeliverySettings | null>(null);
const [items, setItems] = useState<DocumentDeliveryItem[]>([]);
const [officeEmail, setOfficeEmail] = useState('');
const [directorEmail, setDirectorEmail] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const load = async () => {
const [currentSettings, outbox] = await Promise.all([
getDocumentDeliverySettings(),
listDocumentDeliveries(),
]);
setSettings(currentSettings);
setOfficeEmail(currentSettings.officeEmail ?? '');
setDirectorEmail(currentSettings.directorEmail ?? '');
setItems(outbox.data);
};
useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const save = async (event: FormEvent) => {
event.preventDefault();
setSaving(true);
setError('');
setSuccess('');
try {
const currentSettings = await updateDocumentDeliverySettings({
officeEmail: officeEmail || null,
directorEmail: directorEmail || null,
});
setSettings(currentSettings);
setSuccess('Destinatarios institucionales actualizados');
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const retryAll = async () => {
setSaving(true);
setError('');
setSuccess('');
try {
const result = await retryPendingDocumentDeliveries();
await load();
setSuccess(`Se procesaron ${result.processed} entrega/s pendientes.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <LoadingBlock label="Cargando entrega documental…" />;
return <section className="narrow-section">
<div className="page-heading">
<div>
<span className="eyebrow">DOCUMENTOS</span>
<h1>Entrega documental</h1>
<p>Configurá los destinatarios institucionales y controlá el envío automático de Actas e Informes.</p>
</div>
{canManage && <button className="button secondary" onClick={retryAll} disabled={saving}><Icon name="history" />Reintentar pendientes</button>}
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section">
<div>
<h2>Destinatarios institucionales</h2>
<p className="section-copy">El email de cada empresa se configura dentro de su ficha de Inventario. El Inspector responsable usa automáticamente el email declarado en su Usuario. Las credenciales SMTP permanecen fuera de la base de datos.</p>
</div>
<div className="form-grid">
<label className="field"><span>Email de oficina</span><input type="email" value={officeEmail} onChange={(event) => setOfficeEmail(event.target.value)} disabled={!canManage} /></label>
<label className="field"><span>Email del Director de Hidrocarburos</span><input type="email" value={directorEmail} onChange={(event) => setDirectorEmail(event.target.value)} disabled={!canManage} /></label>
</div>
<div className="temporal-notice"><Icon name={settings?.smtpConfigured ? 'check' : 'alert'} /><p><strong>{settings?.smtpConfigured ? 'Servidor de correo configurado' : 'Servidor de correo pendiente'}</strong>{settings?.mailFrom ? ` · Remitente ${settings.mailFrom}` : ' · Falta configurar SMTP_HOST y MAIL_FROM en el servidor.'}</p></div>
{canManage && <div className="form-actions"><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar destinatarios'}</button></div>}
</div>
</form>
<div className="panel">
<div className="panel-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h2>Últimas entregas</h2></div><span>{items.length}</span></div>
{items.length === 0
? <div className="inline-empty">Todavía no existen entregas documentales.</div>
: <div className="table-wrap"><table><thead><tr><th>Documento</th><th>Destino</th><th>Estado</th><th>Intentos</th><th></th></tr></thead><tbody>{items.map((baseItem) => {
const item = baseItem as DeliveryWithInspector;
return <tr key={item.id}>
<td><strong>{item.documentKind === 'ACT_PDF' ? item.actCode : item.reportCode}</strong><small className="block-muted">{item.documentKind === 'ACT_PDF' ? 'Acta PDF' : 'Informe Word'}</small></td>
<td>{recipientLabel(item)}<small className="block-muted">{item.recipientEmail ?? 'Sin email configurado'}</small></td>
<td><span className={`status-badge ${item.status === 'SENT' ? 'active' : item.status === 'FAILED' ? 'inactive' : 'pending'}`}>{statusLabel[item.status]}</span>{item.lastError && <small className="block-muted">{item.lastError}</small>}</td>
<td>{item.attempts}</td>
<td>{canManage && item.status !== 'SENT' && <button className="button text" type="button" disabled={saving} onClick={async () => { setSaving(true); try { await retryDocumentDelivery(item.id); await load(); } catch (requestError) { setError(errorMessage(requestError)); } finally { setSaving(false); } }}>Reintentar</button>}</td>
</tr>;
})}</tbody></table></div>}
</div>
</section>;
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getAsset } from '../lib/api';
import {
createInventoryStructure,
getInventoryFamilyFindings,
getInventoryStructureOptions,
listInventoryStructureParents,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyFindings,
InventoryStructureKind,
InventoryStructureOptions,
InventoryStructureParent,
} from '../lib/inventoryStructureApi';
const KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
{ kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 },
{ kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 },
{ kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', help: 'Debe pertenecer a una Instalación.', step: 4 },
];
const childKindByParentType: Record<string, InventoryStructureKind | undefined> = {
area: 'YACIMIENTO',
yacimiento: 'INSTALACION',
instalacion: 'SUBINSTALACION',
};
function kindLabel(kind: InventoryStructureKind): string {
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
}
export function InventoryCreatePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const contextParentId = searchParams.get('parentId');
const [options, setOptions] = useState<InventoryStructureOptions | null>(null);
const [kind, setKind] = useState<InventoryStructureKind>('AREA');
const [parents, setParents] = useState<InventoryStructureParent[]>([]);
const [parentSearch, setParentSearch] = useState('');
const [parentId, setParentId] = useState('');
const [familyId, setFamilyId] = useState('');
const [familyFindings, setFamilyFindings] = useState<InventoryFamilyFindings | null>(null);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [commonName, setCommonName] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
getInventoryStructureOptions()
.then(async (loaded) => {
setOptions(loaded);
if (contextParentId) {
const parent = await getAsset(contextParentId);
const inferred = childKindByParentType[parent.type.code.toLowerCase()];
if (inferred) {
setKind(inferred);
setParentId(parent.id);
}
}
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [contextParentId]);
useEffect(() => {
if (kind === 'AREA') {
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)));
}, 180);
return () => window.clearTimeout(timer);
}, [kind, parentSearch, contextParentId]);
const selectedParent = parents.find((item) => item.id === parentId) ?? null;
const families = useMemo(() => {
if (!options) return [] as InventoryFamily[];
if (kind === 'INSTALACION') return options.installationFamilies;
if (kind === 'SUBINSTALACION') {
const parentFamilyId = selectedParent?.inventoryFamily?.id;
return parentFamilyId
? options.subinstallationFamilies.filter((item) => item.parentFamilyId === parentFamilyId)
: [];
}
return [];
}, [options, kind, selectedParent]);
const selectedFamily = families.find((item) => item.id === familyId) ?? null;
useEffect(() => {
if (!familyId) {
setFamilyFindings(null);
return;
}
getInventoryFamilyFindings(familyId)
.then(setFamilyFindings)
.catch((requestError) => setError(errorMessage(requestError)));
}, [familyId]);
useEffect(() => {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') setFamilyId('');
if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId('');
}, [kind, familyId, families]);
const chooseKind = (next: InventoryStructureKind) => {
setKind(next);
setParentId('');
setParentSearch('');
setFamilyId('');
setFamilyFindings(null);
setError('');
};
const save = async (event: FormEvent) => {
event.preventDefault();
const requiresParent = kind !== 'AREA';
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
if (requiresParent && !parentId) {
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 {
const created = await createInventoryStructure({
kind,
code: code.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 });
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
if (loading) return <LoadingBlock label="Preparando alta de Inventario…" />;
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">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
<Link to="/inventarios">Inventarios</Link><span></span><strong>Nuevo registro</strong>
</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>}
<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}>
{kind !== 'AREA' && <div className="form-section">
<div><h2>2. Ubicación en la estructura</h2><p className="section-copy">Primero elegí el {parentLabel} al que pertenece este registro.</p></div>
<label className="field">
<span>Buscar {parentLabel.toLowerCase()}</span>
<input value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} placeholder={`Buscar por nombre o código de ${parentLabel.toLowerCase()}`} />
</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>
</section>;
}
+41 -7
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { createUser, listRoles } from '../lib/api';
import { listRoles } from '../lib/api';
import type { AdministrativeRole } from '../lib/api';
import { createUserProfile } from '../lib/userProfileApi';
export function NewUserPage() {
const navigate = useNavigate();
@@ -18,17 +19,30 @@ export function NewUserPage() {
listRoles().then(setRoles).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const inspectorSelected = useMemo(
() => roles.some((role) => role.code === 'inspector' && roleIds.includes(role.id)),
[roles, roleIds],
);
const toggleRole = (id: string) => setRoleIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError('');
setSubmitting(true);
const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) {
setError('El email es obligatorio para usuarios con rol Inspector porque allí recibirán la documentación de sus inspecciones.');
return;
}
setSubmitting(true);
try {
const created = await createUser({
const created = await createUserProfile({
username: String(data.get('username')),
email: String(data.get('email') ?? '') || undefined,
email: email || null,
dni: String(data.get('dni') ?? '').trim() || null,
phone: String(data.get('phone') ?? '').trim() || null,
jobTitle: String(data.get('jobTitle') ?? '').trim() || null,
employeeNumber: String(data.get('employeeNumber') ?? '').trim() || null,
firstName: String(data.get('firstName')),
lastName: String(data.get('lastName')),
password: String(data.get('password')),
@@ -45,10 +59,30 @@ export function NewUserPage() {
return <section className="narrow-section">
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>Nuevo usuario</strong></div>
<div className="page-heading"><div><span className="eyebrow">NUEVO ACCESO</span><h1>Crear usuario</h1><p>La contraseña inicial puede obligarse a cambiar en el primer ingreso.</p></div></div>
<div className="page-heading"><div><span className="eyebrow">NUEVO ACCESO</span><h1>Crear usuario</h1><p>Además del acceso, registrá los datos personales que identifican al agente y permiten las comunicaciones oficiales.</p></div></div>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock /> : <form className="panel form-panel" onSubmit={submit}>
<div className="form-section"><h2>Datos personales</h2><div className="form-grid"><label className="field"><span>Nombre</span><input name="firstName" required maxLength={120} /></label><label className="field"><span>Apellido</span><input name="lastName" required maxLength={120} /></label><label className="field"><span>Usuario</span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label><label className="field"><span>Email <em>opcional</em></span><input name="email" type="email" maxLength={320} /></label></div></div>
<div className="form-section">
<div><h2>Datos personales</h2><p className="section-copy">Nombre y apellido identifican al agente. DNI, contacto y función quedan asociados a su historial operativo.</p></div>
<div className="form-grid">
<label className="field"><span>Nombre <em>obligatorio</em></span><input name="firstName" required maxLength={120} /></label>
<label className="field"><span>Apellido <em>obligatorio</em></span><input name="lastName" required maxLength={120} /></label>
<label className="field"><span>DNI</span><input name="dni" inputMode="numeric" maxLength={15} placeholder="Sin puntos" /></label>
<label className="field"><span>Teléfono</span><input name="phone" type="tel" maxLength={40} placeholder="Ej.: +54 261 ..." /></label>
<label className="field"><span>Cargo / función</span><input name="jobTitle" maxLength={160} placeholder="Ej.: Inspector de Hidrocarburos" /></label>
<label className="field"><span>Legajo / matrícula</span><input name="employeeNumber" maxLength={80} /></label>
</div>
</div>
<div className="form-section">
<div><h2>Acceso y contacto</h2><p className="section-copy">El email del Inspector se utiliza también como destinatario de la documentación al cerrar la inspección.</p></div>
<div className="form-grid">
<label className="field"><span>Usuario <em>obligatorio</em></span><input name="username" required minLength={3} maxLength={80} pattern="[a-zA-Z][a-zA-Z0-9._-]+" /></label>
<label className="field"><span>Email {inspectorSelected ? <em>obligatorio para Inspector</em> : <em>recomendado</em>}</span><input name="email" type="email" maxLength={320} required={inspectorSelected} /></label>
</div>
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Inspector:</strong> este email recibirá copia de las Actas/Informe correspondientes al cierre de la inspección.</p></div>}
</div>
<div className="form-section"><h2>Seguridad</h2><label className="field"><span>Contraseña temporal</span><input name="password" type="password" minLength={12} maxLength={128} required autoComplete="new-password" /><small>Mínimo 12 caracteres.</small></label><label className="check-row"><input name="mustChangePassword" type="checkbox" defaultChecked /><span><strong>Exigir cambio de contraseña</strong><small>El usuario no podrá acceder a otros módulos hasta actualizarla.</small></span></label></div>
<div className="form-section"><h2>Roles</h2><div className="choice-grid">{roles.map((role) => <label className={`choice-card ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div></div>
<div className="form-actions"><Link className="button secondary" to="/admin/users">Cancelar</Link><button className="button primary" disabled={submitting}>{submitting ? 'Creando…' : 'Crear usuario'}</button></div>
+54 -20
View File
@@ -1,25 +1,28 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, useLocation, useParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
getUser,
listRoles,
replaceUserRoles,
resetUserPassword,
updateUser,
updateUserStatus,
} from '../lib/api';
import type { AdministrativeRole, AdministrativeUser } from '../lib/api';
import type { AdministrativeRole } from '../lib/api';
import {
getUserProfile,
updateUserProfile,
} from '../lib/userProfileApi';
import type { AdministrativeUserProfile } from '../lib/userProfileApi';
import { formatDate, initials } from '../lib/format';
export function UserDetailPage() {
const { id = '' } = useParams();
const location = useLocation();
const { user: currentUser, hasPermission } = useAuth();
const [user, setUser] = useState<AdministrativeUser | null>(null);
const [user, setUser] = useState<AdministrativeUserProfile | null>(null);
const [roles, setRoles] = useState<AdministrativeRole[]>([]);
const [roleIds, setRoleIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
@@ -33,7 +36,7 @@ export function UserDetailPage() {
const load = async () => {
setLoading(true);
try {
const [loadedUser, loadedRoles] = await Promise.all([getUser(id), listRoles()]);
const [loadedUser, loadedRoles] = await Promise.all([getUserProfile(id), listRoles()]);
setUser(loadedUser);
setRoles(loadedRoles);
setRoleIds(loadedUser.roles.map((role) => role.id));
@@ -46,27 +49,45 @@ export function UserDetailPage() {
useEffect(() => { void load(); }, [id]);
const inspectorRoleId = useMemo(() => roles.find((role) => role.code === 'inspector')?.id ?? null, [roles]);
const inspectorSelected = inspectorRoleId ? roleIds.includes(inspectorRoleId) : false;
const saveProfile = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(''); setSuccess(''); setSaving('profile');
setError(''); setSuccess('');
const data = new FormData(event.currentTarget);
const email = String(data.get('email') ?? '').trim();
if (inspectorSelected && !email) {
setError('El email es obligatorio para un Inspector porque allí recibe la documentación de sus inspecciones.');
return;
}
setSaving('profile');
try {
const updated = await updateUser(id, {
const updated = await updateUserProfile(id, {
firstName: String(data.get('firstName')),
lastName: String(data.get('lastName')),
username: String(data.get('username')),
email: String(data.get('email') ?? '') || null,
email: email || null,
dni: String(data.get('dni') ?? '').trim() || null,
phone: String(data.get('phone') ?? '').trim() || null,
jobTitle: String(data.get('jobTitle') ?? '').trim() || null,
employeeNumber: String(data.get('employeeNumber') ?? '').trim() || null,
});
setUser(updated); setSuccess('Datos del usuario actualizados');
setUser(updated); setSuccess('Datos personales y de contacto actualizados');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
const saveRoles = async () => {
if (inspectorSelected && !user?.email) {
setError('Antes de asignar el rol Inspector, cargá y guardá un email válido.');
return;
}
setError(''); setSuccess(''); setSaving('roles');
try {
const updated = await replaceUserRoles(id, roleIds);
setUser(updated); setSuccess('Roles actualizados correctamente');
await replaceUserRoles(id, roleIds);
const refreshed = await getUserProfile(id);
setUser(refreshed); setSuccess('Roles actualizados correctamente');
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
@@ -78,8 +99,9 @@ export function UserDetailPage() {
if (!window.confirm(`¿Confirmás que querés ${verb} a ${user.username}?`)) return;
setError(''); setSuccess(''); setSaving('status');
try {
const updated = await updateUserStatus(id, next);
setUser(updated); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`);
await updateUserStatus(id, next);
const refreshed = await getUserProfile(id);
setUser(refreshed); setSuccess(`Usuario ${next === 'ACTIVE' ? 'activado' : 'desactivado'}`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(''); }
};
@@ -91,8 +113,9 @@ export function UserDetailPage() {
if (!window.confirm(`¿Restablecer la contraseña de ${user?.username ?? 'este usuario'}? Se cerrarán todas sus sesiones activas.`)) return;
setError(''); setSuccess(''); setSaving('password');
try {
const updated = await resetUserPassword(id, resetPasswordValue, forcePasswordChange);
setUser(updated);
await resetUserPassword(id, resetPasswordValue, forcePasswordChange);
const refreshed = await getUserProfile(id);
setUser(refreshed);
setResetPasswordValue(''); setResetPasswordConfirm('');
setSuccess('Contraseña restablecida. Las sesiones anteriores fueron revocadas.');
} catch (requestError) { setError(errorMessage(requestError)); }
@@ -110,13 +133,23 @@ export function UserDetailPage() {
return <section>
<div className="breadcrumb"><Link to="/admin/users">Usuarios</Link><span>/</span><strong>{user.username}</strong></div>
<div className="page-heading user-heading"><div className="profile-title"><span className="profile-avatar">{initials(user.firstName, user.lastName)}</span><div><span className="eyebrow">DETALLE DE USUARIO</span><h1>{user.firstName} {user.lastName}</h1><p>@{user.username} · Creado {formatDate(user.createdAt)}</p></div></div>{canChangeStatus && <button className={`button ${user.status === 'ACTIVE' ? 'danger-outline' : 'success-outline'}`} disabled={saving === 'status' || currentUser?.id === user.id} onClick={changeStatus}>{user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}</button>}</div>
<div className="page-heading user-heading"><div className="profile-title"><span className="profile-avatar">{initials(user.firstName, user.lastName)}</span><div><span className="eyebrow">DETALLE DE USUARIO</span><h1>{user.firstName} {user.lastName}</h1><p>@{user.username} · {user.jobTitle || 'Función sin informar'} · Creado {formatDate(user.createdAt)}</p></div></div>{canChangeStatus && <button className={`button ${user.status === 'ACTIVE' ? 'danger-outline' : 'success-outline'}`} disabled={saving === 'status' || currentUser?.id === user.id} onClick={changeStatus}>{user.status === 'ACTIVE' ? 'Desactivar acceso' : 'Activar acceso'}</button>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="detail-grid">
<form className="panel form-panel" onSubmit={saveProfile}>
<div className="panel-heading"><div><span className="eyebrow">CUENTA</span><h2>Datos personales</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
<div className="form-grid"><label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label><label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label><label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label><label className="field"><span>Email</span><input name="email" type="email" defaultValue={user.email ?? ''} disabled={!canUpdate} /></label></div>
<div className="panel-heading"><div><span className="eyebrow">PERFIL</span><h2>Datos personales y contacto</h2></div><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span></div>
<div className="form-grid">
<label className="field"><span>Nombre</span><input name="firstName" defaultValue={user.firstName} required disabled={!canUpdate} /></label>
<label className="field"><span>Apellido</span><input name="lastName" defaultValue={user.lastName} required disabled={!canUpdate} /></label>
<label className="field"><span>DNI</span><input name="dni" defaultValue={user.dni ?? ''} inputMode="numeric" maxLength={15} disabled={!canUpdate} /></label>
<label className="field"><span>Teléfono</span><input name="phone" type="tel" defaultValue={user.phone ?? ''} maxLength={40} disabled={!canUpdate} /></label>
<label className="field"><span>Cargo / función</span><input name="jobTitle" defaultValue={user.jobTitle ?? ''} maxLength={160} disabled={!canUpdate} /></label>
<label className="field"><span>Legajo / matrícula</span><input name="employeeNumber" defaultValue={user.employeeNumber ?? ''} maxLength={80} disabled={!canUpdate} /></label>
<label className="field"><span>Usuario</span><input name="username" defaultValue={user.username} required disabled={!canUpdate} /></label>
<label className="field"><span>Email {inspectorSelected && <em>obligatorio para Inspector</em>}</span><input name="email" type="email" defaultValue={user.email ?? ''} required={inspectorSelected} disabled={!canUpdate} /></label>
</div>
{inspectorSelected && <div className="temporal-notice"><Icon name="mail" /><p><strong>Destinatario del Inspector:</strong> al finalizar una inspección, la documentación se enviará también a <strong>{user.email || 'este email cuando lo completes'}</strong>.</p></div>}
<div className="metadata-grid"><div><small>Último acceso</small><strong>{formatDate(user.lastLoginAt)}</strong></div><div><small>Último cambio de clave</small><strong>{formatDate(user.passwordChangedAt)}</strong></div><div><small>Intentos fallidos</small><strong>{user.failedLoginAttempts}</strong></div><div><small>Bloqueado hasta</small><strong>{formatDate(user.lockedUntil)}</strong></div></div>
{user.mustChangePassword && <Alert type="info">Este usuario debe cambiar su contraseña temporal en el próximo ingreso.</Alert>}
{canUpdate && <div className="form-actions"><button className="button primary" disabled={saving === 'profile'}><Icon name="edit" />{saving === 'profile' ? 'Guardando…' : 'Guardar datos'}</button></div>}
@@ -134,7 +167,8 @@ export function UserDetailPage() {
<article className="panel form-panel">
<div className="panel-heading"><div><span className="eyebrow">AUTORIZACIÓN</span><h2>Roles asignados</h2></div><span className="count-pill">{roleIds.length}</span></div>
<div className="choice-list">{roles.map((role) => <label className={`choice-card compact ${roleIds.includes(role.id) ? 'selected' : ''}`} key={role.id}><input type="checkbox" checked={roleIds.includes(role.id)} onChange={() => toggleRole(role.id)} disabled={!canAssign} /><span><strong>{role.name}</strong><small>{role.description}</small></span><Icon name="check" /></label>)}</div>
{canAssign && <div className="form-actions"><button className="button primary" type="button" onClick={saveRoles} disabled={saving === 'roles'}>{saving === 'roles' ? 'Guardando…' : 'Guardar roles'}</button></div>}
{inspectorSelected && !user.email && <Alert>Para guardar el rol Inspector, primero cargá y guardá un email válido.</Alert>}
{canAssign && <div className="form-actions"><button className="button primary" type="button" onClick={saveRoles} disabled={saving === 'roles' || (inspectorSelected && !user.email)}>{saving === 'roles' ? 'Guardando…' : 'Guardar roles'}</button></div>}
</article>
</div>
</section>;