Compare commits

..
Author SHA1 Message Date
admin ea6a9fb836 test(F6.1): lock read-only checklist and preparation stage 2026-09-10 13:30:18 -03:00
admin c2735d8019 fix(F6.1): make field preparation an inspection detail action 2026-09-10 13:29:12 -03:00
admin 865e66fd49 fix(F6.1): surface field preparation inside inspection detail 2026-09-10 13:28:43 -03:00
admin 746592f896 fix(F6.1): make F4 checklist classification read-only 2026-09-10 13:26:16 -03:00
admin 2710c48b58 fix(F6.1): log exact inspection creation failure stage 2026-09-10 13:21:44 -03:00
admin e3772a4006 ci(F6.1): run hardened inspection smoke script 2026-09-10 13:17:06 -03:00
admin 844aa7e27f ci(F6.1): harden real inspection creation smoke 2026-09-10 13:16:56 -03:00
admin fbb2ebcb72 ci(F6.1): create a real inspection after territory preload 2026-09-10 13:14:30 -03:00
admin d825bd2cd5 test(F6.1): lock inspection hierarchy and field preparation UX 2026-09-10 13:07:58 -03:00
admin 75dea3c31b chore: remove temporary inspection planning patch workflow 2026-09-10 13:07:22 -03:00
admin 7c8085f237 fix(F6.1): route new inspections to hierarchical planner 2026-09-10 13:07:10 -03:00
admin 82fb0e191b fix(F6.1): add explicit hierarchical inspection creation page 2026-09-10 13:06:47 -03:00
admin 0e71a56baa fix(F6.1): register hierarchy-aware inspection planning services 2026-09-10 13:06:03 -03:00
admin f7a137d6ea fix(F6.1): route web inspection create through hierarchy-aware service 2026-09-10 13:05:31 -03:00
admin 3f43f5f9fb fix(F6.1): add hierarchy-aware web inspection creation 2026-09-10 13:04:58 -03:00
admin 5b6f905778 chore: prepare deterministic F6.1 inspection planning patch 2026-09-10 13:03:46 -03:00
admin aaa5765120 fix(F6.1): make field preparation inspection-scoped 2026-09-10 13:02:52 -03:00
admin 302199a554 fix(F6.1): keep field preparation inside inspections 2026-09-10 13:02:15 -03:00
admin 5a656f8604 fix(F6.1): expose Departamento Area Yacimiento planning routes 2026-09-10 13:01:20 -03:00
admin 89a7a24bad fix(F6.1): accept explicit yacimiento scope on inspection create 2026-09-10 13:00:54 -03:00
admin f8ddd7263a fix(F6.1): validate yacimiento inspection scope 2026-09-10 13:00:39 -03:00
admin 2060d81732 fix(F6.1): expose explicit inspection planning hierarchy 2026-09-10 13:00:27 -03:00
112 changed files with 863 additions and 10218 deletions
+25 -100
View File
@@ -116,10 +116,9 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate
# The completed chain must end in the exact authoritative SQL model:
# 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de
# concesión, 14/109 technical families, 181 Hallazgos and their exact
# 48 + 880 contextual mappings.
# F5.1 performs the historical clean start. F6.1 then seeds only the
# presentation territory approved by DH: 7 Departamentos, 64 Áreas,
# 230 Yacimientos and 12 real Empresas.
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
@@ -128,23 +127,13 @@ jobs:
domain_assets integer;
audits integer;
applicability integer;
legacy_territory_sources integer;
legacy_presentation_sources integer;
authoritative_sources integer;
territory_sources integer;
presentation_sources integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
concession_types integer;
departments integer;
areas integer;
yacimientos integer;
companies integer;
invalid_yacimientos integer;
department_types integer;
area_department_rules integer;
yacimiento_area_rules integer;
installation_yacimiento_rules integer;
subinstallation_installation_rules integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
@@ -161,86 +150,49 @@ jobs:
END IF;
SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 314 THEN
RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets;
IF domain_assets <> 313 THEN
RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
END IF;
SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN
RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits;
RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 928 THEN
RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability;
IF applicability <> 0 THEN
RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
END IF;
SELECT COUNT(*) INTO legacy_territory_sources
SELECT COUNT(*) INTO territory_sources
FROM source_documents
WHERE document_number='DH-F5-TERRITORY';
IF legacy_territory_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources;
IF territory_sources <> 0 THEN
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) INTO legacy_presentation_sources
SELECT COUNT(*) INTO presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF legacy_presentation_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources;
END IF;
SELECT COUNT(*) INTO authoritative_sources
FROM source_documents
WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911');
IF authoritative_sources <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_sources;
IF presentation_sources <> 1 THEN
RAISE EXCEPTION 'F6.1 must contain exactly one presentation territory source, got %', presentation_sources;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'modelo_relacional:%';
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'Authoritative technical families must be 14 installations and 109 subinstallations, got % and %', source_installations,source_subinstallations;
RAISE EXCEPTION 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='authmodel' AND item.is_active=true;
IF source_findings <> 181 THEN
RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings;
END IF;
SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true;
IF concession_types <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types;
END IF;
SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'),
COUNT(*) FILTER (WHERE lower(type.code)='area'),
COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'),
COUNT(*) FILTER (WHERE type.operational_role='COMPANY')
INTO departments,areas,yacimientos,companies
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id;
IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN
RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
JOIN assets area ON area.id=yacimiento.parent_id
WHERE lower(type.code)='yacimiento'
AND (
yacimiento.operational_area_id IS DISTINCT FROM area.id
OR yacimiento.operator_company_id IS NULL
OR yacimiento.concession_type_id IS NULL
);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos;
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO department_types
@@ -258,43 +210,16 @@ jobs:
IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF;
SELECT COUNT(*) INTO yacimiento_area_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='yacimiento' AND lower(parent.code)='area';
IF yacimiento_area_rules <> 1 THEN
RAISE EXCEPTION 'Expected Yacimiento → Área canonical rule, got %', yacimiento_area_rules;
END IF;
SELECT COUNT(*) INTO installation_yacimiento_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='instalacion' AND lower(parent.code)='yacimiento';
IF installation_yacimiento_rules <> 1 THEN
RAISE EXCEPTION 'Expected Instalación → Yacimiento canonical rule, got %', installation_yacimiento_rules;
END IF;
SELECT COUNT(*) INTO subinstallation_installation_rules
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion';
IF subinstallation_installation_rules <> 1 THEN
RAISE EXCEPTION 'Expected Subinstalación → Instalación canonical rule, got %', subinstallation_installation_rules;
END IF;
END $$;
SQL
# Destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed
# chain is idempotent and has no pending migration on a second run.
# Both destructive cuts are intentionally one-way: production rollback is
# the PRE database backup, not migration:revert. Prove the completed chain
# is idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: authoritative migration chain is not idempotent." >&2
echo "ERROR: F6.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2
exit 1
}
-11
View File
@@ -1,11 +0,0 @@
# F6.4 · Android Campo Moderno
Criterios de aceptación:
- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente;
- la selección de Instalación abre la clasificación compatible de Subinstalación;
- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina;
- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo;
- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia;
- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos;
- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.
-15
View File
@@ -1,15 +0,0 @@
# Checklist final Android 0.19.0
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección;
- crear/abrir Acta;
- seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado;
- cerrar contenido del Acta y mostrar **Pendiente de firma**;
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
+17 -66
View File
@@ -1,88 +1,39 @@
# DH Inspección Android · release final de campo 0.19.0
# DH Inspección Android · release F6.1
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Fase funcional: **F6.1**.
- `versionName`: **0.15.1**.
- `versionCode`: **23**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
## Procedimiento operativo validado
La APK 0.19.0 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilitan Actas, Hallazgos e Inventario de campo.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo.
3. **Agregar Hallazgos** sobre una Instalación o Subinstalación seleccionada.
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Al terminar el contenido del Acta se usa **Cerrar Acta y dejar pendiente de firma**. Desde ese momento el contenido queda inmutable y el estado visible es **Pendiente de firma**.
8. La firma del Inspector y la manifestación/firma o negativa de la empresa completan el Acta, que pasa a **Firmada y cerrada**.
9. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
## Alta de Instalaciones y Subinstalaciones
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
### Instalación
- contexto territorial: Yacimiento/Área de la Inspección;
- clasificación técnica compatible;
- **Nombre técnico** obligatorio;
- Nombre habitual opcional;
- Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
### Subinstalación
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
## Actas y firma
La terminología visible se simplifica:
- `DRAFT`**En elaboración**;
- `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
## Castellano e identidad visual
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
1. validación de identidad, HTTPS y políticas básicas del manifest;
2. Android lint;
3. pruebas unitarias Android reales, con verificación de que exista al menos una prueba ejecutada;
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
4. `assembleDebug`;
5. `assembleRelease` para comprobar que la variante productiva compile;
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover cambios que afecten al servidor.
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente.
El artefacto de CI contiene:
- APK debug;
- archivo `.sha256`;
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
@@ -91,7 +42,7 @@ No se debe crear una clave nueva para resolver una falta de acceso: eso romperí
Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica si se distribuirá como actualización productiva;
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- comprobar certificado/huella de firma contra la versión histórica;
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre;
- registrar el SHA Git y SHA-256 de la APK distribuida.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 28
versionName = "0.19.0"
versionCode = 23
versionName = "0.15.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
+2 -2
View File
@@ -9,8 +9,8 @@
<application
android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:icon="@drawable/ic_mendoza_launcher"
android:roundIcon="@drawable/ic_mendoza_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion"
@@ -115,7 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy {
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
inventoryParentId = visit?.operationalArea?.id
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
@@ -137,7 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return
launchBusy {
visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
inventoryParentId = visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
@@ -168,14 +168,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return
}
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset?.id,
asset.id,
currentVisit.code,
urgency,
)
@@ -184,25 +188,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
val effectiveParentId = parentId ?: inventoryParentId ?: currentVisit.operationalArea?.id
inventory = repository.fieldInventory(currentVisit.id, search, effectiveParentId).data
}
}
fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
@@ -210,7 +213,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
val effectiveParentId = inventoryParentId ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
@@ -241,7 +244,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double,
longitude: Double,
accuracyM: Double?,
description: String? = null,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
@@ -259,7 +261,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId = familyId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = latitude,
deviceLongitude = longitude,
@@ -342,7 +343,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct()
if (draft == null) {
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
return
}
launchBusy {
@@ -364,7 +365,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id
if (actId == null) {
error = "No hay un Acta en elaboración seleccionada."
error = "No hay un Acta en borrador seleccionada."
return
}
if (description.isBlank()) {
@@ -473,7 +474,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de firmar y cerrar el Acta."
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
}
}
@@ -482,12 +483,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta cerrada y pendiente de firma. Su contenido quedó inmutable; las firmas y manifestaciones pueden completarse a continuación."
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
}
}
fun reopenSelectedAct() {
error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
@@ -548,7 +549,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
}
}
@@ -557,7 +558,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
}
}
@@ -154,7 +154,6 @@ data class VisitDetail(
val code: String,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null,
@@ -207,7 +207,7 @@ data class MobileCloseVisitRequest(
)
private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts/mobile")
@GET("inspection-visits/{visitId}/acts")
suspend fun listActs(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create(
visitId: String,
assetId: String? = null,
assetId: String,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOfNotNull(assetId),
assetIds = listOf(assetId),
),
)
}
@@ -1,73 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val DhLightColors = lightColorScheme(
primary = Color(0xFF075E63),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFD6F0F0),
onPrimaryContainer = Color(0xFF073F43),
secondary = Color(0xFF445E74),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFDCE7F1),
onSecondaryContainer = Color(0xFF243F54),
tertiary = Color(0xFF6A5A42),
background = Color(0xFFF6F8FA),
onBackground = Color(0xFF182025),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF182025),
surfaceVariant = Color(0xFFEDF1F4),
onSurfaceVariant = Color(0xFF455158),
outline = Color(0xFF77848B),
outlineVariant = Color(0xFFD7DEE2),
error = Color(0xFFB3261E),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF6F1713),
)
private val DhDarkColors = darkColorScheme(
primary = Color(0xFF88D4D7),
onPrimary = Color(0xFF00373A),
primaryContainer = Color(0xFF074F53),
onPrimaryContainer = Color(0xFFB7EBED),
secondary = Color(0xFFB6CBE0),
onSecondary = Color(0xFF203646),
secondaryContainer = Color(0xFF344C60),
onSecondaryContainer = Color(0xFFDCE7F1),
background = Color(0xFF101416),
onBackground = Color(0xFFE0E4E6),
surface = Color(0xFF171C1F),
onSurface = Color(0xFFE0E4E6),
surfaceVariant = Color(0xFF263035),
onSurfaceVariant = Color(0xFFC3CDD2),
outline = Color(0xFF8C989E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
)
private val DhShapes = androidx.compose.material3.Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(22.dp),
extraLarge = RoundedCornerShape(28.dp),
)
@Composable
fun DhTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors,
shapes = DhShapes,
content = content,
)
}
@@ -1,972 +0,0 @@
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.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 DynamicGeoSnapshot(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
private enum class DynamicInventoryMode {
BROWSE,
PICK_PARENT,
CREATE_INSTALLATION,
CREATE_SUBINSTALLATION,
}
@Composable
fun DynamicVisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
when {
actsMode -> MobileActsScreen(
model = model,
onBack = { actsMode = false },
onGoInventory = {
actsMode = false
inventoryMode = true
},
)
inventoryMode -> DynamicFieldInventoryScreen(
model = model,
onBack = {
inventoryMode = false
actsMode = true
},
)
else -> DynamicVisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun DynamicVisitOverview(
model: MainViewModel,
onInventory: () -> Unit,
onActs: () -> 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(dynamicStatusLabel(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.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${dynamicShortDate(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)
}
}
}
DynamicMessageStrip(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("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar se habilitan Actas, Hallazgos y altas rápidas de campo.")
Button(
onClick = { model.startVisit(); onActs() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
}
}
}
if (visit.status == "IN_PROGRESS") {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Modo campo rápido", fontWeight = FontWeight.Bold)
Text(
"Desde Inventario podés cargar una Subinstalación en tres pasos: elegir Instalación, identificarla y capturar GPS + foto.",
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Nueva subinstalación / inventario")
}
}
}
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Buscar Inventario / Hallazgos")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
}
DynamicChecklistCard(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 DynamicChecklistCard(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 DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val visit = model.visit ?: return
var modeName by rememberSaveable(visit.id) { mutableStateOf(DynamicInventoryMode.BROWSE.name) }
val mode = runCatching { DynamicInventoryMode.valueOf(modeName) }.getOrDefault(DynamicInventoryMode.BROWSE)
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var parentSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
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) }
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
val attributeValues = remember { mutableStateMapOf<String, String>() }
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var permissionAction by rememberSaveable(visit.id) { mutableStateOf("PHOTO") }
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
fun resetForm() {
name = ""
commonName = ""
selectedTypeId = null
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
localError = null
}
fun backToBrowse() {
modeName = DynamicInventoryMode.BROWSE.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.loadFieldTypes(null)
}
fun startInstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.CREATE_INSTALLATION.name
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetForm()
model.loadFieldTypes(null)
}
fun startSubinstallation() {
model.clearSelectedFieldAsset()
modeName = DynamicInventoryMode.PICK_PARENT.name
parentId = null
parentLabel = ""
parentSearch = ""
resetForm()
model.searchInventory("", visit.scopeAsset?.id)
}
fun chooseParent(item: FieldInventoryItem) {
parentId = item.id
parentLabel = "${item.name} (${item.code})"
modeName = DynamicInventoryMode.CREATE_SUBINSTALLATION.name
resetForm()
model.loadFieldTypes(item.id)
}
LaunchedEffect(visit.id) {
model.searchInventory("", visit.scopeAsset?.id)
}
LaunchedEffect(model.fieldTypes, modeName) {
if (mode == DynamicInventoryMode.CREATE_INSTALLATION || mode == DynamicInventoryMode.CREATE_SUBINSTALLATION) {
val preferred = model.fieldTypes.firstOrNull { type ->
val normalized = dynamicNormalize(type.structuralKind ?: type.code.ifBlank { type.name })
when (mode) {
DynamicInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion")
DynamicInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
else -> false
}
} ?: model.fieldTypes.firstOrNull()
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = preferred?.id
selectedFamilyId = null
familySearch = ""
attributeValues.clear()
}
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
val filteredFamilies = remember(selectedType, familySearch) {
val needle = dynamicNormalize(familySearch)
selectedType?.families.orEmpty().filter { family ->
needle.isBlank() || dynamicNormalize("${family.code} ${family.name}").contains(needle)
}
}
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<DynamicGeoSnapshot?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
val geo = pendingPhotoGeo
if (success && file != null && geo != null) {
runCatching { writeDynamicExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
} else if (!success) {
localError = "La foto quedó pendiente. Podés tomarla cuando estés listo."
}
pendingPhotoFile = null
pendingPhotoGeo = null
}
val beginPhoto: () -> Unit = {
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
val (file, uri) = newDynamicPhoto(context)
pendingPhotoFile = file
pendingPhotoGeo = geo
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
}
}
fun createWithLocation() {
val type = selectedType ?: return
val effectiveParent = when (mode) {
DynamicInventoryMode.CREATE_SUBINSTALLATION -> parentId
else -> null
}
scope.launch {
runCatching { currentDynamicGeo(context) }
.onSuccess { geo ->
pendingAutoPhoto = true
model.createFieldAsset(
type = type,
parentId = effectiveParent,
familyId = selectedFamilyId,
name = name,
commonName = commonName,
attributes = buildDynamicAttributes(type, attributeValues),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val cameraAllowed = result[Manifest.permission.CAMERA] == true || dynamicHasPermission(context, Manifest.permission.CAMERA)
val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || dynamicHasLocation(context)
when (permissionAction) {
"CREATE" -> {
if (cameraAllowed && locationAllowed) createWithLocation()
else localError = "Para el alta rápida se necesitan cámara y ubicación."
}
else -> {
if (cameraAllowed && locationAllowed) beginPhoto()
else localError = "Para completar el alta se necesitan cámara y ubicación."
}
}
}
fun requestCreate() {
permissionAction = "CREATE"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
createWithLocation()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
fun requestPhoto() {
permissionAction = "PHOTO"
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
beginPhoto()
} else {
permissionsLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
val selectedCapture = model.selectedFieldAsset
LaunchedEffect(selectedCapture?.asset?.id, pendingAutoPhoto) {
val capture = selectedCapture?.capture
if (pendingAutoPhoto && capture?.captureRequired == true && capture.creationGpsCaptured && capture.fieldPhotoCount == 0) {
pendingAutoPhoto = false
requestPhoto()
}
}
val mergeSource = selectedCapture?.takeIf {
it.capture.captureRequired && dynamicItemTypeCode(it.asset) in setOf("instalacion", "subinstalacion")
}
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 = { if (mode == DynamicInventoryMode.BROWSE) onBack() else backToBrowse() }) {
Text(if (mode == DynamicInventoryMode.BROWSE) "Acta" else "Atrás")
}
Column(horizontalAlignment = Alignment.End) {
Text(
when (mode) {
DynamicInventoryMode.PICK_PARENT -> "Elegir Instalación"
DynamicInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
DynamicInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
else -> "Inventario de campo"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
)
Text(visit.scopeAsset?.name ?: visit.code, style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) {
DynamicMessageStrip(model)
localError?.let {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
onClick = { localError = null },
) { Text(it, Modifier.padding(12.dp)) }
}
}
if (selectedCapture != null) {
DynamicCaptureCard(
item = selectedCapture.asset,
gps = selectedCapture.capture.creationGpsCaptured,
photos = selectedCapture.capture.fieldPhotoCount,
ready = selectedCapture.capture.readyForFinding,
onPhoto = { requestPhoto() },
onClose = { model.clearSelectedFieldAsset() },
onCreateAnother = {
if (dynamicItemTypeCode(selectedCapture.asset) == "subinstalacion" && selectedCapture.asset.parent != null) {
chooseParent(
FieldInventoryItem(
id = selectedCapture.asset.parent.id,
code = selectedCapture.asset.parent.code,
name = selectedCapture.asset.parent.name,
type = null,
),
)
} else {
startSubinstallation()
}
},
)
}
if (mergeSource != null && !mergeSource.capture.readyForFinding) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Ya existía?", fontWeight = FontWeight.Bold)
Text("Antes de seguir, podés fusionar el alta con un registro existente compatible.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = mergeSearch,
onValueChange = { mergeSearch = it },
label = { Text("Buscar posible duplicado") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedButton(
onClick = {
mergeCandidateId = null
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar coincidencias") }
mergeCandidates.take(6).forEach { candidate ->
AssistChip(
onClick = { mergeCandidateId = candidate.id },
label = {
Text(if (mergeCandidateId == candidate.id) "${candidate.code} · ${candidate.name}" else "${candidate.code} · ${candidate.name}")
},
)
}
if (mergeCandidateId != null) {
OutlinedTextField(
value = mergeReason,
onValueChange = { mergeReason = it },
label = { Text("Motivo de la fusión") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
Button(
onClick = { model.mergeCreatedFieldAsset(mergeCandidateId!!, mergeReason) },
enabled = mergeReason.trim().length >= 8 && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Fusionar con el existente") }
}
}
}
}
when (mode) {
DynamicInventoryMode.BROWSE -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Alta rápida en campo", fontWeight = FontWeight.Bold)
Text("La Subinstalación se carga guiada, sin navegar por formularios largos.", style = MaterialTheme.typography.bodySmall)
Button(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Subinstalación")
}
OutlinedButton(onClick = { startInstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
Text("+ Nueva Instalación")
}
}
}
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación, subinstalación o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar") }
}
Text("Inventario disponible", 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.filter { dynamicItemTypeCode(it) in setOf("instalacion", "subinstalacion") },
key = { it.id },
) { item ->
DynamicInventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
onAddChild = { chooseParent(item) },
)
}
item { Spacer(Modifier.height(30.dp)) }
}
}
DynamicInventoryMode.PICK_PARENT -> {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
DynamicStepHeader(1, "Elegí la Instalación padre")
Text("Solo se muestran Instalaciones del Yacimiento de esta inspección.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = parentSearch,
onValueChange = { parentSearch = it },
label = { Text("Buscar Instalación por nombre o código") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = { model.searchInventory(parentSearch, visit.scopeAsset?.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Buscar Instalación") }
}
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { dynamicItemTypeCode(it) == "instalacion" },
key = { it.id },
) { item ->
Card(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
Text("Tocar para agregar una Subinstalación", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
}
}
}
item { Spacer(Modifier.height(24.dp)) }
}
}
DynamicInventoryMode.CREATE_INSTALLATION,
DynamicInventoryMode.CREATE_SUBINSTALLATION -> {
val isSubinstallation = mode == DynamicInventoryMode.CREATE_SUBINSTALLATION
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
DynamicStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación")
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold)
if (isSubinstallation) {
OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) {
Text("Cambiar Instalación padre")
}
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo habilitado para esta ubicación.", color = MaterialTheme.colorScheme.error)
}
if (model.fieldTypes.size > 1) {
Text("Nivel de Inventario", fontWeight = FontWeight.Bold)
model.fieldTypes.forEach { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (selectedTypeId == type.id) "${type.name}" else type.name) },
)
}
}
if (selectedType?.familyRequired == true) {
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar clasificación") },
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filteredFamilies.take(12).forEach { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
if (filteredFamilies.size > 12) {
Text("Escribí parte del nombre para reducir la lista.", style = MaterialTheme.typography.bodySmall)
}
selectedFamily?.let { family ->
if (family.isOther) {
Text("Quedará marcado como no catalogado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
}
if (family.informationLabels.isNotEmpty()) {
Text("Datos esperados: ${family.informationLabels.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código visible *") },
supportingText = { Text("Usá lo que ve el inspector en la placa o en campo.") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
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(),
)
}
HorizontalDivider()
DynamicStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto en un solo paso")
Text(
"Al guardar se captura la ubicación actual y la cámara se abre automáticamente. La foto sigue siendo obligatoria antes de crear Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") }
Spacer(Modifier.height(30.dp))
}
}
}
}
}
@Composable
private fun DynamicStepHeader(step: Int, title: String) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Text(step.toString(), Modifier.padding(horizontal = 10.dp, vertical = 6.dp), fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun DynamicCaptureCard(
item: FieldInventoryItem,
gps: Boolean,
photos: Int,
ready: Boolean,
onPhoto: () -> Unit,
onClose: () -> Unit,
onCreateAnother: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = if (ready) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold)
Text(item.code, style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = onClose) { Text("Cerrar") }
}
Text("GPS: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Falta la fotografía obligatoria.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto ahora") }
} else {
Text("Alta completa · lista para Hallazgos", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) {
Text("+ Crear otra Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicInventoryCard(
item: FieldInventoryItem,
onInspect: () -> Unit,
onAddChild: () -> Unit,
) {
val typeCode = dynamicItemTypeCode(item)
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(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación", style = MaterialTheme.typography.bodySmall)
}
Text(item.code, style = MaterialTheme.typography.bodySmall)
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Usar para Hallazgo")
}
if (typeCode == "instalacion") {
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Subinstalación")
}
}
}
}
}
@Composable
private fun DynamicMessageStrip(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 dynamicItemTypeCode(item: FieldInventoryItem): String {
val type = item.type ?: return ""
return dynamicNormalize(type.typeName ?: type.name)
}
private fun dynamicNormalize(value: String): String = value.trim().lowercase()
.replace('ó', 'o')
.replace('í', 'i')
.replace('á', 'a')
.replace('é', 'e')
.replace('ú', 'u')
.replace("_", "")
.replace("-", "")
.replace(" ", "")
private fun dynamicStatusLabel(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun buildDynamicAttributes(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 coerceDynamicAttribute(definition, raw)
}.toMap()
private fun coerceDynamicAttribute(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 dynamicHasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun dynamicHasLocation(context: Context): Boolean =
dynamicHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
dynamicHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentDynamicGeo(context: Context): DynamicGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!dynamicHasLocation(context)) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
try {
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) {
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
} else {
continuation.resume(DynamicGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
private fun newDynamicPhoto(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_FAST_${System.currentTimeMillis()}_", ".jpg", directory)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
return file to uri
}
private fun writeDynamicExif(file: File, geo: DynamicGeoSnapshot) {
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 dynamicShortDate(value: String): String = value.replace('T', ' ').take(16)
@@ -86,10 +86,7 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
@@ -122,9 +119,6 @@ private fun F3VisitOverview(
}
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
visit.scopeAsset?.let {
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
}
visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
}
@@ -144,10 +138,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
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(); onActs() },
onClick = { model.startVisit() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -156,12 +150,12 @@ private fun F3VisitOverview(
}
if (visit.status == "IN_PROGRESS") {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -169,7 +163,7 @@ private fun F3VisitOverview(
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -215,9 +209,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) {
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
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) }
@@ -331,10 +323,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Acta") }
OutlinedButton(onClick = onBack) { Text("Volver") }
Column(horizontalAlignment = Alignment.End) {
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
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) }
@@ -418,182 +410,170 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}
}
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar instalación o subinstalación") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { model.searchInventory(search) },
enabled = !model.busy,
modifier = Modifier.weight(1f),
) { Text("Buscar") }
OutlinedButton(
onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
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) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
Column(
Modifier
.fillMaxWidth()
.padding(12.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Yacimiento") }
}
if (model.fieldTypes.isEmpty()) {
Text("No hay un tipo disponible para esta ubicación.")
} else {
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
}
if (selectedType?.familyRequired == true) {
Text("Clasificación", fontWeight = FontWeight.Bold)
Text(
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como no catalogado para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre o código identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
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("Guardar alta y capturar GPS") }
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(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
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.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
items(model.inventory, key = { it.id }) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
@@ -631,10 +611,10 @@ private fun F3CaptureCard(
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
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 el Hallazgo", color = MaterialTheme.colorScheme.primary)
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -651,8 +631,8 @@ private fun F3InventoryCard(
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode == "instalacion"
val childLabel = "+ Agregar subinstalación"
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)) {
@@ -666,12 +646,12 @@ private fun F3InventoryCard(
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
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,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
}
}
if (canHaveChild) {
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "completo" else "pendiente"}",
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -210,7 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
@@ -3,13 +3,11 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
@@ -31,9 +29,7 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -41,7 +37,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled"
@@ -79,7 +74,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
if (model.session == null) unlocked = false
}
DhTheme {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
@@ -96,7 +91,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> ModernVisitRoot(model)
model.visit != null -> F3VisitRoot(model)
else -> MobileHomeScreen(model)
}
}
@@ -110,13 +105,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -124,7 +113,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o correo electrónico") },
label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -209,12 +198,6 @@ private fun BiometricUnlockScreen(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -126,14 +126,13 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
@@ -157,15 +156,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) {
Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia", fontWeight = FontWeight.Bold)
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
@@ -184,15 +183,20 @@ fun MobileActsScreen(
},
style = MaterialTheme.typography.bodySmall,
)
Text(
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = { model.createAct(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button(
onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
}
@@ -216,21 +220,7 @@ fun MobileActsScreen(
when (selected.status) {
"DRAFT" -> {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -269,7 +259,12 @@ fun MobileActsScreen(
}
HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button(
onClick = { model.prepareSelectedAct() },
@@ -215,7 +215,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visitStatusLabelEs(visit.status))
Text(visit.status)
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
@@ -1,559 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.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.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
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 kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class ModernActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun ModernMobileActsScreen(
model: MainViewModel,
onBack: () -> Unit,
onGoInventory: () -> Unit,
) {
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
tonalElevation = 1.dp,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(
visit.scopeAsset?.name ?: "Yacimiento",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
ModernMessage(model)
if (model.acts.isEmpty()) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(
Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else {
Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
model.acts.forEach { act ->
val active = selected?.id == act.id
Surface(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
tonalElevation = if (active) 1.dp else 0.dp,
border = BorderStroke(
if (active) 2.dp else 1.dp,
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
),
) {
Row(
Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
when (act.status) {
"LOCKED" -> Icons.Filled.Lock
"SEALED" -> Icons.Filled.CheckCircle
else -> Icons.Filled.EditNote
},
contentDescription = null,
tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(act.code, fontWeight = FontWeight.Bold)
Text(
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}",
style = MaterialTheme.typography.bodySmall,
)
}
if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.width(10.dp))
Column {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text("Urgencia", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = newActUrgency == "NON_URGENT",
onClick = { newActUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = newActUrgency == "URGENT",
onClick = { newActUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al cerrar su contenido."
} else {
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
},
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = { model.createAct(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text(if (model.busy) "Creando…" else "Crear nueva Acta")
}
}
}
}
if (selected != null) {
HorizontalDivider()
Surface(
Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
StatusPill(modernActStatusLabel(selected.status))
}
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
}
}
}
when (selected.status) {
"DRAFT" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text("Agregar Hallazgo")
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(documentTypeLabelEs(kind)) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Correo electrónico") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
}
}
}
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(8.dp))
Text("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Pendiente de firma. Podés firmarla ahora o continuar con otra Acta.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Acta y dejar pendiente de firma") }
}
}
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.width(8.dp))
Text("Acta pendiente de firma", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("El contenido ya está cerrado e inmutable. Completá la firma del inspector y la manifestación de la empresa cuando corresponda.")
closure?.closure?.preparedSha256?.let { Text("Huella de integridad · $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
SuccessLine("Firma del inspector registrada")
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de firmar y cerrar el Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
}
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
label = { Text("Motivo de negativa") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Firmar y cerrar Acta")
}
} else {
Text(
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
"SEALED" -> {
ElevatedCard(Modifier.fillMaxWidth()) {
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta firmada y cerrada", fontWeight = FontWeight.Bold)
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
}
}
}
}
"CANCELLED" -> {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp))
}
}
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
val activeActs = model.acts.filter { it.status != "CANCELLED" }
val pendingActs = activeActs.filter { it.status != "SEALED" }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
SuccessLine("Todas las Actas están firmadas y cerradas")
} else {
Text(
"Falta firmar y cerrar: ${pendingActs.joinToString { it.code }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Button(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Inspección y salir") }
}
}
}
Spacer(Modifier.height(32.dp))
}
}
@Composable
private fun SuccessLine(text: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
}
}
private fun modernActStatusLabel(status: String): String = actStatusLabelEs(status)
private fun hasModernActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation ->
if (!hasModernActLocation(context)) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
try {
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ModernActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() }
}
File diff suppressed because it is too large Load Diff
@@ -49,7 +49,7 @@ fun SignaturePad(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
@@ -1,50 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import java.util.Locale
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"OPEN" -> "Abierto"
"PENDING" -> "Pendiente"
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"VERIFICATION_PENDING" -> "Pendiente de verificación"
"VERIFICATION_SCHEDULED" -> "Verificación programada"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> humanBackendLabelEs(type)
}
internal fun humanBackendLabelEs(value: String): String {
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

@@ -1,26 +0,0 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
class DynamicFieldFlowContractTest {
private val source = File("src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt").readText()
@Test
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
assertTrue(source.contains("Elegí la Instalación padre"))
assertTrue(source.contains("Clasificación técnica *"))
assertTrue(source.contains("Guardar y tomar foto"))
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
assertTrue(source.contains("parentId = item.id"))
}
@Test
fun fieldCaptureStillRequiresGpsAndPhotoBeforeFindings() {
assertTrue(source.contains("La foto sigue siendo obligatoria antes de crear Hallazgos"))
assertTrue(source.contains("creationGpsCaptured"))
assertTrue(source.contains("fieldPhotoCount == 0"))
assertTrue(source.contains("model.uploadFieldPhoto"))
}
}
@@ -1,57 +0,0 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar Acta y dejar pendiente de firma"))
assertTrue(labels.contains("\"LOCKED\" -> \"Pendiente de firma\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")"))
}
@Test
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("documentTypeLabelEs(kind)"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
}
@@ -8,8 +8,8 @@ class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(28, BuildConfig.VERSION_CODE)
assertEquals("0.19.0-debug", BuildConfig.VERSION_NAME)
assertEquals(23, BuildConfig.VERSION_CODE)
assertEquals("0.15.1-debug", BuildConfig.VERSION_NAME)
}
@Test
@@ -36,20 +36,6 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[];
}
function toIsoDate(value: unknown): string {
const parsed = value instanceof Date
? value
: value == null
? new Date()
: new Date(String(value));
if (Number.isNaN(parsed.getTime())) {
throw new Error('La fecha planificada de la inspección no es válida');
}
return parsed.toISOString().slice(0, 10);
}
@Injectable()
export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {}
@@ -81,9 +67,7 @@ export class FieldBriefingService {
});
}
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
@@ -1,407 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
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, 500),
warnings: failedFacets,
};
}
}
@@ -24,7 +24,6 @@ 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 { SimpleInventoryStructureService } from './simple-inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
@@ -32,7 +31,6 @@ import { InventoryTechnicalValuesService } from './inventory-technical-values.se
import { InventoryFunctionService } from './inventory-function.service';
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@@ -61,13 +59,11 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetTypesService,
AssetsService,
InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
@@ -142,8 +142,27 @@ export class AssetOperationalRelationsService {
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
const [row] = (await manager.query(`
INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
@@ -151,6 +170,9 @@ export class AssetOperationalRelationsService {
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// F5/F6: changing the Area operator is a temporal relation event only.
// Existing Inventory keeps its creation/historical operator snapshot and
// physical hierarchy unchanged. Runtime ownership must resolve this row.
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -159,7 +181,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
: undefined,
}, manager);
return created;
@@ -190,6 +212,7 @@ export class AssetOperationalRelationsService {
});
}
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
await manager.query(`
UPDATE area_company_relations
@@ -209,7 +232,6 @@ export class AssetOperationalRelationsService {
afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager);
@@ -54,10 +54,6 @@ export class CreateAssetDto {
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -11,7 +11,6 @@ import {
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
// La Empresa y el Tipo de concesión se relacionan directamente con el Yacimiento.
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'DEPARTAMENTO',
@@ -53,14 +52,6 @@ export class CreateInventoryStructureDto {
@IsUUID('4')
familyId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@@ -48,14 +48,14 @@ export class UpdateAssetDto {
@IsUUID('4')
operationalAreaId?: string | null;
/**
* Snapshot histórico/de compatibilidad. La Operadora vigente pertenece a la
* relación temporal Área↔Empresa y no puede editarse como propiedad física.
*/
@IsOptional()
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' })
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
operatorCompanyId?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Tipo de concesión se administra en el Yacimiento, no desde la edición genérica del Inventario' })
concessionTypeId?: string | null;
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -4,14 +4,10 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure')
export class InventoryStructureController {
constructor(
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
constructor(private readonly inventoryStructure: InventoryStructureService) {}
@Get()
@RequirePermissions('assets.read')
@@ -28,17 +24,6 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search);
}
@Post('simple')
@RequirePermissions('assets.create')
createSimple(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.simpleInventoryStructure.create(dto, principal, request);
}
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
@Post()
@RequirePermissions('assets.create')
create(
@@ -20,7 +20,6 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type SimpleOptionRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = {
id: string;
@@ -38,17 +37,9 @@ type ParentRow = {
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
@@ -123,21 +114,6 @@ export class InventoryStructureService {
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
const companies = (await this.dataSource.query(`
SELECT asset.id,asset.code,COALESCE(profile.legal_name,asset.name) AS name
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id AND type.operational_role='COMPANY' AND type.is_active=true
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
WHERE asset.information_status<>'INACTIVE'
ORDER BY COALESCE(profile.legal_name,asset.name),asset.code
`)) as SimpleOptionRow[];
const concessionTypes = (await this.dataSource.query(`
SELECT id,code,name
FROM concession_types
WHERE is_active=true
ORDER BY CASE lower(name) WHEN 'explotación' THEN 1 WHEN 'exploración' THEN 2 ELSE 9 END,name,code
`)) as SimpleOptionRow[];
return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
@@ -149,8 +125,6 @@ export class InventoryStructureService {
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
],
companies,
concessionTypes,
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
};
@@ -205,43 +179,25 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const yacimientoContext = await this.requireYacimientoContext(manager, dto);
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
? await this.resolveAreaId(manager, parent)
: null;
const operatorCompanyId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.companyId ?? null
: dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION'
? parent?.operatorCompanyId ?? null
: null;
const concessionTypeId = dto.kind === 'YACIMIENTO'
? yacimientoContext?.concessionTypeId ?? null
: null;
if ((dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') && !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_COMPANY_MISSING',
message: 'El Yacimiento de origen no tiene una Empresa relacionada válida',
});
}
const inserted = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
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::uuid,
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
family?.id ?? null,
generatedCode,
dto.name,
@@ -250,13 +206,9 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F6' : 'Estructura manual de Inventario F6',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family
? `Clasificación técnica: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
@@ -269,15 +221,6 @@ export class InventoryStructureService {
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`,[id,dto.name,principal.userId]);
}
if (dto.kind === 'YACIMIENTO' && operationalAreaId && operatorCompanyId && concessionTypeId) {
await this.ensureCompatibilityProjection(
manager,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
yacimientoContext?.concessionName ?? 'Concesión',
);
}
const versionNumber = await this.history.capture(
manager,id,AssetVersionChangeType.CREATED,principal,request,
@@ -286,14 +229,10 @@ export class InventoryStructureService {
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)
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
: dto.kind === 'EMPRESA'
? 'Alta manual de Empresa independiente'
: 'Alta manual de estructura de Inventario',
id,parent?.id ?? null,operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
versionNumber,request.requestId,principal.userId,
]);
const created = await this.loadView(manager, id);
@@ -305,8 +244,7 @@ export class InventoryStructureService {
metadata: {
versionNumber,inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
concessionTypeId,
operatorOwnership: false,
},
}, manager);
return created;
@@ -352,9 +290,7 @@ export class InventoryStructureService {
});
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
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'
@@ -369,65 +305,8 @@ export class InventoryStructureService {
return parent;
}
private async requireYacimientoContext(
manager: EntityManager,
dto: CreateInventoryStructureDto,
): Promise<YacimientoContext | null> {
if (dto.kind !== 'YACIMIENTO') {
if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_NOT_ALLOWED',
message: 'Empresa relacionada y Tipo de concesión sólo corresponden al Yacimiento',
});
}
return null;
}
if (!dto.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
message: 'Seleccioná la Empresa relacionada del Yacimiento',
});
}
if (!dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_REQUIRED',
message: 'Seleccioná el Tipo de concesión del Yacimiento',
});
}
const companyRows = (await manager.query(`
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND type.operational_role='COMPANY'
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
FOR KEY SHARE
`,[dto.operatorCompanyId])) as IdRow[];
if (!companyRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_INVALID',
message: 'La Empresa relacionada seleccionada no es válida',
});
const concessionRows = (await manager.query(`
SELECT id,name
FROM concession_types
WHERE id=$1::uuid AND is_active=true
FOR KEY SHARE
`,[dto.concessionTypeId])) as Array<{id:string;name:string}>;
if (!concessionRows[0]) throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_CONCESSION_INVALID',
message: 'El Tipo de concesión seleccionado no es válido',
});
return {
companyId: dto.operatorCompanyId,
concessionTypeId: dto.concessionTypeId,
concessionName: concessionRows[0].name,
};
}
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
if (parent.operationalAreaId) return parent.operationalAreaId;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
@@ -502,40 +381,6 @@ export class InventoryStructureService {
return family;
}
private async ensureCompatibilityProjection(
manager: EntityManager,
areaId: string,
companyId: string,
concessionTypeId: string,
concessionName: string,
): Promise<void> {
const companyProjection = await manager.query(`
SELECT id FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_to IS NULL
LIMIT 1
`,[areaId,companyId]);
if (!companyProjection[0]) {
await manager.query(`
INSERT INTO area_company_relations(area_id,company_id,relation_role,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,'Proyección derivada de Yacimiento')
`,[areaId,companyId]);
}
const rightType = concessionName.toLocaleLowerCase('es-AR').includes('explor')
? 'EXPLORATION_PERMIT'
: 'EXPLOITATION_CONCESSION';
const rightProjection = await manager.query(`
SELECT id FROM area_legal_rights
WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND status='ACTIVE'
LIMIT 1
`,[areaId,rightType]);
if (!rightProjection[0]) {
await manager.query(`
INSERT INTO area_legal_rights(area_id,right_type,name,status,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4)
`,[areaId,rightType,`${concessionName} · proyección de Yacimiento`,`Tipo de concesión canónico: ${concessionTypeId}`]);
}
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
@@ -555,8 +400,6 @@ export class InventoryStructureService {
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN concession.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',concession.id,'code',concession.code,'name',concession.name) END AS "concessionType",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
@@ -566,9 +409,6 @@ export class InventoryStructureService {
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN concession_types concession ON concession.id=asset.concession_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
@@ -1,13 +1,10 @@
import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
@@ -32,22 +29,11 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
@@ -58,7 +44,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.activity.dossier(assetId) as LooseRecord;
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
@@ -159,9 +145,6 @@ export class MergedInventoryDossierService {
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;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return {
asset: {
@@ -211,7 +194,6 @@ export class MergedInventoryDossierService {
media,
versions,
timeline: timeline.slice(0, 1000),
warnings,
};
}
}
@@ -1,323 +0,0 @@
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 ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
};
@Injectable()
export class SimpleInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
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);
let operationalAreaId: string | null = null;
let operatorCompanyId: string | null = null;
if (dto.kind === 'YACIMIENTO') {
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
operationalAreaId = parent.id;
operatorCompanyId = dto.operatorCompanyId;
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
operationalAreaId = parent?.operationalAreaId ?? null;
operatorCompanyId = parent?.operatorCompanyId ?? null;
if (!operationalAreaId || !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
});
}
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
message: 'Empresa operadora sólo corresponde al Yacimiento',
});
}
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const rows = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,NULL,$5::uuid,
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
generatedCode,
dto.name,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
`inventory-simple:${dto.kind.toLowerCase()}`,
family ? `Tipo: ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = rows[0]?.id;
if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE
SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`, [id, dto.name, principal.userId]);
}
const versionNumber = await this.history.capture(
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,
dto.kind === 'YACIMIENTO'
? 'Alta de Yacimiento con Empresa operadora vinculada al Área'
: 'Alta simple de Inventario',
versionNumber,
request.requestId,
principal.userId,
]);
const created = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.ASSET_CREATED,
entityType: 'asset',
entityId: id,
afterData: created as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
simpleInventoryFlow: true,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un registro con ese código' });
}
throw error;
}
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const sql = kind === 'EMPRESA'
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind, 'EMPRESA'>]];
const rows = (await manager.query(sql, params)) as StructureTypeRow[];
if (!rows[0]) throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', message: `El nivel ${kind} no está configurado` });
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_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` });
return null;
}
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
FOR KEY SHARE OF asset
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent || parent.typeCode !== expectedType) {
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
}
return parent;
}
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
const rows = await manager.query(`
SELECT 1
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
LIMIT 1
FOR KEY SHARE OF relation
`, [areaId, companyId]);
if (!rows[0]) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
});
}
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
return null;
}
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
const rows = (await manager.query(`
SELECT id,code,name,level::text AS level
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
LIMIT 1
FOR KEY SHARE
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
const compatible = await manager.query(`
SELECT 1 FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`, [family.id, parent.inventoryFamilyId]);
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
}
return family;
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP'
: kind === 'AREA' ? 'AREA'
: kind === 'YACIMIENTO' ? 'YAC'
: kind === 'INSTALACION' ? 'INST'
: 'SUB';
const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
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 area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',family.id,'code',family.code,'name',family.name,'level',family.level) END AS "inventoryFamily",
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 assets area ON area.id=asset.operational_area_id
LEFT JOIN assets company ON company.id=asset.operator_company_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
@@ -33,7 +33,6 @@ export enum AssetDataOrigin {
@Index('idx_assets_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus'])
@@ -55,9 +54,6 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null;
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
concessionTypeId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@@ -1,39 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F62FreezeInspectionContext1790098200000 implements MigrationInterface {
name = 'F62FreezeInspectionContext1790098200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id
OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id
OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING
ERRCODE='23514',
MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación';
END IF;
RETURN NEW;
END $$
`);
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query(`
CREATE TRIGGER trg_inspection_context_immutable
BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id
ON inspection_visits
FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
`);
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()');
}
}
@@ -1,57 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface {
name = 'F63MobileFieldCommonAttributes1790099100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
WITH target_types AS (
SELECT id
FROM asset_types
WHERE lower(code) IN ('instalacion','subinstalacion')
), fields(code,name,sort_order) AS (
VALUES
('campo_marca','Marca',10),
('campo_modelo','Modelo',20),
('campo_capacidad','Capacidad',30),
('campo_numero_serie','Número de serie',40),
('campo_funcion','Función',50)
)
INSERT INTO asset_attribute_definitions (
asset_type_id, code, name, data_type, is_required, is_active, sort_order
)
SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type,
false, true, fields.sort_order
FROM target_types target
CROSS JOIN fields
WHERE NOT EXISTS (
SELECT 1
FROM asset_attribute_definitions existing
WHERE existing.asset_type_id=target.id
AND lower(existing.code)=lower(fields.code)
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM asset_attribute_values value
USING asset_attribute_definitions definition, asset_types type
WHERE value.definition_id=definition.id
AND definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
await queryRunner.query(`
DELETE FROM asset_attribute_definitions definition
USING asset_types type
WHERE definition.asset_type_id=type.id
AND lower(type.code) IN ('instalacion','subinstalacion')
AND definition.code IN (
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
)
`);
}
}
@@ -1,240 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const TARGET_CODES = [
'departamento',
'area',
'yacimiento',
'instalacion',
'subinstalacion',
] as const;
const DELETE_ORDER = [
'subinstalacion',
'instalacion',
'yacimiento',
'area',
'departamento',
] as const;
type ProtectedSnapshot = {
users: string;
companies: string;
companyProfiles: string;
assetTypes: string;
assetAttributes: string;
inventoryFamilies: string;
familyAttributes: string;
findingCategories: string;
findingItems: string;
};
export class ResetOperationalHierarchyData1790099200000 implements MigrationInterface {
name = 'ResetOperationalHierarchyData1790099200000';
public async up(queryRunner: QueryRunner): Promise<void> {
// This is a one-time live-data cleanup, not a new canonical empty seed.
// Fresh CI/bootstrap databases intentionally have no admin account while
// replaying the historical migration chain, so they must retain the F6.1
// presentation seed used by hierarchy/planning contract tests.
const adminRows = (await queryRunner.query(`
SELECT id
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[hierarchy-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Hierarchy reset aborted: expected exactly one live admin account, found ${adminRows.length}`,
);
}
const targetTypes = (await queryRunner.query(
`
SELECT lower(code) AS code
FROM asset_types
WHERE lower(code)=ANY($1::text[])
ORDER BY lower(code)
`,
[[...TARGET_CODES]],
)) as Array<{ code: string }>;
const found = new Set(targetTypes.map((row) => row.code));
const missing = TARGET_CODES.filter((code) => !found.has(code));
if (missing.length > 0) {
throw new Error(`Hierarchy reset aborted: missing asset types ${missing.join(', ')}`);
}
const before = await this.protectedSnapshot(queryRunner);
await queryRunner.query(
`
CREATE TEMP TABLE reset_target_assets ON COMMIT DROP AS
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
`,
[[...TARGET_CODES]],
);
await queryRunner.query(`CREATE UNIQUE INDEX reset_target_assets_pk ON reset_target_assets(id)`);
const [targetCount] = (await queryRunner.query(
`SELECT COUNT(*)::integer AS total FROM reset_target_assets`,
)) as Array<{ total: number }>;
// An Inspection freezes Area/Yacimiento/Operadora from creation and several
// inspection tables hold RESTRICT references to the hierarchy. Keeping a
// transaction that points to deleted territory would be invalid, so the
// complete disposable inspection graph is cleared first.
await queryRunner.query('TRUNCATE TABLE inspection_visits CASCADE');
// Legacy administrative departments are also presentation/operational data.
// Current F6 Departments live in assets, but this prevents old rows from
// resurfacing through compatibility paths.
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
// Legal-right participants depend on area_legal_rights rather than directly
// on assets. Remove them before the generic direct-FK cleanup below.
await queryRunner.query(`
DELETE FROM area_legal_right_organizations organization
USING area_legal_rights legal_right
WHERE organization.right_id=legal_right.id
AND legal_right.area_id IN (SELECT id FROM reset_target_assets)
`);
// Clean every table that directly references one of the hierarchy assets.
// This deliberately discovers the current schema instead of maintaining a
// fragile hand-written list as new dossier/history tables are added.
await queryRunner.query(`
DO $$
DECLARE dependency record;
BEGIN
FOR dependency IN
SELECT
namespace.nspname AS schema_name,
relation.relname AS table_name,
attribute.attname AS column_name
FROM pg_constraint constraint_row
JOIN pg_class relation ON relation.oid=constraint_row.conrelid
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
JOIN LATERAL unnest(constraint_row.conkey) WITH ORDINALITY local_key(attnum,ordinality)
ON true
JOIN LATERAL unnest(constraint_row.confkey) WITH ORDINALITY referenced_key(attnum,ordinality)
ON referenced_key.ordinality=local_key.ordinality
JOIN pg_attribute attribute
ON attribute.attrelid=constraint_row.conrelid
AND attribute.attnum=local_key.attnum
JOIN pg_attribute referenced_attribute
ON referenced_attribute.attrelid=constraint_row.confrelid
AND referenced_attribute.attnum=referenced_key.attnum
WHERE constraint_row.contype='f'
AND constraint_row.confrelid='assets'::regclass
AND constraint_row.conrelid<>'assets'::regclass
AND array_length(constraint_row.conkey,1)=1
AND referenced_attribute.attname='id'
ORDER BY namespace.nspname,relation.relname,attribute.attname
LOOP
EXECUTE format(
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM reset_target_assets)',
dependency.schema_name,
dependency.table_name,
dependency.column_name
);
END LOOP;
END $$;
`);
// parent_id is RESTRICT, therefore physical hierarchy rows are deleted from
// the leaves upward. Company/Operator assets are intentionally not targets.
for (const code of DELETE_ORDER) {
await queryRunner.query(
`
DELETE FROM assets asset
USING asset_types type
WHERE asset.asset_type_id=type.id
AND lower(type.code)=$1
`,
[code],
);
}
const after = await this.protectedSnapshot(queryRunner);
for (const key of Object.keys(before) as Array<keyof ProtectedSnapshot>) {
if (before[key] !== after[key]) {
throw new Error(
`Hierarchy reset verification failed: protected ${key} changed (${before[key]} -> ${after[key]})`,
);
}
}
const [verification] = (await queryRunner.query(`
SELECT
(
SELECT COUNT(*)::integer
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)=ANY($1::text[])
) AS hierarchy_assets,
(SELECT COUNT(*)::integer FROM administrative_departments) AS administrative_departments,
(SELECT COUNT(*)::integer FROM inspection_visits) AS inspection_visits
`, [[...TARGET_CODES]])) as Array<{
hierarchy_assets: number;
administrative_departments: number;
inspection_visits: number;
}>;
if (
!verification
|| Number(verification.hierarchy_assets) !== 0
|| Number(verification.administrative_departments) !== 0
|| Number(verification.inspection_visits) !== 0
) {
throw new Error(`Hierarchy reset verification failed: ${JSON.stringify(verification ?? {})}`);
}
// eslint-disable-next-line no-console
console.log(
`[hierarchy-reset] removed ${Number(targetCount?.total ?? 0)} Departamento/Área/Yacimiento/Instalación/Subinstalación assets; inspections and legacy departments cleared; users=${after.users}; companies=${after.companies} preserved`,
);
}
public async down(): Promise<void> {
throw new Error(
'ResetOperationalHierarchyData is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
private async protectedSnapshot(queryRunner: QueryRunner): Promise<ProtectedSnapshot> {
const [snapshot] = (await queryRunner.query(`
SELECT
(SELECT COUNT(*)::text FROM users) AS "users",
(
SELECT COUNT(*)::text
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companies",
(
SELECT COUNT(*)::text
FROM organization_profiles profile
JOIN assets asset ON asset.id=profile.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role='COMPANY'
) AS "companyProfiles",
(SELECT COUNT(*)::text FROM asset_types) AS "assetTypes",
(SELECT COUNT(*)::text FROM asset_attribute_definitions) AS "assetAttributes",
(SELECT COUNT(*)::text FROM inventory_families) AS "inventoryFamilies",
(SELECT COUNT(*)::text FROM inventory_family_attribute_definitions) AS "familyAttributes",
(SELECT COUNT(*)::text FROM finding_categories) AS "findingCategories",
(SELECT COUNT(*)::text FROM finding_catalog_items) AS "findingItems"
`)) as ProtectedSnapshot[];
if (!snapshot) throw new Error('Hierarchy reset aborted: could not snapshot protected masters');
return snapshot;
}
}
@@ -1,173 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* One-time full live-data reset requested before loading the definitive source files.
*
* The database is left with only the single `admin` user and the minimum product
* scaffolding required to keep authentication/authorization and the core asset
* model functional. Every business, operational, imported, catalog, history,
* document, inspection and tenant/company row is removed.
*
* Fresh migration replays/CI do not have the live `admin` account at this point,
* so this migration intentionally no-ops there.
*/
export class FullLiveDataReset1790103000000 implements MigrationInterface {
name = 'FullLiveDataReset1790103000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const adminRows = (await queryRunner.query(`
SELECT id, username
FROM users
WHERE lower(btrim(username))='admin'
ORDER BY id
`)) as Array<{ id: string; username: string }>;
if (adminRows.length === 0) {
// eslint-disable-next-line no-console
console.log('[full-live-reset] skipped: no live admin account on migration replay');
return;
}
if (adminRows.length !== 1) {
throw new Error(
`Full live reset aborted: expected exactly one username admin, found ${adminRows.length}`,
);
}
const adminId = adminRows[0].id;
// These are product/schema scaffolding, not customer/business data.
// Everything else in public is disposable live data for this reset.
const structuralTables = [
'roles',
'permissions',
'role_permissions',
'asset_types',
'asset_attribute_definitions',
'asset_type_parent_rules',
] as const;
const preservedTables = new Set<string>([
'typeorm_migrations',
'users',
'user_roles',
...structuralTables,
]);
const structuralCounts = new Map<string, string>();
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
structuralCounts.set(table, rows[0]?.total ?? '0');
}
const adminRolesBefore = (await queryRunner.query(
`SELECT count(*)::text AS total FROM user_roles WHERE user_id=$1`,
[adminId],
)) as Array<{ total: string }>;
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
if (adminRoleCount === '0') {
throw new Error('Full live reset aborted: admin has no assigned role');
}
const tableRows = (await queryRunner.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema='public'
AND table_type='BASE TABLE'
ORDER BY table_name
`)) as Array<{ table_name: string }>;
const disposableTables = tableRows
.map((row) => row.table_name)
.filter((table) => !preservedTables.has(table));
if (disposableTables.length > 0) {
const quoted = disposableTables
.map((table) => `"${table.replace(/"/g, '""')}"`)
.join(', ');
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
}
// Keep only the owner's administrator account. user_roles for other users
// are removed through their FK cascade.
await queryRunner.query(`DELETE FROM users WHERE id<>$1`, [adminId]);
// Invalidate any previous login state and unlock the preserved account.
await queryRunner.query(
`
UPDATE users
SET failed_login_attempts=0,
locked_until=NULL,
last_login_at=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`,
[adminId],
);
const finalUsers = (await queryRunner.query(`
SELECT
count(*)::text AS total,
count(*) FILTER (WHERE lower(btrim(username))='admin')::text AS admins
FROM users
`)) as Array<{ total: string; admins: string }>;
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
throw new Error('Full live reset verification failed: users table is not admin-only');
}
const finalAdminRoles = (await queryRunner.query(
`
SELECT
count(*) FILTER (WHERE user_id=$1)::text AS total,
count(*) FILTER (WHERE user_id<>$1)::text AS foreign_users
FROM user_roles
`,
[adminId],
)) as Array<{ total: string; foreign_users: string }>;
if (
finalAdminRoles[0]?.total !== adminRoleCount ||
finalAdminRoles[0]?.foreign_users !== '0'
) {
throw new Error('Full live reset verification failed: admin role assignments changed');
}
for (const table of structuralTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
const before = structuralCounts.get(table) ?? '0';
if (rows[0]?.total !== before) {
throw new Error(
`Full live reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
);
}
}
for (const table of disposableTables) {
const safeTable = `"${table.replace(/"/g, '""')}"`;
const rows = (await queryRunner.query(
`SELECT count(*)::text AS total FROM ${safeTable}`,
)) as Array<{ total: string }>;
if (rows[0]?.total !== '0') {
throw new Error(`Full live reset verification failed: ${table} is not empty`);
}
}
// eslint-disable-next-line no-console
console.log(
`[full-live-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} product tables; cleared ${disposableTables.length} data tables`,
);
}
public async down(): Promise<void> {
throw new Error(
'FullLiveDataReset is intentionally destructive; restore the automatic deploy PRE database backup instead.',
);
}
}
@@ -1,380 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { AUTHORITATIVE_INVENTORY_MODEL as SOURCE } from '../../reference-data/authoritative-inventory-model';
type IdRow = { id: string };
const TERRITORY_DOC = 'DH-AUTH-TERRITORY-20260911';
const INVENTORY_DOC = 'DH-AUTH-INVENTORY-20260911';
const FINDING_CATEGORY_CODE = 'AUTHMODEL';
function sourceCode(prefix: string, id: number): string {
return `${prefix}-${String(id).padStart(4, '0')}`;
}
export class AuthoritativeInventoryRelationships1790106600000 implements MigrationInterface {
name = 'AuthoritativeInventoryRelationships1790106600000';
public async up(q: QueryRunner): Promise<void> {
this.assertSource();
const companyType = await this.type(q, `SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC, created_at LIMIT 1`);
const departmentType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true LIMIT 1`);
const areaType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
const fieldType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
const installationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='instalacion' AND is_active=true LIMIT 1`);
const subinstallationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='subinstalacion' AND is_active=true LIMIT 1`);
await q.query(`
CREATE TABLE IF NOT EXISTS concession_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL UNIQUE,
name varchar(120) NOT NULL UNIQUE,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_concession_types_name CHECK (length(btrim(name)) >= 3)
)
`);
await q.query(`ALTER TABLE assets ADD COLUMN IF NOT EXISTS concession_type_id uuid`);
await q.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='fk_assets_concession_type') THEN
ALTER TABLE assets ADD CONSTRAINT fk_assets_concession_type
FOREIGN KEY (concession_type_id) REFERENCES concession_types(id) ON DELETE RESTRICT;
END IF;
END $$
`);
await q.query(`CREATE INDEX IF NOT EXISTS idx_assets_concession_type_id ON assets(concession_type_id)`);
await q.query(`
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
GRANT SELECT ON concession_types TO dhv2_app;
END IF;
END $$
`);
await q.query(`DELETE FROM asset_type_parent_rules WHERE child_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
await q.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) VALUES
($1::uuid,$2::uuid),($3::uuid,$1::uuid),($4::uuid,$3::uuid),($5::uuid,$4::uuid)
ON CONFLICT DO NOTHING
`, [areaType, departmentType, fieldType, installationType, subinstallationType]);
await q.query(`UPDATE asset_types SET can_be_root=true,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid)`, [companyType, departmentType]);
await q.query(`UPDATE asset_types SET can_be_root=false,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
await q.query(`UPDATE asset_types SET description='Área territorial. Sólo posee Nombre y pertenece obligatoriamente a un Departamento.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [areaType]);
await q.query(`UPDATE asset_types SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [fieldType]);
await q.query(`UPDATE asset_types SET description='Instalación física. Pertenece obligatoriamente a un Yacimiento y utiliza una clasificación técnica.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [installationType]);
await q.query(`UPDATE asset_types SET description='Subinstalación física. Pertenece obligatoriamente a una Instalación y utiliza una clasificación técnica compatible.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [subinstallationType]);
await q.query(`DELETE FROM asset_attribute_definitions WHERE asset_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid)`, [companyType, departmentType, areaType, fieldType, installationType, subinstallationType]);
await this.insertCommonAttributes(q, installationType, [
['campo_marca','Marca',10],
['tipo_instalacion','Tipo de instalación',20],
['campo_modelo','Modelo',30],
['campo_capacidad','Capacidad',40],
['campo_numero_serie','Número de serie',50],
['campo_funcion','Función',60],
]);
await this.insertCommonAttributes(q, subinstallationType, [
['campo_marca','Marca',10],
['campo_modelo','Modelo',20],
['campo_capacidad','Capacidad',30],
['campo_numero_serie','Número de serie',40],
['campo_funcion','Función',50],
]);
await q.query(`
DO $$ DECLARE trigger_name text; BEGIN
FOR trigger_name IN
SELECT trigger_row.tgname
FROM pg_trigger trigger_row
JOIN pg_proc function_row ON function_row.oid=trigger_row.tgfoid
WHERE trigger_row.tgrelid='assets'::regclass
AND NOT trigger_row.tgisinternal
AND function_row.proname='enforce_asset_operational_context'
LOOP
EXECUTE format('DROP TRIGGER %I ON assets',trigger_name);
END LOOP;
END $$
`);
await q.query(`DROP FUNCTION IF EXISTS enforce_asset_operational_context()`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
await q.query(`DROP TRIGGER IF EXISTS trg_assets_authoritative_inventory_relationships ON assets`);
await q.query(`
CREATE TRIGGER trg_assets_authoritative_inventory_relationships
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,concession_type_id
ON assets FOR EACH ROW EXECUTE FUNCTION enforce_authoritative_inventory_relationships()
`);
await q.query(`TRUNCATE TABLE assets CASCADE`);
await q.query(`TRUNCATE TABLE source_documents CASCADE`);
await q.query(`TRUNCATE TABLE inventory_families CASCADE`);
await q.query(`TRUNCATE TABLE finding_categories CASCADE`);
await q.query(`TRUNCATE TABLE concession_types CASCADE`);
const [territoryDoc] = (await q.query(`
INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id
`, [TERRITORY_DOC,'modelo_yacimientos.sql','sha256:6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a','Modelo relacional exacto de Departamentos, Áreas, Yacimientos, Empresas y Tipo de concesión.'])) as IdRow[];
const [inventoryDoc] = (await q.query(`
INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id
`, [INVENTORY_DOC,'modelo_relacional instalaciones y sub.sql','sha256:fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49','Modelo relacional exacto de tipos de Instalación/Subinstalación y sus Hallazgos.'])) as IdRow[];
if(!territoryDoc?.id || !inventoryDoc?.id) throw new Error('Authoritative source documents could not be created');
const concessionIds=new Map<number,string>();
for(const row of SOURCE.concessionTypes){
const [created]=(await q.query(`INSERT INTO concession_types(code,name,is_active) VALUES($1,$2,true) RETURNING id`,[sourceCode('CONC',row.id),row.nombre])) as IdRow[];
if(!created?.id)throw new Error(`Could not create concession ${row.nombre}`); concessionIds.set(row.id,created.id);
}
await this.seedFamiliesAndFindings(q);
const companyIds=new Map<number,string>();
for(const row of SOURCE.companies){
const id=await this.asset(q,companyType,null,null,null,null,sourceCode('ORG',row.id),row.nombre,'modelo_yacimientos.sql',`empresas:${row.id}`);
companyIds.set(row.id,id);
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name) VALUES($1::uuid,$2::organization_kind,$3)`,[id,row.nombre.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',row.nombre]);
await this.link(q,id,territoryDoc.id,`Empresa · source id ${row.id}`);
}
const departmentIds=new Map<number,string>();
for(const row of SOURCE.departments){
const id=await this.asset(q,departmentType,null,null,null,null,sourceCode('DEP',row.id),row.nombre,'modelo_yacimientos.sql',`departamentos:${row.id}`);
departmentIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Departamento · source id ${row.id}`);
}
const areaIds=new Map<number,string>();
for(const row of SOURCE.areas){
const parent=departmentIds.get(row.departamento_id); if(!parent)throw new Error(`Missing source Departamento ${row.departamento_id}`);
const id=await this.asset(q,areaType,parent,null,null,null,sourceCode('AREA',row.id),row.nombre,'modelo_yacimientos.sql',`areas:${row.id}`);
areaIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Área · source id ${row.id}`);
}
for(const row of SOURCE.fields){
const area=areaIds.get(row.area_id); const company=companyIds.get(row.empresa_id); const concession=concessionIds.get(row.tipo_concesion_id);
if(!area||!company||!concession)throw new Error(`Incomplete source relations for Yacimiento ${row.id}`);
const id=await this.asset(q,fieldType,area,area,company,concession,sourceCode('YAC',row.id),row.nombre,'modelo_yacimientos.sql',`yacimientos:${row.id}`);
await this.link(q,id,territoryDoc.id,`Yacimiento · source id ${row.id}`);
}
await q.query(`TRUNCATE TABLE area_company_relations CASCADE`);
await q.query(`TRUNCATE TABLE area_legal_rights CASCADE`);
const projectedPairs=new Set<string>();
const projectedRights=new Set<string>();
for(const row of SOURCE.fields){
const area=areaIds.get(row.area_id)!; const company=companyIds.get(row.empresa_id)!; const concession=concessionIds.get(row.tipo_concesion_id)!;
const pair=`${area}|${company}`;
if(!projectedPairs.has(pair)){
projectedPairs.add(pair);
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,[area,company,territoryDoc.id,'Derivado automáticamente de Yacimientos del modelo autoritativo']);
}
const rightKey=`${area}|${concession}`;
if(!projectedRights.has(rightKey)){
projectedRights.add(rightKey);
const concessionRow=SOURCE.concessionTypes.find((item)=>item.id===row.tipo_concesion_id)!;
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
area,concessionRow.nombre==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
`${concessionRow.nombre} · proyección del Yacimiento`,territoryDoc.id,'Compatibilidad derivada; el Tipo de concesión canónico está en Yacimiento.']);
}
}
await this.installFamilyFindingSync(q);
await this.verify(q);
}
public async down():Promise<void>{
throw new Error('Authoritative inventory model is a deliberate clean-load migration; restore the deploy PRE database backup.');
}
private assertSource():void{
if(SOURCE.departments.length!==7||SOURCE.companies.length!==13||SOURCE.concessionTypes.length!==2||SOURCE.areas.length!==64||SOURCE.fields.length!==230)
throw new Error('Authoritative territory source cardinality mismatch');
if(SOURCE.installationFamilies.length!==14||SOURCE.subinstallationFamilies.length!==109||SOURCE.findings.length!==181||SOURCE.installationFindings.length!==48||SOURCE.subinstallationFindings.length!==880)
throw new Error('Authoritative inventory source cardinality mismatch');
for(const area of SOURCE.areas) if(!SOURCE.departments.some((d)=>d.id===area.departamento_id)) throw new Error(`Area ${area.id} has no source Departamento`);
for(const field of SOURCE.fields){
if(!SOURCE.areas.some((a)=>a.id===field.area_id)||!SOURCE.companies.some((c)=>c.id===field.empresa_id)||!SOURCE.concessionTypes.some((c)=>c.id===field.tipo_concesion_id))
throw new Error(`Yacimiento ${field.id} has invalid source relationship`);
}
}
private async insertCommonAttributes(q:QueryRunner,typeId:string,rows:Array<[string,string,number]>):Promise<void>{
for(const [code,name,sortOrder] of rows) await q.query(`
INSERT INTO asset_attribute_definitions(asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order)
VALUES($1::uuid,$2,$3,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,$4)
`,[typeId,code,name,sortOrder]);
}
private async seedFamiliesAndFindings(q:QueryRunner):Promise<void>{
const installationIds=new Map<number,string>();
for(const row of SOURCE.installationFamilies){
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
VALUES($1,$2,'INSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-I',row.id),row.nombre,`modelo_relacional:instalaciones:${row.id}`])) as IdRow[];
if(!created?.id)throw new Error(`Could not create Instalación family ${row.id}`); installationIds.set(row.id,created.id);
}
const subIds=new Map<number,string>();
for(const row of SOURCE.subinstallationFamilies){
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
VALUES($1,$2,'SUBINSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-S',row.id),row.nombre,`modelo_relacional:subinstalaciones:${row.id}`])) as IdRow[];
const parent=installationIds.get(row.instalacion_id); if(!created?.id||!parent)throw new Error(`Invalid Subinstalación family ${row.id}`);
subIds.set(row.id,created.id);
await q.query(`INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) VALUES($1::uuid,$2::uuid)`,[created.id,parent]);
}
const [category]=(await q.query(`INSERT INTO finding_categories(code,name,sort_order,is_active) VALUES($1,$2,300,true) RETURNING id`,[FINDING_CATEGORY_CODE,'DH · Hallazgos del modelo autoritativo'])) as IdRow[];
if(!category?.id)throw new Error('Could not create authoritative finding category');
const findingIds=new Map<number,string>();
for(const row of SOURCE.findings){
const [created]=(await q.query(`INSERT INTO finding_catalog_items(category_id,code,source_number,title,import_note,revision,is_active)
VALUES($1::uuid,$2,$3,$4,$5,1,true) RETURNING id`,[category.id,sourceCode('AUTH-H',row.id),row.id,row.nombre,'modelo_relacional instalaciones y sub.sql'])) as IdRow[];
if(!created?.id)throw new Error(`Could not create Hallazgo ${row.id}`); findingIds.set(row.id,created.id);
await q.query(`INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,jsonb_build_object('id',item.id,'categoryId',item.category_id,'code',item.code,'sourceNumber',item.source_number,'title',item.title,'revision',item.revision,'isActive',item.is_active),'migration:AUTHORITATIVE'
FROM finding_catalog_items item WHERE item.id=$1::uuid`,[created.id]);
}
for(const mapping of SOURCE.installationFindings){
const family=installationIds.get(mapping.instalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
if(!family||!finding)throw new Error('Invalid installation finding mapping');
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
}
for(const mapping of SOURCE.subinstallationFindings){
const family=subIds.get(mapping.subinstalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
if(!family||!finding)throw new Error('Invalid subinstallation finding mapping');
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
}
}
private async installFamilyFindingSync(q:QueryRunner):Promise<void>{
await q.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides WHERE asset_id=NEW.id AND reason LIKE 'AUTHORITATIVE familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by)
SELECT NEW.id,m.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families m WHERE m.inventory_family_id=NEW.inventory_family_id
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$
`);
await q.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides o USING assets a
WHERE o.asset_id=a.id AND a.inventory_family_id=OLD.inventory_family_id AND o.catalog_item_id=OLD.catalog_item_id AND o.reason LIKE 'AUTHORITATIVE familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by)
SELECT a.id,NEW.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',a.created_by,a.updated_by
FROM assets a WHERE a.inventory_family_id=NEW.inventory_family_id
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$
`);
}
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,companyId:string|null,concessionId:string|null,
code:string,name:string,sourceName:string,sourceReference:string):Promise<string>{
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
code,name,information_status,operational_status,data_origin,source_name,source_reference,is_inventory_instance)
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,NULL,$6,$7,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$8,$9,false) RETURNING id`,
[typeId,parentId,areaId,companyId,concessionId,code,name,sourceName,sourceReference])) as IdRow[];
if(!row?.id)throw new Error(`Could not seed ${code}`); return row.id;
}
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes) VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
}
private async type(q:QueryRunner,sql:string):Promise<string>{
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing canonical asset type: ${sql}`); return rows[0].id;
}
private async verify(q:QueryRunner):Promise<void>{
const [row]=await q.query(`SELECT
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer fields,
(SELECT COUNT(*) FROM concession_types)::integer concessions,
(SELECT COUNT(*) FROM inventory_families WHERE level='INSTALLATION')::integer installation_families,
(SELECT COUNT(*) FROM inventory_families WHERE level='SUBINSTALLATION')::integer subinstallation_families,
(SELECT COUNT(*) FROM finding_catalog_items)::integer findings,
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='INSTALLATION')::integer installation_mappings,
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='SUBINSTALLATION')::integer subinstallation_mappings,
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id JOIN assets a ON a.id=y.parent_id WHERE lower(t.code)='yacimiento' AND (y.operational_area_id IS DISTINCT FROM a.id OR y.operator_company_id IS NULL OR y.concession_type_id IS NULL))::integer invalid_fields`);
const expected:Record<string,number>={companies:13,departments:7,areas:64,fields:230,concessions:2,installation_families:14,subinstallation_families:109,findings:181,installation_mappings:48,subinstallation_mappings:880,invalid_fields:0};
for(const [key,value] of Object.entries(expected)) if(Number(row?.[key]??-1)!==value)throw new Error(`Authoritative inventory verification failed: ${key}=${row?.[key]} expected=${value}`);
}
}
@@ -1,179 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F7SimpleInventoryContext1790110200000 implements MigrationInterface {
name = 'F7SimpleInventoryContext1790110200000';
public async up(q: QueryRunner): Promise<void> {
await q.query(`
UPDATE asset_types
SET description='Yacimiento. Pertenece a un Área, tiene una única Empresa operadora vigente y puede recibir Hallazgos.',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='yacimiento'
`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere una Empresa operadora'; END IF;
SELECT type.operational_role INTO company_role
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa operadora del Yacimiento no es válida';
END IF;
IF NOT EXISTS(
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=NEW.parent_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa del Yacimiento debe estar vinculada como explotadora del Área';
END IF;
IF NEW.concession_type_id IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de concesión indicado no es válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre';
END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Yacimiento debe tener una Empresa operadora'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(
SELECT 1 FROM inventory_family_parent_rules rule
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no está habilitado dentro del Tipo de instalación padre';
END IF;
IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación debe pertenecer a un Yacimiento con Empresa operadora'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`
UPDATE asset_types
SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',
updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='yacimiento'
`);
await q.query(`
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
kind text;
asset_role asset_type_operational_role;
parent_kind text;
parent_area uuid;
parent_company uuid;
parent_family uuid;
company_role asset_type_operational_role;
family_level text;
BEGIN
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
IF asset_role='COMPANY'::asset_type_operational_role THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
ELSIF kind='departamento' THEN
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
RETURN NEW;
END IF;
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
INTO parent_kind,parent_area,parent_company,parent_family
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
IF kind='area' THEN
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='yacimiento' THEN
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF;
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
END IF;
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
ELSIF kind='instalacion' THEN
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
ELSIF kind='subinstalacion' THEN
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
END IF;
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
END IF;
RETURN NEW;
END $$
`);
}
}
@@ -1,6 +1,7 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
@@ -41,6 +42,7 @@ export class CreateInspectionActDto {
observations?: string | null;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@ArrayUnique()
@IsUUID('4', { each: true })
@@ -5,16 +5,10 @@ import {
InspectionVisitActsController,
} from './inspection-acts.controller';
import { InspectionActsService } from './inspection-acts.service';
import { MobileInspectionActsController } from './mobile-inspection-acts.controller';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Module({
imports: [AuditModule],
controllers: [
InspectionVisitActsController,
MobileInspectionActsController,
InspectionActsController,
],
providers: [InspectionActsService, MobileInspectionActsService],
controllers: [InspectionVisitActsController, InspectionActsController],
providers: [InspectionActsService],
})
export class InspectionActsModule {}
@@ -656,7 +656,6 @@ export class InspectionActsService {
visitId: string,
assetIds: string[],
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets
@@ -664,7 +663,7 @@ export class InspectionActsService {
AND asset_id = ANY($2::uuid[])
AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== assetIds.length) {
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección',
@@ -1,18 +0,0 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Controller('inspection-visits/:visitId/acts/mobile')
export class MobileInspectionActsController {
constructor(private readonly acts: MobileInspectionActsService) {}
@Get()
@RequirePermissions('inspection_acts.read')
list(
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
@Query() query: ListInspectionActsQueryDto,
) {
return this.acts.listForVisit(visitId, query);
}
}
@@ -1,102 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
/**
* Read model intentionally kept small for the field client.
*
* The Android app only needs the Act identity/lifecycle, deadlines and counters to
* open an Inspection. It must not fail because an office-document/report module is
* unavailable or temporarily schema-drifted. The canonical office endpoints keep
* their richer projection in InspectionActsService.
*/
@Injectable()
export class MobileInspectionActsService {
constructor(private readonly dataSource: DataSource) {}
async listForVisit(visitId: string, query: ListInspectionActsQueryDto) {
const [visit] = (await this.dataSource.query(
'SELECT id FROM inspection_visits WHERE id = $1::uuid',
[visitId],
)) as Array<{ id: string }>;
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
const conditions = ['act.visit_id = $1::uuid'];
const parameters: unknown[] = [visitId];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`);
}
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const where = `WHERE ${conditions.join(' AND ')}`;
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = await this.dataSource.query(`
SELECT
act.id,
act.visit_id AS "visitId",
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.title,
act.summary,
act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_at AS "deadlineBaseAt",
act.deadline_at AS "deadlineAt",
act.locked_at AS "lockedAt",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "currentVersion",
act.closed_at AS "closedAt",
act.closure_sha256 AS "closureSha256",
COALESCE((
SELECT COUNT(*)::integer
FROM inspection_act_assets link
WHERE link.act_id = act.id AND link.included = true
), 0)::integer AS "assetCount",
COALESCE((
SELECT COUNT(*)::integer
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
), 0)::integer AS "findingCount"
FROM inspection_acts act
${where}
ORDER BY act.act_year DESC, act.act_number DESC
LIMIT ${limit} OFFSET ${offset}
`, parameters);
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
}
@@ -1,9 +1,9 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
objective?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' })
@IsUUID('4')
scopeAssetId?: string | null;
@IsOptional()
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' })
@IsUUID('4')
operationalAreaId?: string | null;
@IsOptional()
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' })
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService {
principal: AuthPrincipal,
) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
const effectiveParentId = parentId ?? base.parent.id;
const effectiveParentId = parentId ?? base.context.area.id;
const parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode
@@ -129,7 +129,6 @@ export class FieldFindingsService {
visit.id AS "visitId",
visit.code AS "visitCode",
visit.status AS "visitStatus",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName",
@@ -140,7 +139,8 @@ export class FieldFindingsService {
asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId",
(
asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
asset.operational_area_id=visit.operational_area_id
OR asset.id=visit.operational_area_id
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -148,11 +148,9 @@ export class FieldFindingsService {
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors
WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
LIMIT 1
SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
)
) AS "insideScope",
) AS "insideArea",
EXISTS (
SELECT 1
FROM area_company_relations relation
@@ -215,7 +213,6 @@ export class FieldFindingsService {
visitId: string;
visitCode: string;
visitStatus: string;
scopeAssetId: string | null;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
@@ -225,7 +222,7 @@ export class FieldFindingsService {
assetId: string | null;
assetTypeCode: string | null;
inventoryFamilyId: string | null;
insideScope: boolean;
insideArea: boolean;
operatorRelationValid: boolean;
assigned: boolean;
selected: boolean;
@@ -281,10 +278,10 @@ export class FieldFindingsService {
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
});
}
if (!row.insideScope) {
if (!row.insideArea) {
throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección',
message: 'El Inventario no pertenece al Área de esta inspección',
});
}
if (!row.selected) {
@@ -23,7 +23,6 @@ interface MobileVisitContext {
code: string;
status: string;
areaId: string;
scopeAssetId: string;
areaCode: string;
areaName: string;
companyId: string;
@@ -81,12 +80,12 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context);
// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope.
const args: unknown[] = [context.scopeAssetId, visitId];
// Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.areaId, visitId];
const conditions = [
"asset.information_status <> 'INACTIVE'",
`(
asset.id = $1::uuid
asset.operational_area_id = $1::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -199,7 +198,7 @@ export class FieldInventoryService {
principal: AuthPrincipal,
) {
const context = await this.requireVisitContext(visitId, principal, false);
const effectiveParentId = parentId ?? context.scopeAssetId;
const effectiveParentId = parentId ?? context.areaId;
const parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(`
@@ -273,7 +272,7 @@ export class FieldInventoryService {
request: RequestWithContext,
) {
const context = await this.requireVisitContext(visitId, principal, true);
const parentId = dto.parentId ?? context.scopeAssetId;
const parentId = dto.parentId ?? context.areaId;
await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt);
@@ -413,7 +412,6 @@ export class FieldInventoryService {
visit.id,
visit.code,
visit.status,
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName",
@@ -487,7 +485,6 @@ export class FieldInventoryService {
private publicContext(context: MobileVisitContext) {
return {
inspection: { id: context.id, code: context.code, status: context.status },
scopeAssetId: context.scopeAssetId,
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: {
id: context.companyId,
@@ -506,6 +503,7 @@ export class FieldInventoryService {
asset.asset_type_id AS "typeId",
(
asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -514,16 +512,16 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideScope"
) AS "insideArea"
FROM assets asset
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [parentId, context.scopeAssetId])) as Array<{
`, [parentId, context.areaId])) as Array<{
id: string;
code: string;
name: string;
typeId: string;
insideScope: boolean;
insideArea: boolean;
}>;
if (!parent) {
@@ -532,10 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe',
});
}
if (!parent.insideScope) {
if (!parent.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección',
message: 'La ubicación padre no pertenece al Área de la inspección',
});
}
return parent;
@@ -547,7 +545,7 @@ export class FieldInventoryService {
asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
(
asset.id=$2::uuid
asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -556,15 +554,15 @@ export class FieldInventoryService {
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideScope"
) AS "insideArea"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [assetId, context.scopeAssetId])) as Array<{
`, [assetId, context.areaId])) as Array<{
id: string;
eligibleTarget: boolean;
insideScope: boolean;
insideArea: boolean;
}>;
if (!asset) {
@@ -573,10 +571,10 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado',
});
}
if (!asset.insideScope) {
if (!asset.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección',
message: 'El registro no pertenece al Área de esta inspección',
});
}
if (!asset.eligibleTarget) {
@@ -10,9 +10,9 @@ import { validateInspectionPlanningScope } from './inspection-planning-scope';
import { InspectionVisitsService } from './inspection-visits.service';
/**
* Creación WEB. La ubicación territorial es Departamento → Área → Yacimiento.
* Empresa y Tipo de concesión pertenecen directamente al Yacimiento; por eso
* la Operadora elegida debe coincidir con el Yacimiento seleccionado.
* Creación WEB F6.1. Separa explícitamente ubicación territorial y alcance:
* Departamento → Área → Yacimiento. La Operadora no es un padre físico; se
* valida contra la relación temporal vigente del Área para la fecha planificada.
*/
@Injectable()
export class InspectionPlanningCreateService {
@@ -52,27 +52,27 @@ export class InspectionPlanningCreateService {
stage = 'operator:validate';
const [context] = await manager.query(`
SELECT yacimiento.id
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
SELECT relation.id
FROM area_company_relations relation
INNER JOIN assets area ON area.id=relation.area_id
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
INNER JOIN assets company ON company.id=relation.company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND yacimiento.operator_company_id=$3::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND yacimiento.concession_type_id IS NOT NULL
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
AND area_type.operational_role='AREA'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND area.information_status<>'INACTIVE'
AND company.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, dto.operationalAreaId, dto.operatorCompanyId]) as Array<{ id: string }>;
`, [dto.operationalAreaId, dto.operatorCompanyId, plannedStartAt]) as Array<{ id: string }>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO',
message: 'La Empresa seleccionada no es la Empresa relacionada con el Yacimiento',
code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA',
message: 'La Operadora seleccionada no está vigente para el Área en la fecha de la Inspección',
});
}
@@ -129,6 +129,8 @@ export class InspectionPlanningCreateService {
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
// El checklist sigue el alcance físico del Yacimiento. No usa
// assets.operator_company_id, que en F6 es sólo snapshot histórico.
stage = 'checklist:insert';
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
@@ -165,49 +167,6 @@ export class InspectionPlanningCreateService {
ORDER BY finding.created_at,finding.id
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
stage = 'checklist:assets';
await manager.query(`
INSERT INTO inspection_visit_assets (
visit_id, asset_id, included, planning_source, added_by
)
SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
ON CONFLICT (visit_id,asset_id) DO UPDATE SET
included=true,
planning_source=CASE
WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'
ELSE 'AUTOMATIC'
END,
exclusion_reason=NULL,
excluded_by=NULL,
excluded_at=NULL,
added_by=EXCLUDED.added_by,
updated_at=CURRENT_TIMESTAMP
`, [visit.id, principal.userId]);
stage = 'checklist:asset-events';
await manager.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, reason, actor_user_id, metadata
)
SELECT DISTINCT
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
jsonb_build_object('generation',1,'source','AUTHORITATIVE_CREATE')
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
AND NOT EXISTS (
SELECT 1 FROM inspection_visit_asset_events event
WHERE event.visit_id=$1::uuid
AND event.asset_id=item.asset_id
AND event.event_type='AUTO_INCLUDED'
)
`, [visit.id, principal.userId]);
stage = 'audit:record';
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -222,7 +181,6 @@ export class InspectionPlanningCreateService {
operationalAreaId: dto.operationalAreaId,
scopeAssetId,
operatorCompanyId: dto.operatorCompanyId,
operatorSource: 'YACIMIENTO',
plannedStartAt: plannedStartAt.toISOString(),
leadInspectorUserId: dto.leadInspectorUserId,
},
@@ -242,7 +200,7 @@ export class InspectionPlanningCreateService {
const technical = error instanceof Error
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
: String(error);
this.logger.error(`inspection create failed at ${stage}: ${technical}`);
this.logger.error(`F6.1 inspection create failed at ${stage}: ${technical}`);
throw error;
}
}
@@ -62,42 +62,6 @@ export class InspectionPlanningHierarchyService {
return { data };
}
async operatorsForArea(
areaId: string,
at?: string,
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
if (at) {
const effectiveAt = new Date(at);
if (!Number.isFinite(effectiveAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_PLANNING_DATE_INVALID',
message: 'La fecha de planificación no es válida',
});
}
}
// Modelo autoritativo: la Empresa pertenece al Yacimiento. Para mantener el
// selector de planificación por Área, se proyectan las Empresas de todos los
// Yacimientos activos que pertenecen a esa Área.
const data = await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE yacimiento.parent_id=$1::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY name, company.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
return { data };
}
private async requireType(id: string, typeCode: string, message: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1
@@ -1,17 +0,0 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
@Controller('inspection-visits')
export class InspectionPreventiveCandidatesController {
constructor(private readonly candidates: InspectionPreventiveCandidatesService) {}
@Get(':id/preventive-candidates')
@RequirePermissions('inspections.read')
list(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Query('search') search?: string,
) {
return this.candidates.list(id, search);
}
}
@@ -1,77 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
@Injectable()
export class InspectionPreventiveCandidatesService {
constructor(private readonly dataSource: DataSource) {}
async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> {
const [context] = (await this.dataSource.query(`
SELECT
visit.operational_area_id AS "operationalAreaId",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId"
FROM inspection_visits visit
WHERE visit.id = $1::uuid
`, [visitId])) as Array<{
operationalAreaId: string | null;
scopeAssetId: string | null;
}>;
if (!context) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] };
const term = search?.trim().slice(0, 200) ?? '';
const rows = (await this.dataSource.query(`
WITH RECURSIVE scope_tree AS (
SELECT root.id
FROM assets root
WHERE root.id = $2::uuid
UNION ALL
SELECT child.id
FROM assets child
INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id
)
SELECT
asset.id,
asset.code,
asset.name,
asset_type.name AS "typeName"
FROM scope_tree
INNER JOIN assets asset ON asset.id = scope_tree.id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE asset.id <> $2::uuid
AND asset.operational_area_id = $3::uuid
AND asset.information_status <> 'INACTIVE'
AND lower(asset_type.code) IN ('instalacion', 'subinstalacion')
AND NOT EXISTS (
SELECT 1
FROM inspection_visit_assets linked
WHERE linked.visit_id = $1::uuid
AND linked.asset_id = asset.id
)
AND (
$4::text = ''
OR asset.code ILIKE '%' || $4::text || '%'
OR asset.name ILIKE '%' || $4::text || '%'
OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%'
)
ORDER BY asset_type.name, asset.name, asset.code
LIMIT 100
`, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[];
return { data: rows };
}
}
@@ -82,9 +82,8 @@ export class InspectionVisitsController {
@RequirePermissions('inspections.read')
planningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@Query('at') at?: string,
) {
return this.planningHierarchy.operatorsForArea(areaId, at);
return this.visits.listPlanningOperators(areaId);
}
@Get('mobile/planning-context/areas')
@@ -10,25 +10,17 @@ import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service';
@Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [
InspectionVisitsController,
InspectionPreventiveCandidatesController,
FieldInventoryController,
FieldFindingsController,
],
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionPreventiveCandidatesService,
InspectionVisitLifecycleService,
FieldInventoryService,
F3FieldInventoryStructureService,
@@ -23,7 +23,6 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
export interface InspectionPerson {
id: string;
@@ -313,7 +312,7 @@ export class InspectionVisitsService {
}
const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt);
@@ -383,24 +382,29 @@ export class InspectionVisitsService {
? visit.operatorCompanyId
: dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);
const nextScope = contextTouched
? nextAreaId
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
await this.requireAsset(manager, nextScope);
if (nextAreaId && nextScope) {
await validateInspectionPlanningScope(manager, nextAreaId, nextScope);
if (contextTouched && nextAreaId && nextCompanyId) {
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
} else if (dto.scopeAssetId !== undefined) {
await this.assertCurrentAssetsInScope(manager, id, nextScope);
}
if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) {
visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId;
}
if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = nextAreaId;
} else if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId;
}
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions;
if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id);
@@ -459,10 +463,11 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
});
}
await this.assertAssetsInScope(
await this.assertAssetsMatchContext(
manager,
additions,
visit.scopeAssetId ?? visit.operationalAreaId,
visit.operationalAreaId,
visit.operatorCompanyId,
);
for (const assetId of additions) {
await manager.query(`
@@ -624,10 +629,11 @@ export class InspectionVisitsService {
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
}
await this.assertAssetsInScope(
await this.assertAssetsMatchContext(
manager,
[assetId],
visit.scopeAssetId ?? visit.operationalAreaId,
visit.operationalAreaId,
visit.operatorCompanyId,
);
await manager.query(`
UPDATE inspection_visit_assets
@@ -914,7 +920,6 @@ export class InspectionVisitsService {
manager: EntityManager,
operationalAreaId: string | null,
operatorCompanyId: string | null,
effectiveAt: Date | null = null,
): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) {
@@ -933,8 +938,8 @@ export class InspectionVisitsService {
WHERE relation.area_id = area.id
AND relation.company_id = company.id
AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= $3::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
) AS "activeOperatorRelation"
FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -946,7 +951,7 @@ export class InspectionVisitsService {
AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{
`, [operationalAreaId, operatorCompanyId])) as Array<{
areaId: string;
companyId: string;
activeOperatorRelation: boolean;
@@ -965,6 +970,44 @@ export class InspectionVisitsService {
}
}
private async assertAssetsMatchContext(
manager: EntityManager,
assetIds: string[],
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM assets asset
WHERE asset.id = ANY($1::uuid[])
AND (
asset.operational_area_id IS DISTINCT FROM $2::uuid
OR asset.operator_company_id IS DISTINCT FROM $3::uuid
)
`, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT',
message: 'Uno o más registros no pertenecen al Área y Operadora de la visita',
});
}
}
private async assertCurrentAssetsMatchContext(
manager: EntityManager,
visitId: string,
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
await this.assertAssetsMatchContext(
manager,
await this.activeAssetIds(manager, visitId),
operationalAreaId,
operatorCompanyId,
);
}
private async appendAssetEvent(
manager: EntityManager,
visitId: string,
@@ -998,7 +1041,7 @@ export class InspectionVisitsService {
message: 'Indicá la fecha prevista antes de generar el checklist',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt);
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
const generation = visit.checklistGeneration + 1;
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
@@ -1063,25 +1106,14 @@ export class InspectionVisitsService {
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED'
AND (
asset.id=$4::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=$4::uuid LIMIT 1
)
)
ORDER BY finding.created_at, finding.id
`, [
visit.id,
generation,
visit.operationalAreaId,
visit.scopeAssetId ?? visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt.toISOString().slice(0, 10),
]);
@@ -1263,12 +1295,7 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
});
}
await this.validatePlanningContext(
manager,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt,
);
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
if (!visit.scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED',
@@ -1323,10 +1350,13 @@ export class InspectionVisitsService {
message: 'El equipo debe incluir al inspector responsable',
});
}
// F6.1: Inventory membership is physical (Yacimiento scope).
// Company is temporal Area context and must never be reconstructed from
// assets.operator_company_id, which is only a creation/historical snapshot.
await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId);
await this.assertCurrentAssetsMatchContext(
manager,
visit.id,
visit.operationalAreaId,
visit.operatorCompanyId,
);
}
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
@@ -1 +0,0 @@
export default 'eNqtfUtz5MiR5l+B4bBWZVZdlQgAAUA3MAmS6M1X54Pq7hnZGCqJYqM3maDyUdZVMh103MMctPMLdJyDDmO6jc2Nf2w9gCQZAQQSHwo6yMSu9PgCCHeP8Fc4/mTepY/J7vCQbg9783f/8iczuzN/Z70zt/nDx11q/s5cJFtjmOw2+d7887vyZyb9PM8+J3fJ5yx5+dWWfh0df33629a4S43h8Uv+QuJIJONkk+zun/4nffnVrcw+Tz4l6eblZy79vDw+Hrf3yeEV2pNnT4xZ8tX88x/emev84THZZqn+HWfpYff0j02a743F8S55SHfZOtnSf0XbdHf/xVi8D9/r3346vFF/ld9+tYyMNz+s4skynIRG9L9mRji/jibLmP5r8X7+fvT+nfH68ySaX/9kxJPbaLEcE9WiAH5nXIf013I6D68jI5xcGuP4crGcR+HYWETz23gYPRMOp+NZ+PTXp38PjeXiovjHt9olX2RbI3p43KX7xJg+prvkLt8l2uW/SQ4HWov/Iy/E4r2eFcn2PtcumMyS23SbZxv1d1/6/afZlfpjILNpOFd/tAbyr5vj/pEYmVfgLZnT0fvx++i9+rvM0NkvebrNfjOuN/nHZGPM031+3K3TfQVS5vL1l2tjKt4p3X3O1umJsyep29LYfZZvl18eG2Qv+u1xkx+Sdfb0j61WxgqC3TMBoSa7NFGwShVOhA7n/1bFv82+rpP1L8RlRYFrY5iqOJ/ph2SraHVtiLwKF8lul2zX0iSOZoQsheH9MTEu6NXvFNk7P2ZzyB43pMjrLKVdZUP6/eW4leXx7HuF9EO2/+Mxk2WzNsJVRhxpFpkFxiTfHVJZeAGAxXH3CpJvZekGRhczGlpBERpwli9DwfvjIVHU4ewaD5NfxWvSnk17Pm39pAfy5m+x1vGbh+M6V5SlZcDTfyV3Ys7wIdllNNvrWKeFo8P0E23WW2lEmwgN090uNy5zmtP4/iiLuMWhoVfEjSMdDtKcHjRwnG/oqHsd5beN+oV4l/xyTLdCfl7H6YSGq+Po5Wh3N25oXtrcJcUftM6Z/XLM7vKC/YmxSDaZtELM+pbhytMz1hEiE+9gTNJ76YhirSI1MW7j2AhfRzitI767faVuFaJ8+2lDjFlnlbdrE6FoY4yT7ddEkljmtY+Zkcwp50PbtkNjFvl+mxL3XwcFLRNd5x/T3VZYA0b4hcyy182/TWy+T4QFt6HdgkwKYtwsS+8kftltckMHzgWdaq8DWMv7FabdJn+QhrTJxCjb3qW7XPd4bcIx2oijbZtKO5PdJiIjejhjSHxLaNbk1Xy1eduriYGl8I9l5bM9ZEZiwmX2OduLWaWn1cmLYirTVr/Md3di990b03QvHXF224YzFobkbbq7ex3jDFreUryaUe6L4YZOZDLA//Yq306bwMyS/WmH2BtketzR/6nnssNaEb48/fe28qqO3fKqpeTQsZiLs5ym/S7aH3bH9eFIm1S8PaS7h/ROcoacNtGa0QGfSi/uttPvD7nY2bcJyfPX15EcG0mnkFj6ymp5be9dDp7lX3PBtTVtEsZ0vc7uhJn4KtyO/y04u6yCEmCvMs9/lexNd9A6ansg+U4Kw/HmmG3Xssi5Vsujz9NPx/ssN+o+p9sma3MaVphyr0PsFsNmXsy0PX5JpEdsE6bTYRl+PW6UY8l1sYFiYSbpZ/nQcNvE6pbMw9IWF498vRNS+Tq6zb6+Te+T2iC/ZW1+TsiZl9YyAM0vYQsZkazvfNBuSMRkSKjawtt2p3mypS1tUx3WJiYX+SZV923eeqjRbj1Ldpvy51dl4A5uXJ6ekjzLT1m6udO7qaWRTiJCG51Z+qDPrmZahhKe/5M8tPzfSs+XXJ3yX3V+rVDHRN3DZWDWCVjvikpwdic4eaVW97SKKXlR3w7nNri9EqCjArLzgLzdJ5awXRXbPo/t1TRnlNGWt06+HdKvQQp7Pv92wEAPWGx63wyqRJNeQwUSHu/EdSXw9IJXfcaOmIrzS4KefU02QonCj8mveQ9cW3UefjimD+IY+HbA6i4ltDIlf1DsUt+O6tatgHDzKdn1eVIlilkchPS0m9oO0hHVa0YttHRMdmKfp/bP4Jc7+rdjB/oQlIzoqYiOFpHJcYd6SEzG81U8twXPqp6B+boqWR2fkGlM5UwYJkkPULstlpcrG3WgovOWw9TRHXonj+abzyrmalEr7OqIyZWc0HYjjv7vnrevHrheFyXoiO3roqiy5TNQ8bwWvECNseb1EKsMbnWyB+yBDrx09frAWrVz9tVG7QHLKlEL0t90I7zp0jrvgWwrLtSWyF6Dyj1gdfFtGY512hZst2ZYNz2n3cmCsXmNXycZm2R3FQHriOyp9sHwl2x7n/d7WL8S48zIIdmq20FXyEAPWd29OsI6AzXyOCJv4Pg16YOoZJ8Tsg3zXS885fQSG9/XY9qLO45Gk4qdtReoUwclAY3262STb3stgKtLCsl4TjcHkKssp0ekBc0qJ0BHTE8TCNtl6aHXg/pnc1sysNtpk3IUI3B7yNbiMNkYRZQ5POySzwSkGkdWxcz0W7ziwZncWg9Yqxm2bhx1BWdnwKu7TEdoW2OBJtu9CFn2gVWCdflmnWyNRSJCL2M6b3ohuw0pzrrpZVXs5qAFmTcEezX864jsNT1zb2S/OXUro/rd1jg4n9iVkbt5D3zQmvaVo3GDTtsxt1qTwsN0exBxmz6zMHSWRZ9Z7NZZavLedQosh95jAtWp+zU3hNM8yj+qIsSsbqi882N3nMBDJijshz6z+PVkOxkmVVekK2rFFv05qVgkHfG8gdZ2GuYirNYH1zqD25N/Xv20FHI3Tjeb7GtF9FinfdGTlfIy3X/KNoVXalwkX/I+uM4Z3LpIdASvVGiM812vZ+UVuU2yfbLthSjr2/WRzLtNHzRftaGH+aZiMHYFDCpeU5HT6wHoD85l1nvgWk159z6gmghlUQPTh+O+rauakvFsBO/Fa/BrNVUyltMp';
@@ -1 +0,0 @@
export default 'yOu7ZyquZFi3k+Hjy2pzcSQpmqebz8cHBZJ3iu35nrJrkCqmh0x1Yroi+pUA9zi/zw/pvg9kUIEsnYFemMGgWtKz/CXNeiFamjz6sM9SBtWyilO5QQ9EJaxPpxodDcNcmCfputeTOnXc8rzcHtd5r1V19TWIPRC5FrHuRnXF9fS4VZ3viFo18ZJ9NdTaFVHRpuxoDI9k1Gx7IFqDgbYKUATFH0m19r2wNScSHck/kHYlvXCZ1nauO0Rex1S83VDMKmP6nRx0a1C5SUBCVYlgs4oLLVI85yHrIZCb5CFZ61zCztj1YP7NMdkeN/1QvQbUmoZ1Rq7oGHkQa3Iw+2EGWsx/wuoqxR768uW6ud95Eqt9kr7LbjFATOxBV1RZ/35K1tmDqNEUx9zNwki3xjA1LtJt+ilb04H6/XDWb7JKtbu29qY7qqt/hYjM3OINisjQP/EtuDrfe4M4fqwzvJj7Iu0/n9dZuLrP4evXkI6OcPOY7O6FE/ZPeyHVvXt4fC4q7wPKBpprBjKg1e0QUStPNsI0qduTnUGZ7nQmCz29r1QxdgXW5RSOT/+Vf+2F6lRu6F6JIpFsrxq/nVH19ztkRNYRkVcs/5t8WzEhOhbfeW0Vgl0BfU2J7WWlxLYrZuXMvCC/kWzdbQ9IpbzktTpQcwx3xLVU3Hm+T3oxSKksEXppXCS/ijUti4L6INtqaGybrHNNQrwrauUS1O3p1m0PRFeLaPyrGf6r2QeX63GLY7QSJuyI7OmRa9ZXR1hfD9tbZgM9btWO61gtqx5Te9qqdg/ZfZ+SeEspMqnffpM3VhsJzEnI7ByypvKuK759Fr9q1XRFb7jaJ0M63U4axz1z80/G7VaDYTl1H/B0Wy5R9nO7WuDbKhvemRuHvYD9usWxEYGG9OOuEv3vjh1UHzpSbjvK2F43mXAHLZchZWy/GxOVghTprqQMGXQsg2f6q5SisFwrIM6gm0ArxSjiygqdoeEu3dIBrcBanbINllKMIhUVFdd/hPNyqIblOs+gRGSOu11mDCv2VGfISqgz3CQPtYBkZ9DKqVcubqXArDOof/aKbC/oALl922MCrs1vn1a7F7Baa/nluK2rtMM6pZAspfKkdidYQba7BVS5Xb8cLMM53fYe7oAZTsftuKqa4q9kk2zqN7scjjyyhMxrN4fIYd33gvTaEsedEX0dYs1m7QwbtFzY7oGtKyCpOzCdUS38lrg8jddxGnZ+GgXb76bMSi2J0qpFBg26HaCeRvHCu/xj2gvUbboE3weUV+Mue8JS00LuoCO7PG09ulHcHMizPx7TXuj+2Vv9quXTGTyoXtaN9kR7l6nGq1u9+Mza7usN0COjO7RVb1PQD5Bpe+z1grR1kPWTuDuwU7tjVweFAoevp6ZSeSJ3YpAx7Y73NfmZRg0ybscTXqk9qfdxkJE7Op9KDUqhCeKe8S6rSGu3onHLb7xXIIzIz1n1JlxX/EB7FW66SQ6V+svOwPV7Bc83GLNDP+R6Cq8MH/RDrR9rel+5M7BzBrgmzl3B63YlHRrXSc8VrsdRZsnukPVcYdWHuz9uS9WbZfmv/YD9aqAj3KieYWfEQIdYs1c7wrJ6yUoB208G2MCqoWrvG3XGZVXPgiShem28M6hdAR0mn5OekI4Wsj+33Erf3U15++6QbLP9Jsl7YXMNtqbLQWdcr5YUnhF65QJuZ1S/tSawM2SgheyrC0qdStEtSSzrKO+1hTOlMEXuwSRjVlw0y24DZZpk5mhDZ/rXatywY9cgy67U14oU6dfKBc+OkE597y6cn+xzryd1K3mc70Wrg30fRLUHWdm1eSRKL/qAehVhXaTrl7vi4upHskl1l+06zuLXm3bJAtYtjs6U+pBaF4na7tix3QMbnO0YJj930E3ZlJIRpaGY3KFl0LFdCWvsNyajWh1RbW07MhmRdUQ8161MxrU74rrnmpnJwE5HYK7rela0t9uuRQ/Wb9cHVtW62yz9tU+Klyn1I1InyuHu+LUh1dtxgqChlfi3r4JSTHKbiFYt42O6O3zTQvzhnZltyYDZCP8u314lD9mmqcv/RbiM5nGobbI+Ww61Lfxny1DbQn81iS/DS2MUDpfaxvnLeThZzKbzZaRtlj+T/l0xcKTHU4Jcy2ttR/x5NJsu4uV0Hk8bmuJPf54u9K3wh+HoMpqH+j748+jSGE4n9BpGPBlGk8tYhlFWaBROlqFxGRkRkUfXoaltAfZKNpuO4nE0F3jEvf3xYxsDy9+LZkm17vbD6Sga0vvLTD1HvwwnP6wimdXt1OVCTEeyIACjrsUXHcKRLB7AqNlqNH76j4ksNcCoaBTdhpdTWabOjVpEs3BO9HPjIr4KF/FwatyQDP1M7yk9sN8Z5DaaL+OhBBGgEMv5CUMR43NjL6bji9AYCqGLr1ay2FnIwIubpSL57SMuowUJ8c/hOKY5p0ahePHt1HhzuZqNoh/fKurRA46keRKPRtO3iiK1AxJkHF7Nw+txqHTD77KIxng1WsbRMpyFC6UvPjL9LBzRyIUxpZ3gmva/8PtoofTIP6vI4fw6pP1o+oEW5uU/lGb554bPYjF3sQvR3kKqN4zlN2iRw/GUFl0o0ZDWgeT4Tam7JJhvldb57Rih+ArLxWqxjKcTPUzbhkaLSFp4KW9pLeJZ3WiYRv5Y8x7IHIScjrpb9alcaNiQzkB1HIfGLf53PCZOKl3ygWFCq8LhzVTplA89prrFswAaVl16ewANq27ytgUNq+7yNjs/7GV7VZrknxtx9fup0hX/HK1GUu0WmbiZLo1pPFJ64Z+jL3YWpQH++Qc6u4XYLZLwoqxK4/vzE64uQ2HMLKVpnBYROO1U4SxWut2fZf88XJanZDQfy2ekoxEAu3H9HPs8dYPmOQ40rKoKjnt+2DAaXdJhQTv21Wi6DIe0YSq97M+NHcWTqBxLenu5GorB4r/C61VpYs6np39VOtyfg9QdIEpb+3ODr+LRci4ZyE7QsmTT+bzYq6Y/xjSV4G2o';
@@ -1 +0,0 @@
export default '9LE/N3gcXcY0ICrfvxBBpYl9dWyr26IRIler59Jx7trnB0ULMYKecGSQhS64sZwqfezPDSZX4ediTcQbLobzcCaJo+ueHzyPhtFsKQ9WRvOWl52vxAfV6BSJSABG8ht750eSzC2jUhKH4XJ6SXaw0tG+OpbXhG+hbApucH5M9MMqJqPRKDf5YUznSfHGT/8xIvFYKC3uz+G87pqGvG2WwjWm15HWgFvnsYr9ZqE0vD9LTg65TG6fJ6/YLVwjRV6NXIJ3z9OrmyXn56kX9OyjeBkJc/cyXpDdeLGKVR3h3nmIcLiKCsUwPhjFjmbKHYnOjezEM40c+TojQRrjDVrGjFbXQsqHN5Jqedb5QeLzhDSJOhFrGzO5jCYL5VT27PNjwtE4JI/m2bE6HRIliin3+TkLQssZTa7mcXVl3PPjTkJXHEan5/heCvXwltGnc4FQ4nIHFAymnWX28g+m3KvnHNY8uiBTS+aPf35ARXm9FrnRHgt+i+BEP96EK/ExS3lMi9wsSLuicWhMotU4XMrmj8+Q5bwe0ZiR8Sa8INm7pWeOXr0xv0WWbsgXl+ZrExqSt/nwRgrh6dzvQWXNo6kxjoZPf5kIzzOML0y5UU230XQEhVej6EdTbkyDQJSu79RYrITxE1+MXmXW91sghuFtYVSQmF4TX+Pb0HgzG86kRQ5aEBark8FlTMLlSjYig0HL0HjyUxH/k+QpsFrG0Bobb17XLhRL9/q0Aeu0ZJoVC+wWBFod40192aRncKAVk4LIgU7SzkV7A51wKe5QOJqRLTMfvpokgdcyprKFBH4LPYHX/IZAJy12sxlQtE05O0DjqBb9UM4OUq2Bos9JyyQ651N7IhfNTc6CnXVGij4mteHq15xEGvcuNR7zzdPfH+QPChUdS84ODu8zUXm2S6VPfg54y6CrbHPYlXNuEmOfb47lt1IfkrtdairNR87iXNAf4vsY+T77lK2L7xIXoMmp+vAuOyGbStsRCDM1su2XdF0+mMAUlUoiw1TBC1rwRElLsT4CZJ0c75INId9ln7O7Y5E2f8x39L+veTnfId+dvq1ENNt7fWqqMP+eVYHMQXJZyMnTp6pK2stTzCqe6ugV53QVnZyh4nPRUqZIfqvJ1JgSSljEoEUE15ifHAvJiVa6iNKPE/Lgv5uNQjp1pvMx/S1En2wO8a/xQmiAbN7LAl4+r5imos7Kx6LD0e1qRH7R7exWmxajs3ZWvFp8KT6rfUU+l/K8gaqgZLErCUB1S42e/hqOyMoqXLjJFQG/mdMmIMfh1Q316a8CzXhz8nU/xBPlKd42fGF6JeiN0FjG0ZwYQwcBDRInwQdDuPaVocqeNI9FyORlQuNiNaKdhR7irT7pJhyTUGweJ1t4HE/i8dTUfk2H/Ob41V5eij2H1mEcjS9EKN/Ufi1HP4aMpnF8LbHBUt2sYcGyUTyexdHPoan9og1tmiRfxnN6rxhxOVetaaUeZDqnxTd+qsMqdR0niSJ9uZr+3tR+TeZF6EQcKRbWeFVEldKL63l8JbzwkJ5YcFaWeKWaorA8p6b24y0nhtYC+a5Wfktdvl4Jabg0td9YqQiZqf1gioZ7clBaqS0gJS/ZJnBHIsJgaj9scvv0l3KFtc+opP7H4WRahE4XpvZ7I8+8Enwgc6f444o8OTX+La3haesxtR8DUZeYLNRyB1q8NbUf+dCvjfEdneaTU5rru1JGFzrRtBs4R1sE7Q60x4am9psdJ19ncQpingJKH65X4fxSaAP5M2o8vOYlSas1MsoYkan94oZwiovNJhJxp8jUf0NjL2yB4rRcr9O99IFBpWFBTEdbcVrTAb2TjtjPZbegN+Nke5+8NfVfv8j25b3ltahpK0rI31zvks+JWOy0KBSiv6ZkXrw1tZ+7GD797SE52QlkbWyTX9XvuaoXphIjMQ7lLYk363yb/lY8a7p5+s/1YUcv8dbUfqxinByST8f0nmycN8Nke8jukjt6rGX2KD+WzPV4c3zItpXPtauflXj6+zZNynW6S0ztZyJq+69yv35Moihtdcr9+GE0J4+QxLzIT8gx4oE+aimOISO8jUzthxSicbw4Rdmvw4UcF1UOt116yNSvnL789vnpb5vPR2EepvfHnVhAU/uNgot8XSzKTfkP+q8NJI+H5EXM7uWvwMorfFmKQyEan0Tvn3dGWkr0FxK3koum9tMA8VYUU5VlasWnlMjoU1mp3LWukpNkkcBuiLF7sglPk5rapv7CyDLm4U+VkK28TZZ7321xhpvaFvuach0yD6Ifl2ITU2KSTBekN3QJFVPbAH+Zkol7fEx3n0XTtC3Nss/2Bu29kvWpXDom8/NahEjnkZwCUK4PL9PtvjCo062x/iWjZSzVIt2vk8f0xGHJH1FuCFdtnHkRu6c/QmMqDpdyE3z6f2pctmqJmtpm8KdTwNT2dCdzekYzPv37pPTyRtFVOCyPiXJhTW3r9memFGZrrbTKU4ub5vPpRUjmz/zFNDTeCaOIbCgCCIu3FOeoqe24LiVhqm+i3HmdPf3f8jA0tW3Qy/qON2Rjj43Sw39r6luaV8/YZ9wPxvU8HM+UgYq58vSfO3HBcv/OOG5JqkgUPhY7hPgX8iZPfz3vIPTnY3YgZ+q9qe1cHov7xoUDqaqsp4YVST/EoUneDjk/5d+mttW4bFvK+62v42slslmxaE1tN++hqJsSfsPlKWIwii9K14u0Xg30qA27hYAsFKtNuQ55WnVT21FbdXiEkKhZOuXCYoW1Lx7nwtT2wH6xkj4Yw6e/FGaSqW1tXZipJQMWUqmlekkwXoykBNcqGk1NbQPqofhQ7ybdPf09Megk+iykgOyID8aiduIEddYsTG3HaGJqdF2YsfVQlYYXBcsWK5lKwxF6JvIH5OVT7r09K06RRhhGsRzo402p4El8Szx8c0FeOR36o5GsbcpNNbJmF8+DFqvvI9XsV+6eSTY4icotnSpvSkURG+5bU9sM+eR7VDwPtbvxbVYelR/W';
@@ -1 +0,0 @@
export default 'yUcRMjmcTgBT37B4nq7FkVMEZE7HK6n4vvgCjr4V8Szb5Ifc1PcTFi270pwOLjpu6N+Oe1F5muxMfafg8ujPny0NQ4hVKh/nahfgeXqf7Q9lv6qH01BT39T3hjyp8OdrubJWbc9byLPxup0VFnZaBpfEWZk8iDut+g68bYM3+TqpPFvlq8rr3KBFp+NYNHgzYnrr3ZaM8+RjsiMLLXkko/tQvOZHGpTmcpxkcA4p+k1F+ngy+Ion+2KkfzwqVrXaRfciv0vvE7Oh/W0RjSvmkZZFjdPmRVV5viPz6LdfEuJ9LjFeDaOK76eLZTP1PWWX4tYFWSmb8qa1vhHs8MWrKaKFD8XpZuqbuJLQpofSkSmETMb01faDu7tkXX75paF96lEAFKu6y++Oa5XT6ud4t+sNGcmFwVUsvfjrIMBNfdfTS+E9PaR6IVKCIxfp7qHoD/tQN4LV3qQL0pmUaBfrRHKF1E6jRSBXPFz8Esc19d1Db8WXzU7y/okc0vK7Eaa+Mehyl2z3n/Ldg8pqJVRyVQSLD8mj8DjWGfmBxdu/OHCiCWK6zsnl28rT+DobR4x8sWSKiPkJRrjOB3l4oAgbbZS70nH5uFH3HzWsktCfQgHUdTdy45N4CVPffHO2yw+S4/4oNo+98fD0j88kk5K8KiGX2+wUnS9iA0J1C4Pd1DfNvAkv4lG8PJ3kM7KAp/PrcBIvxlNjSsZPLEzGy+lwNRbZmoJKjoM6akRlEi2NZ0Q6octKo1lZwHcORi3ZWUVk2d7El/Onv6xGZH+9QNLZq31CU98QMxyF83FoLKYTOUWvtrY80VSsA7VP5WI6G52MwPORNLUN5YvBeLKRWsYqsZtT5eurBXGqozP1fSQlclHNoZgOal9I4Z4sjEk0jBbhXLk+okZlnmN7ixWxT6yyFOFVYjANUUC1+aLk8wxX4eUpBXeqyZaztGpvRe2wyjLowm2Lm9XSuJz+Xl4EX2cIm/ouhvPolqaOX2tVRHA6JHdezhcqERt9TL+8fFMOJU+lkr1UIjkNAHq/WWabEum5nNJiGqVMm/q2gfpgpqlvBni1Em7OQkRIo5lwisv/pKFlvt3Ud/ir+CU1r0Rt3hcOSb9KVBFWWSnXn5T4TyHbhVtUSKTYYcgFJOmYrYo1K95qcRXekr6LhXgjNpGw2EPGRYmJmIN4Yep785FxPJ5NS5rFyWAumS/49zqpqe+/95wyemesLopsE2nhO/IUSNVisX8JZ+Un4zW8M55O4sI1NaJitxM7pny/Y9C8okJ7Th5cGf6frWSGK1GoS/J/nrNfND/NF81vQ+0wpeg8Gt6UCU2RNqeBs5tiTN2hVrvjdV0FLaBi8MVj2jSHQvi1wRm16d1pF9TeN1EiVGFxnWdRcvtqTg7981b9kv581hF6dEnSlUBVnS+FtLwssbpZcXUTmtGEYjlKgZOHGG9eZZF2hhlJymntpFNTiXsVFe/PR+sLTPTjcLRayIkNteHcyQqRbZ8PvxZ34z9sMjJ/JBtDiYE9JNunf7zYM4/kcDw8/U1YHftPyed8J2whYXhsjT0Z/sVN3cIdkewcJSRGhmAqenkRTLI9JhtT3wVOBGXnalBW7ej2vP8335FUImNSduaUKpIpz2TuXrKPCyHK1R3Z8/Rpppk4ViYizWR8oGcrkvNLlTeKfIzCZ8U9sVTsfpcrZSpZBC7i6Sw+5UjL56SdZTwTyTwyX2RFULuexZWjUAmQSWv0mkzW3hGVKyleflPuRv0iaL/e569XjwE61kDHKnQ2SOeAdC5Ix0E6D6TzQboApLMGKKGFEqIssVCeWChTLJQrFsoWC+WLhTLGQjnDUM4wlDMM5Qxr4owNapUNapUNapUNapUNapUNapUNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapUDapUDapUDapUDapUDapUDapUDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapULapULapULapULapULapULapULapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLahUHtYqDWsVBreKgVnFQqzioVRzUKo5qFUe1iqNaxVGt4qhWcVSrOKpVHNUqjmoVR7WKo1rFUa3iqFZxVKs89GWqhMxBCV2UkKOEHkroo4ToW9sDlNBCCZtY6KOc8VHO+ChnfJQzPsoZH+WMj3LGRznjo5zxUc4EKGcClDMBypkA5UyAciZAOROgnAlQzgQoZwKUM6KSCGNNjdK2YUoHpnRhSg5TejBlEyctC14lC14lC14lC14lC14lC14lC14lOH5ao2xeJQavEoNXicGrxOBVYvAqwfGwGmXzKtnwKtnwKtnwKtnwKtnwKsHxjRpl8yo58Co58Co58Co58Co58CrB/mqNsnmVXHiVXHiVXHiVXHiVXHiVYP+jRtm8ShxeJQ6vEodXicOrxOFVgh2bGqUNUzoDmNKCKRlMacOUDkzpwpSN3Kza0Y4HU/owZYBSuo08CtCghAVb3TVKtzFDOoBTzQN09hpl8+wWmrS34Mkt1JGoUboMprRhSgemdGFK+N1dD6ZsXKVa3UeAUvIBTGnBlI08QjPzDE3NMzQ3z9DkPEOz8wxNzzM0P8/gBD2DM/QMTtEzOEfP4CQ9g7P0DE7TMzhPz+BEPYM9Ewan6hmcq2dwsp7B2XqGpusZmq9naMKeoRl7hqbsGZqzZ2jSnsFZewan7Rmct2dw4p7BmXsGp+4ZnLtncPKewdl7Bru3DM7fMziBz+AMPoNT+AzN4TM0ic/QLD5D0/gMzeMzNJHP0Ew+g1P5DM7lMziZz+BsPoPT+QzO5zM4oc/gjD6DU/oMjpEwOKnP4Kw+g9P6DM7rMzSxz9DMPkNT+wzN7TM0uc/Q7D5D0/sMzu8zOMHP4Aw/g1P8DM7xMzjJz+AsP4PT/AzO8zM40MbgTD+DU/0MzvUzONnP';
@@ -1 +0,0 @@
export default 'PFTVPFTVPFTVPFTVPFTVPFTVPFTVPFjVPFjVPFjVPFjVPFjVPFjVPFjVPFjVPFjV4GhtjZLBPGIwjxjMo2ZV81FV81FV81FV81FV81FV81FV81FV82FV82FV82FV82FV82FV82FV82FV82FV82FVg+uKapQM5hGDecRgHjWrWoCqWoCqWoCqWoCqWoCqWoCqWoCqWgCrWgCrWgCrWgCrGpyPqVPC/LFgBlkwhyyYRRbMIwbziME8YjCPmm/hDdBreAP0Ht4AvYg3QG/iDdCreAP0Lt4AvYw3gG/jDeDreAP4Pt4AvpAHJx/rlDB/LJhBFswhC2aRBfOIwTxiMI8YzKNmVbNQVbNQVbNQVbNQVbNQVbNQVUMT0zXCZlWzYFWzYFWzYFWzYFWzYFWzYFWzYFWzYFWDq0drlAzmEYN5xGAeNasaXOlZo2ys6K5TujAlhyk9mNKHKeF3b6zrrlNaMGUjN+EL8Ta8nDa8nDa8nHCRa43ShSl5IyWaKq0RNi+SAy+SAy8SXONao3RhSnyRuANTujBl43qiKbYaIYMhGYzZWD5Zo+QeTIljws/pNW41cIzehothbbgY1oaLYW24GNaGi2FtuBjWhsOrNlwMa8PFsDZcDGvDxbA2XAxrw8WwNlwMa8PFsDYcP7ThS3c1ysYyTxsuXK1Reo3tElBn2IFdshqlA1M2VvfWKD0GU9owZWPLDdSNceD6Wgeur3Xg+loHrq914PpaB66vdeD6Wgeur3Xg+loHNvod+HqXA1/vcuDrXQ58vcuBr3c58PUuB63uddDqXget7nXQ6l4Hre510OpeB3U2HLi614Grex24uteBq3sduLrXgat7Hbi614Grex24uteBq3sduLrXgat7Hbi618F7ccHNuOBuXHA7LrgfF9yQC+7IBbfkwnty4U258K5ceFsuvC8X3pgL78yFt+bCe3Phzbnw7lx4ey68Pxdc3eu4aHDOgb3wOqWHUjbbE7Wyw8bn9FF/qEbpcZiy8Y1gP8OtmdCNvatQE7pG6MGU/gCmtGDKxkZXaKNcFw4Iu3CYt0bpwo/Z6BPUKR2Y0oUp4Xdv9AnqlI2rZKPh/Rplo9NYo/RxSnh2v3E9a+cwhykb19OFMV0Yk8OYcCOqGqXfyHd4/+JwUKNGacOUjZG0OqUFUzKY0oYpHZjShSkbVx5OO3K4aQ2Hm9ZwuGkNh5vWcLhpDYeb1nA4qlGj9GHKoFE+0WgBR6MFHI0WcDRawNFoAUejBRyNFnA4WsDhaAGHowUcjhZwOFrA4WgBh6MFHI4WcDhawOFoAYejBRyOFnA4WsDhaAFHowUcjRZwNFrA0WgBR6MFHI0WcDRawOFoAYejBRyOFnA4WsDhaAGHowUcjhZwOFrA4WgBh6MFHI4WcDhawOFoAYejBRy+cslhz57Dbak43JaKw22pONyWisMVA5yjHlGNMmjkpgdj1u54MZgSx2zkEXynokZpw5TNXoEPewU+7BXA8aQ6JbxKzV6BD3sFcIk9h5uxcrgbK8f9RrgfK4cbsnK4IyuHW7JyuCcrh5uyerDPXKNsbjI9gLtMw+2z6pQeTOnDlPC7N7eaHsC9pgcwj+A6hBpl0LjycBzTgysxPTjuVqMM4NmDxtnhskkP7R/iof1DPLR/iIf2D/HQ/iEe2j/EQ4sbPbh/iAf3D/Hg/iEe3D/Eg/uHeHD/EA/uH+LB/UM8uH+IBxuzHtw/xIP7h3hw/xAP7h/icVgpYTOtRtloyNcpHZjShSnhN2o05OuUjRKCXiz1YOvHg22aGmVjnqpOacOUDkzpwpTwuzfmqeqUjR9JqJ3BjZTwGVyjbKz49mvnZeM3Ghy0sNOvx2sGOKmFkzKc1MZJHZy08Zsb9SOR46QeTtooKhx/AI4/AMcfwMMfwMMfwMMfwEcTHX79Tn3j1zDqN30bv4dRv6nY+EWM+kdyG79WUs8lNEl2AMfuAtgSrmM22lAaUhcn7fAAHk7q46TwWlmNlpSG1MJJGU6KSwDDucVwbnUQF4Zzi+HcYji3bJxbNs6t5q8L1b0TGyd1cFIXJ+U4qYeT+jhpAJM2hkk1pBZOinPLwbnl4NxycG45OLcaW/MH8L26AL4DV6NsXlSORorrz9n4DQENKfxOVuM9Iw2phZMynLRRquC7fQH8wb0apQ3P3rwBebCceKj7W6f0YEofXs/mTb3eXc/BSRtfv95IjOOkHk7q46QBTNr47QENqYWTMpzUxkkdnBTnFse5xXFucZxbHOeWh3PLw7nVeNUyQDu1BWintgDt1BagndoCtFNbgHZqC9CAWgB3agvgTm0B3KktgDu1BXCntgDu1BbAndoCuFNbAHdqC/BPesKd2gK4U1sAd2oL4E5t1gC9p12nbP680QBOfNZJXYaT4m/lOjipi5PiK+B6OGnzYuFf5Bzgn+Qc4N/kHOAf5RzgX+Uc4J/lHODf5RzgH+askdo4afPH3eqkFk7KcFIbJ3VwUhcnbWasDW8u+PdBa6R8gKN6dgdapwNt83I5+OO6HR6Xd3hc/COHddIzb+bjsH4H2AAWmvoRz//8h3dmSbhJDkR5lW3vsu393vzdv/zJrCFoX6CNqrwO2IpVXoOsNVGFpmTQlAyb0oamtKEpbWxKB5rSgaZ0sCldaEoXmtLFpuTQlByakmNTetCUHjSlh03pQ1P60JQ+NmUATamvv2yjciAs/eMH3/j45e3ldjLo0Szw2cq7yK2ff2/Y8wbYpjfAVsQCd1pwqwX3WgvbbC1st7XA7dbC9lsL23AtcMe1sC3XwvZcS7fp/uHP/x8ocJi7';
@@ -1,41 +0,0 @@
import { inflateSync } from 'node:zlib';
import payload1 from './authoritative-inventory-model.payload-1';
import payload2 from './authoritative-inventory-model.payload-2';
import payload3 from './authoritative-inventory-model.payload-3';
import payload4 from './authoritative-inventory-model.payload-4';
import payload5 from './authoritative-inventory-model.payload-5';
export const AUTHORITATIVE_INVENTORY_MODEL_SOURCES = {
territory: {
file: 'modelo_yacimientos.sql',
sha256: '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a',
},
inventory: {
file: 'modelo_relacional instalaciones y sub.sql',
sha256: 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49',
},
} as const;
export type AuthoritativeInventoryModel = {
departments: Array<{ id: number; nombre: string }>;
companies: Array<{ id: number; nombre: string }>;
concessionTypes: Array<{ id: number; nombre: string }>;
areas: Array<{ id: number; departamento_id: number; nombre: string }>;
fields: Array<{
id: number;
nombre: string;
area_id: number;
empresa_id: number;
tipo_concesion_id: number;
}>;
installationFamilies: Array<{ id: number; nombre: string }>;
subinstallationFamilies: Array<{ id: number; instalacion_id: number; nombre: string }>;
findings: Array<{ id: number; nombre: string }>;
subinstallationFindings: Array<{ subinstalacion_id: number; hallazgo_id: number }>;
installationFindings: Array<{ instalacion_id: number; hallazgo_id: number }>;
};
const payload = payload1 + payload2 + payload3 + payload4 + payload5;
export const AUTHORITATIVE_INVENTORY_MODEL: AuthoritativeInventoryModel =
JSON.parse(inflateSync(Buffer.from(payload, 'base64')).toString('utf8')) as AuthoritativeInventoryModel;
@@ -1,50 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
AUTHORITATIVE_INVENTORY_MODEL as model,
AUTHORITATIVE_INVENTORY_MODEL_SOURCES as sources,
} from '../../src/reference-data/authoritative-inventory-model';
test('authoritative SQL sources keep their exact cardinalities', () => {
assert.equal(sources.territory.file, 'modelo_yacimientos.sql');
assert.equal(sources.territory.sha256, '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a');
assert.equal(sources.inventory.file, 'modelo_relacional instalaciones y sub.sql');
assert.equal(sources.inventory.sha256, 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49');
assert.equal(model.departments.length, 7);
assert.equal(model.companies.length, 13);
assert.equal(model.concessionTypes.length, 2);
assert.equal(model.areas.length, 64);
assert.equal(model.fields.length, 230);
assert.equal(model.installationFamilies.length, 14);
assert.equal(model.subinstallationFamilies.length, 109);
assert.equal(model.findings.length, 181);
assert.equal(model.installationFindings.length, 48);
assert.equal(model.subinstallationFindings.length, 880);
});
test('Area belongs only to Departamento while Yacimiento owns Area Empresa and concession', () => {
for (const area of model.areas) {
assert.ok(model.departments.some((department) => department.id === area.departamento_id));
assert.deepEqual(Object.keys(area).sort(), ['departamento_id', 'id', 'nombre']);
}
for (const field of model.fields) {
assert.ok(model.areas.some((area) => area.id === field.area_id));
assert.ok(model.companies.some((company) => company.id === field.empresa_id));
assert.ok(model.concessionTypes.some((type) => type.id === field.tipo_concesion_id));
assert.deepEqual(Object.keys(field).sort(), ['area_id', 'empresa_id', 'id', 'nombre', 'tipo_concesion_id']);
}
});
test('Subinstalación classifications belong to one installation classification and every mapping resolves', () => {
for (const subinstallation of model.subinstallationFamilies) {
assert.ok(model.installationFamilies.some((installation) => installation.id === subinstallation.instalacion_id));
}
for (const mapping of model.installationFindings) {
assert.ok(model.installationFamilies.some((installation) => installation.id === mapping.instalacion_id));
assert.ok(model.findings.some((finding) => finding.id === mapping.hallazgo_id));
}
for (const mapping of model.subinstallationFindings) {
assert.ok(model.subinstallationFamilies.some((subinstallation) => subinstallation.id === mapping.subinstalacion_id));
assert.ok(model.findings.some((finding) => finding.id === mapping.hallazgo_id));
}
});
@@ -32,17 +32,15 @@ test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root
assert.match(migration, /SET can_be_root=false/);
});
test('Historical authoritative endpoint keeps the former Yacimiento Company plus concession contract for compatibility', () => {
test('F5.1 manual creation permits Area context without requiring an operator', () => {
const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts');
assert.match(structure, /operator_company_id,concession_type_id,inventory_family_id/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/);
assert.match(structure, /INVENTORY_YACIMIENTO_COMPANY_REQUIRED/);
assert.match(structure, /INVENTORY_YACIMIENTO_CONCESSION_REQUIRED/);
assert.match(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/);
assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.match(structure, /operator_company_id,inventory_family_id/);
assert.match(structure, /asset_context_history/);
assert.match(dto, /operatorCompanyId\?: string \| null/);
assert.match(dto, /concessionTypeId\?: string \| null/);
assert.match(structure, /operational_area_id,operator_company_id/);
});
test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => {
@@ -66,60 +64,23 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items
assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
});
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => {
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /Tipos y campos/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/);
assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/);
assert.match(configPage, /Tipos de Instalación/);
assert.match(configPage, /Tipos de Subinstalación/);
assert.match(configPage, /Puede estar dentro de/);
assert.match(configPage, /\{ value: 'TEXT', label: 'Texto' \}/);
assert.match(configPage, /\{ value: 'NUMBER', label: 'Número' \}/);
assert.match(configPage, /\{ value: 'DATE', label: 'Fecha' \}/);
assert.match(configPage, /\{ value: 'BOOLEAN', label: 'Sí \/ No' \}/);
assert.doesNotMatch(configPage, /Hallazgos asociados/);
assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Filtrar por tipo de Instalación/);
});
test('F7 Web creation keeps the fixed hierarchy and asks only the required simple context', () => {
test('F5.1 Web creation and configuration expose Departamento as the physical root', () => {
const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx');
const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(createPage, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA: 'Departamento'/);
assert.match(createPage, /YACIMIENTO: 'Área'/);
assert.match(createPage, /INSTALACION: 'Yacimiento'/);
assert.match(createPage, /SUBINSTALACION: 'Instalación'/);
assert.match(createPage, /listCompaniesForInventoryArea/);
assert.match(createPage, /Empresa operadora/);
assert.match(createPage, /ALTA RÁPIDA/);
assert.doesNotMatch(createPage, /Tipo de concesión/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/);
});
test('F7 canonical detail uses one short everyday profile and keeps advanced administration separate', () => {
const detailRoute = source('../web-v2/src/pages/InventoryDetailPage.tsx');
const detail = source('../web-v2/src/pages/SimpleInventoryDetailPage.tsx');
assert.match(detailRoute, /isSimpleInventoryAsset\(asset\) && !advanced/);
assert.match(detailRoute, /<SimpleInventoryDetailPage initialAsset=\{asset\}/);
assert.match(detail, /DEPARTAMENTO/);
assert.match(detail, /AREA/);
assert.match(detail, /YACIMIENTO/);
assert.match(detail, /INSTALACION/);
assert.match(detail, /SUBINSTALACION/);
assert.match(detail, />Resumen</);
assert.match(detail, />Actividad</);
assert.match(detail, />Ubicación</);
assert.match(detail, />Fotos</);
assert.match(detail, />Cambios</);
assert.match(detail, /Empresa operadora/);
assert.match(detail, /Tipo técnico/);
assert.match(detail, /AssetTechnicalDataPanel/);
assert.match(detail, /AssetDossierPanel/);
assert.match(detail, /Administración avanzada/);
assert.doesNotMatch(detail, /Tipo de concesión/);
assert.doesNotMatch(detail, /AssetProvenancePanel/);
assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /Datos opcionales/);
assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(configPage, /familyId=\$\{familyEditor\.id\}/);
});
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => {
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 28/);
assert.match(gradle, /versionName = "0\.19\.0"/);
assert.match(gradle, /versionCode = 23/);
assert.match(gradle, /versionName = "0\.15\.1"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
});
test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => {
test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8',
@@ -113,7 +113,7 @@ test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM
);
});
test('F5/F6.1 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
@@ -129,8 +129,7 @@ test('F5/F6.1 source contracts keep Empresa out of physical ownership and preser
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/);
assert.match(fieldSource, /scopeAssetId/);
assert.match(fieldSource, /Área de esta inspección/);
assert.match(fieldSource, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
@@ -26,7 +26,7 @@ test('F6.1 inspection planning exposes Departamento → Área → Yacimiento exp
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
});
test('Authoritative WEB creation freezes Area and validates Company against selected Yacimiento', () => {
test('F6.1 WEB creation freezes Area and uses the selected Yacimiento as physical scope', () => {
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
@@ -37,18 +37,15 @@ test('Authoritative WEB creation freezes Area and validates Company against sele
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
assert.match(create, /scopeAssetId,/);
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
assert.match(create, /yacimiento\.operator_company_id=\$3::uuid/);
assert.match(create, /yacimiento\.concession_type_id IS NOT NULL/);
assert.match(create, /INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO/);
assert.match(create, /relation\.relation_role='OPERATOR'/);
assert.match(create, /relation\.valid_from <= \$3::timestamptz/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('Authoritative WEB inspection creation uses Yacimiento context and not descendant Inventory snapshots', () => {
test('F6.1 WEB inspection creation does not use Inventory operator snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /yacimiento\.operator_company_id/);
assert.match(create, /operatorSource: 'YACIMIENTO'/);
assert.doesNotMatch(create, /area_company_relations relation/);
assert.match(create, /area_company_relations relation/);
assert.doesNotMatch(create, /inventory\.operator_company_id/);
assert.doesNotMatch(create, /asset\.operator_company_id/);
});
@@ -1,80 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.1 field Inventory is bounded by the frozen Yacimiento scope', () => {
const source = api('src/inspection-visits/field-inventory.service.ts');
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(source, /const effectiveParentId = parentId \?\? context\.scopeAssetId/);
assert.match(source, /const parentId = dto\.parentId \?\? context\.scopeAssetId/);
assert.match(source, /\[parentId, context\.scopeAssetId\]/);
assert.match(source, /\[assetId, context\.scopeAssetId\]/);
assert.match(source, /AS "insideScope"/);
assert.doesNotMatch(source, /insideArea/);
});
test('F6.1 field Findings reject Inventory outside the frozen Yacimiento scope', () => {
const source = api('src/inspection-visits/field-findings.service.ts');
assert.match(source, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(source, /AS "insideScope"/);
assert.match(source, /if \(!row\.insideScope\)/);
assert.match(source, /Yacimiento definido como alcance/);
assert.doesNotMatch(source, /AS "insideArea"/);
});
test('F6.1 planning preserves Yacimiento and never validates descendant Inventory ownership', () => {
const source = api('src/inspection-visits/inspection-visits.service.ts');
assert.match(source, /const nextScope = dto\.scopeAssetId === undefined \? visit\.scopeAssetId : dto\.scopeAssetId/);
assert.match(source, /validateInspectionPlanningScope\(manager, nextAreaId, nextScope\)/);
assert.match(source, /visit\.scopeAssetId \?\? visit\.operationalAreaId/);
assert.match(source, /visit\.plannedStartAt,/);
assert.doesNotMatch(source, /visit\.scopeAssetId = nextAreaId/);
assert.doesNotMatch(source, /assertAssetsMatchContext/);
assert.doesNotMatch(source, /assertCurrentAssetsMatchContext/);
});
test('F6.1 creation selects actionable checklist Inventory automatically', () => {
const source = api('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(source, /INSERT INTO inspection_visit_assets/);
assert.match(source, /COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL/);
assert.match(source, /'AUTO_INCLUDED'/);
});
test('Authoritative operator choices are projected from Yacimientos of the selected Area', () => {
const hierarchy = api('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = api('src/inspection-visits/inspection-visits.controller.ts');
const createPage = web('src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /operatorsForArea\([\s\S]*at\?: string/);
assert.match(hierarchy, /FROM assets yacimiento/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(hierarchy, /yacimiento\.operator_company_id/);
assert.match(hierarchy, /SELECT DISTINCT company\.id/);
assert.doesNotMatch(hierarchy, /FROM area_company_relations relation/);
assert.match(controller, /@Query\('at'\) at\?: string/);
assert.match(createPage, /operators\?at=\$\{at\}/);
assert.match(createPage, /\[areaId, plannedStartAt\]/);
});
test('F6.1 WEB keeps Area and Yacimiento as separate concepts and freezes context after creation', () => {
const source = web('src/pages/InspectionVisitEditorF4Page.tsx');
assert.doesNotMatch(source, /<span>Área \/ Yacimiento<\/span>/);
assert.match(source, /<span>Área<\/span>/);
assert.match(source, /<span>Yacimiento<\/span>/);
assert.match(source, /<span>Operadora<\/span>/);
assert.match(source, /visit\.operationalArea\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.scopeAsset\.name[\s\S]{0,160}readOnly/);
assert.match(source, /visit\.operatorCompany\.name[\s\S]{0,160}readOnly/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}scopeAssetId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operatorCompanyId:/);
});
@@ -7,24 +7,24 @@ function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('Authoritative model stores Company on Yacimiento and propagates it to physical descendants', () => {
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
const structure = source('src/asset-master/inventory-structure.service.ts');
test('F6.1 preserves Inventory operator snapshots and changes operator only through Area relations', () => {
const relations = source('src/asset-master/asset-operational-relations.service.ts');
const migration = source('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts');
assert.match(migration, /Yacimiento requiere Empresa relacionada/);
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
assert.match(migration, /NEW\.operator_company_id:=parent_company/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/);
assert.match(structure, /parent\?\.operatorCompanyId/);
assert.match(structure, /ensureCompatibilityProjection/);
assert.match(migration, /Runtime ownership and search MUST NOT depend on it/);
assert.match(migration, /La Empresa se cambia en la relación temporal del Área, no en el Inventario/);
assert.match(relations, /operatorSnapshotPreserved:true/);
assert.doesNotMatch(relations, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
});
test('Authoritative Yacimiento relationships cannot be mutated through generic Inventory edits', () => {
test('F6.1 blocks operator mutation through generic Inventory edit DTOs', () => {
const updateDto = source('src/asset-master/dto/update-asset.dto.ts');
const contextDto = source('src/asset-master/dto/change-asset-context.dto.ts');
assert.match(updateDto, /@IsEmpty/);
assert.match(updateDto, /La Empresa relacionada se administra en el Yacimiento/);
assert.match(updateDto, /El Tipo de concesión se administra en el Yacimiento/);
for (const dto of [updateDto, contextDto]) {
assert.match(dto, /@IsEmpty/);
assert.match(dto, /La Operadora se administra en la relación temporal del Área, no en el Inventario/);
}
});
test('F6.1 rejects legacy Inventory searches that filter by operator snapshot', () => {
@@ -34,25 +34,28 @@ test('F6.1 rejects legacy Inventory searches that filter by operator snapshot',
for (const dto of [listDto, treeDto]) {
assert.match(dto, /operatorCompanyId\?: string/);
assert.match(dto, /@IsEmpty/);
assert.match(dto, /Filtrá la Operadora mediante la relación temporal del Área/);
}
});
test('Compatibility Area relation history remains available for legacy temporal queries', () => {
test('F6.1 resolves temporal Inventory operator from Area relation history', () => {
const temporal = source('src/asset-master/asset-temporal.service.ts');
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
assert.match(temporal, /FROM area_company_relations relation/);
assert.match(migration, /Derivado automáticamente de Yacimientos del modelo autoritativo/);
assert.match(migration, /TRUNCATE TABLE area_company_relations CASCADE/);
assert.match(temporal, /relation\.valid_from <= \$2/);
assert.match(temporal, /relation\.valid_until IS NULL OR relation\.valid_until > \$2/);
assert.match(temporal, /operator_company\.id=operator_relation\.company_id/);
assert.doesNotMatch(temporal, /operator_company\.id=history\.operator_company_id/);
});
test('F6.1 verification planning can consume the Area compatibility projection', () => {
test('F6.1 verification planning uses Area relation truth instead of Inventory operator snapshot', () => {
const verification = source('src/inspection-verifications/inspection-verifications.service.ts');
const migration = source('src/database/migrations/1790106600000-authoritative-inventory-relationships.ts');
assert.match(verification, /FROM area_company_relations relation/);
assert.match(verification, /relation\.valid_from <= \$2::timestamptz/);
assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/);
assert.match(migration, /relation_role,source_document_id,valid_from,start_reason/);
assert.match(verification, /operatorSource: 'area_company_relations'/);
assert.doesNotMatch(verification, /company\.id = asset\.operator_company_id/);
});
test('F6.1 historical operational lists read Company and Area from the parent Inspection', () => {
@@ -65,44 +68,35 @@ test('F6.1 historical operational lists read Company and Area from the parent In
assert.doesNotMatch(acts, /context_asset\.operator_company_id/);
assert.match(findings, /company\.id = visit\.operator_company_id/);
assert.match(findings, /area\.id = visit\.operational_area_id/);
assert.doesNotMatch(findings, /company\.id = asset\.operator_company_id/);
assert.match(reports, /visit\.\$\{column\} = \$\{parameter\}::uuid/);
assert.match(reports, /company\.id=context_visit\.operator_company_id/);
assert.match(reports, /area\.id=context_visit\.operational_area_id/);
assert.doesNotMatch(reports, /context_asset\.operator_company_id/);
});
test('F6.1 WEB Inventory searches use physical scope and never the operator snapshot filter', () => {
test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => {
const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const preventiveApi = source('../web-v2/src/features/inspections/preventiveCandidatesApi.ts');
const preventiveService = source('src/inspection-visits/inspection-preventive-candidates.service.ts');
const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx');
const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts');
assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.match(preventiveApi, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
assert.match(preventiveService, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.doesNotMatch(inspectionEditor, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
for (const page of [fieldDiscoveries, inventoryMerge]) {
for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) {
assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/);
}
assert.doesNotMatch(inventoryMerge, /source\.operatorCompany/);
});
test('F7 WEB creation selects one Area-linked Company for Yacimiento and retires concession from the simple flow', () => {
const page = source('../web-v2/src/pages/InventoryCreatePage.tsx');
const api = source('../web-v2/src/lib/inventoryStructureApi.ts');
const simpleStructure = source('src/asset-master/simple-inventory-structure.service.ts');
const migration = source('src/database/migrations/1790110200000-f7-simple-inventory-context.ts');
test('F6.1 WEB derives the creation snapshot from the active Area operator and removes it from Inventory edits', () => {
const editor = source('../web-v2/src/pages/AssetEditorPage.tsx');
const contextPanel = source('../web-v2/src/features/assets/AssetContextHistoryPanel.tsx');
assert.match(page, /const requiresCompany = kind === 'YACIMIENTO'/);
assert.match(page, /listCompaniesForInventoryArea\(parentId\)/);
assert.match(page, /Empresa operadora/);
assert.match(page, /operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(page, /Tipo de concesión/);
assert.doesNotMatch(page, /concessionTypeId/);
assert.match(api, /inventory-structure\/simple/);
assert.match(simpleStructure, /requireAreaCompanyRelation/);
assert.match(simpleStructure, /INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA/);
assert.match(migration, /La Empresa del Yacimiento debe estar vinculada como explotadora del Área/);
assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/);
assert.match(editor, /createAsset\(\{[\s\S]*operatorCompanyId: operatorCompanyId \|\| null/);
assert.doesNotMatch(editor, /updateAsset\(id, \{[^}]*operatorCompanyId/);
assert.doesNotMatch(editor, /parent\.operatorCompany/);
assert.match(editor, /listCompaniesForArea\(operationalAreaId\)\.then\(\(loadedCompanies\)/);
assert.match(editor, /loadedCompanies\.length === 1 \? \(loadedCompanies\[0\]\?\.id \?\? ''\) : ''/);
assert.match(editor, /Snapshot histórico de alta/);
assert.match(contextPanel, /Cambiar contexto físico/);
assert.doesNotMatch(contextPanel, /operatorCompanyId:/);
});
@@ -1,44 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 planning preloads the local start time and resolves Companies projected by Yacimientos', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
assert.match(page, /function currentLocalDateTime\(\)/);
assert.match(page, /useState\(currentLocalDateTime\)/);
assert.match(page, /areas\/\$\{areaId\}\/operators\?at=\$\{at\}/);
assert.match(page, /response\.data\.length === 1/);
assert.match(page, /setOperatorId\(response\.data\[0\]\.id\)/);
assert.match(page, /Sin Operadora vigente para esa fecha/);
assert.match(hierarchy, /FROM assets yacimiento/);
assert.match(hierarchy, /yacimiento\.operator_company_id/);
assert.match(hierarchy, /SELECT DISTINCT company\.id/);
});
test('F6.1 WEB cancellation requires an explicit audited reason and is available while in progress', () => {
const editor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/cancel-inspection-visit.dto.ts');
const lifecycle = source('src/inspection-visits/inspection-visit-lifecycle.service.ts');
assert.doesNotMatch(editor, /window\.prompt\('Indicá el motivo de cancelación/);
assert.match(editor, /showCancelForm/);
assert.match(editor, /cancelReason/);
assert.match(editor, /<textarea/);
assert.match(editor, /cancelReason\.trim\(\)\.length < 10/);
assert.match(editor, /maxLength=\{500\}/);
assert.match(editor, /'DRAFT', 'PLANNED', 'IN_PROGRESS'/);
assert.match(editor, /Confirmar cancelación/);
assert.match(controller, /@Post\(':id\/cancel'\)/);
assert.match(dto, /@MinLength\(10\)/);
assert.match(dto, /@MaxLength\(4000\)/);
assert.match(lifecycle, /\[InspectionVisitStatus\.DRAFT, InspectionVisitStatus\.PLANNED, InspectionVisitStatus\.IN_PROGRESS\]\.includes\(visit\.status\)/);
assert.match(lifecycle, /INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL/);
});
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.2 planning uses the shared CSRF-aware API requester', () => {
const page = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
const api = source('../web-v2/src/lib/api.ts');
assert.match(page, /import \{ apiRequest \} from '\.\.\/lib\/api';/);
assert.doesNotMatch(page, /async function requestJson/);
assert.doesNotMatch(page, /fetch\(`\/api\/v3/);
assert.match(page, /apiRequest<CreatedInspection>\('\/inspection-visits'/);
assert.match(api, /headers\.set\('x-csrf-token', token\)/);
assert.match(api, /readCsrfCookie\(\)/);
});
test('F6.2 removes the duplicated global operational bar and hidden persisted filters', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const context = source('../web-v2/src/context/OperationalContext.tsx');
assert.doesNotMatch(layout, /OperationalContextBar/);
assert.match(context, /localStorage\.removeItem\(LEGACY_STORAGE_KEY\)/);
assert.doesNotMatch(context, /localStorage\.setItem/);
assert.match(context, /useState\(''\)/);
});
test('F6.2 exposes provisional PDF data contracts for Actas and Informes', () => {
const projection = source('../web-v2/src/components/DocumentPdfProjection.tsx');
const actPage = source('../web-v2/src/pages/InspectionActEditorPage.tsx');
const reportPage = source('../web-v2/src/pages/ReportDetailPage.tsx');
assert.match(projection, /PDF PRELIMINAR · ESTRUCTURA PROYECTADA/);
assert.match(projection, /Contrato de datos estable, renderer reemplazable/);
assert.match(projection, /Identificación y contexto/);
assert.match(projection, /Firmas y manifestaciones/);
assert.match(projection, /GEDO e integridad/);
assert.match(projection, /Anexo final/);
assert.match(actPage, /<DocumentPdfProjection kind="act" \/>/);
assert.match(reportPage, /<DocumentPdfProjection kind="report" \/>/);
});
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
test('F6.2 protects the frozen Inspection context at database level', () => {
const migration = api('src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts');
assert.match(migration, /prevent_inspection_context_mutation/);
assert.match(migration, /NEW\.operational_area_id IS DISTINCT FROM OLD\.operational_area_id/);
assert.match(migration, /NEW\.scope_asset_id IS DISTINCT FROM OLD\.scope_asset_id/);
assert.match(migration, /NEW\.operator_company_id IS DISTINCT FROM OLD\.operator_company_id/);
assert.match(migration, /BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id/);
});
test('F6.2 preventive candidates are descendants of the frozen Yacimiento only', () => {
const service = api('src/inspection-visits/inspection-preventive-candidates.service.ts');
const controller = api('src/inspection-visits/inspection-preventive-candidates.controller.ts');
assert.match(service, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/);
assert.match(service, /WITH RECURSIVE scope_tree AS/);
assert.match(service, /child\.parent_id/);
assert.match(service, /asset\.operational_area_id = \$3::uuid/);
assert.match(service, /lower\(asset_type\.code\) IN \('instalacion', 'subinstalacion'\)/);
assert.match(service, /NOT EXISTS \([\s\S]*inspection_visit_assets linked/);
assert.match(controller, /:id\/preventive-candidates/);
});
test('F6.2 WEB uses the server-scoped preventive selector instead of the mutable Area form', () => {
const page = web('src/pages/InspectionVisitEditorF4Page.tsx');
const apiClient = web('src/features/inspections/preventiveCandidatesApi.ts');
assert.match(page, /listInspectionPreventiveCandidates\(id, assetSearch\)/);
assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,220}operationalAreaId: form\.operationalAreaId/);
assert.match(page, /Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento/);
assert.match(apiClient, /inspection-visits\/\$\{visitId\}\/preventive-candidates/);
});
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.3 field types start at the frozen Yacimiento instead of the Area', () => {
const structure = source('src/inspection-visits/f3-field-inventory-structure.service.ts');
assert.match(structure, /parentId \?\? base\.parent\.id/);
assert.doesNotMatch(structure, /parentId \?\? base\.context\.area\.id/);
});
test('F6.3 an Acta can start empty and receive Inventory when Hallazgos are added', () => {
const dto = source('src/inspection-acts/dto/create-inspection-act.dto.ts');
const service = source('src/inspection-acts/inspection-acts.service.ts');
const mobile = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
assert.doesNotMatch(dto, /ArrayMinSize\(1\)/);
assert.match(service, /if \(assetIds\.length === 0\) return/);
assert.match(mobile, /assetIds = listOfNotNull\(assetId\)/);
});
test('F6.3 every Installation and Subinstallation receives the common field card', () => {
const migration = source('src/database/migrations/1790099100000-f6-3-mobile-field-common-attributes.ts');
for (const label of ['Marca', 'Modelo', 'Capacidad', 'Número de serie', 'Función']) {
assert.match(migration, new RegExp(label));
}
assert.match(migration, /'instalacion','subinstalacion'/);
assert.match(migration, /'TEXT'::asset_attribute_data_type/);
assert.match(migration, /false, true/);
});
test('F6.3 Android follows Inspección → Acta → Hallazgo → Inventario', () => {
const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt');
const vm = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
assert.match(root, /model\.startVisit\(\); onActs\(\)/);
assert.match(root, /Text\("Nuevo Hallazgo"/);
assert.match(root, /Buscar instalación o subinstalación/);
assert.match(acts, /Text\("\+ Agregar Hallazgo"\)/);
assert.match(acts, /model\.createAct\(newActUrgency\)/);
assert.match(vm, /fun createAct\(urgency: String = "NON_URGENT"\)/);
assert.match(vm, /repository\.fieldInventory\(currentVisit\.id, search, parentId\)/);
});
@@ -1,43 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
function apiFile(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
function repoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
}
test('F6.4 Android Installation picker searches live and selects a concrete parent', () => {
const screen = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt');
assert.match(screen, /LaunchedEffect\(modeName, parentSearch\)/);
assert.match(screen, /delay\(280\)/);
assert.match(screen, /model\.searchInventory\(parentSearch\.trim\(\), visit\.scopeAsset\?\.id\)/);
assert.match(screen, /ElevatedCard\(onClick = \{ chooseParent\(item\) \}/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
test('F6.4 Android field shell uses the modern theme and workspace', () => {
const gate = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
const theme = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/DhTheme.kt');
assert.match(gate, /DhTheme \{/);
assert.match(gate, /model\.visit != null -> ModernVisitRoot\(model\)/);
assert.match(theme, /RoundedCornerShape\(16\.dp\)/);
});
test('F6.4 Android Act list uses a mobile read model independent from office reports', () => {
const android = repoFile('android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt');
const controller = apiFile('src/inspection-acts/mobile-inspection-acts.controller.ts');
const service = apiFile('src/inspection-acts/mobile-inspection-acts.service.ts');
assert.match(android, /inspection-visits\/\{visitId\}\/acts\/mobile/);
assert.match(controller, /inspection-visits\/:visitId\/acts\/mobile/);
assert.match(service, /FROM inspection_acts act/);
assert.doesNotMatch(service, /inspection_reports/);
assert.doesNotMatch(service, /inspection_document/);
});
@@ -59,12 +59,11 @@ test('F6 field findings resolve by exact technical classification and keep OTROS
assert.match(resolver, /label: 'OTROS'/);
});
test('F6.1 mobile finding gate uses Yacimiento scope and temporal Area operator relation instead of Inventory company ownership', () => {
test('F6 mobile finding gate uses Area ancestry and temporal operator relation instead of copying company ownership onto each object', () => {
const fieldFindings = source('src/inspection-visits/field-findings.service.ts');
assert.match(fieldFindings, /WITH RECURSIVE ancestors/);
assert.match(fieldFindings, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\)/);
assert.match(fieldFindings, /AS "insideScope"/);
assert.match(fieldFindings, /WHERE id=visit\.operational_area_id/);
assert.match(fieldFindings, /FROM area_company_relations relation/);
assert.match(fieldFindings, /relation\.relation_role='OPERATOR'/);
assert.doesNotMatch(fieldFindings, /asset\.operator_company_id=visit\.operator_company_id/);
@@ -80,15 +79,12 @@ test('F6 field-created technical Inventory may be provisional only until the sel
assert.match(fieldFindings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6.1 Android roots search and reload at the Inspection Yacimiento with legacy Area fallback', () => {
const model = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
test('F6 Android keeps search and reloads scoped to the current physical parent', () => {
const viewModel = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
const screen = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
assert.match(model, /data class VisitDetail[\s\S]*val scopeAsset: AssetSummary\? = null/);
assert.match(viewModel, /var inventoryParentId: String\? by mutableStateOf\(null\)/);
assert.match(viewModel, /currentVisit\.scopeAsset\?\.id \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /visit\?\.scopeAsset\?\.id \?: visit\?\.operationalArea\?\.id/);
assert.match(viewModel, /effectiveParentId = parentId \?: inventoryParentId \?: currentVisit\.operationalArea\?\.id/);
assert.match(viewModel, /repository\.fieldInventory\(currentVisit\.id, null, effectiveParentId\)/);
assert.match(screen, /model\.loadFieldTypes\(item\.id\)/);
});
@@ -1,47 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { ActivityDossierService } from '../../src/asset-master/activity-dossier.service';
test('F7 Actividad no usa DISTINCT con ORDER BY COALESCE en visitas', () => {
const source = readFileSync('src/asset-master/activity-dossier.service.ts', 'utf8');
assert.doesNotMatch(source, /SELECT\s+DISTINCT\s+visit\.id/i);
assert.match(source, /ORDER BY COALESCE\(visit\.actual_started_at, visit\.planned_start_at, visit\.created_at\) DESC/);
assert.match(source, /EXISTS \(\s*SELECT 1\s*FROM inspection_visit_assets/s);
});
test('F7 una faceta auxiliar defectuosa no derriba todo el expediente', async () => {
const dataSource = {
query: async (sql: string) => {
if (sql.includes('FROM assets\n WHERE id = $1')) {
return [{
id: 'bcba740f-cc84-48b3-8d06-88a9c439e25c',
code: 'YAC-0005',
name: 'Agua Botada',
commonName: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
}];
}
if (sql.includes('inspection_finding_communications')) {
throw new Error('simulated optional facet failure');
}
return [];
},
};
const service = new ActivityDossierService(dataSource as never);
const dossier = await service.dossier('bcba740f-cc84-48b3-8d06-88a9c439e25c') as {
asset: { code: string; name: string };
counters: { inspections: number; findings: number };
warnings: string[];
timeline: unknown[];
};
assert.equal(dossier.asset.code, 'YAC-0005');
assert.equal(dossier.asset.name, 'Agua Botada');
assert.equal(dossier.counters.inspections, 0);
assert.equal(dossier.counters.findings, 0);
assert.deepEqual(dossier.warnings, ['communications']);
assert.deepEqual(dossier.timeline, []);
});
@@ -1,26 +0,0 @@
import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
const mergedDossier = readFileSync('src/asset-master/merged-inventory-dossier.service.ts', 'utf8');
const dossierPanel = readFileSync('../web-v2/src/features/assets/AssetDossierPanel.tsx', 'utf8');
const simpleDetail = readFileSync('../web-v2/src/pages/SimpleInventoryDetailPage.tsx', 'utf8');
test('F7 mantiene Actividad disponible para Yacimiento', () => {
assert.match(simpleDetail, /kind === 'YACIMIENTO'/);
assert.match(simpleDetail, /setTab\('activity'\)/);
assert.match(simpleDetail, /AssetDossierPanel assetId=\{asset\.id\}/);
});
test('F7 no hace depender el dossier territorial del subsistema de fusión', () => {
assert.match(mergedDossier, /MERGEABLE_DOSSIER_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(mergedDossier, /if \(!MERGEABLE_DOSSIER_TYPES\.has\(requestedTypeCode\)\)/);
assert.match(mergedDossier, /private readonly activity: ActivityDossierService/);
assert.match(mergedDossier, /return await this\.activity\.dossier\(requestedAssetId\)/);
});
test('F7 la consulta auxiliar de merge nunca bloquea la carga de Actividad', () => {
assert.match(dossierPanel, /getInventoryMergeStatus\(assetId\)\.catch\(\(\) => null\)/);
assert.match(dossierPanel, /getAssetDossier\(assetId\)/);
});
@@ -1,50 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DataSource } from 'typeorm';
import { FieldBriefingService } from '../../src/act-administration/field-briefing.service';
test('field briefing convierte el timestamptz de pg a YYYY-MM-DD antes del cast SQL', async () => {
const calls: Array<{ sql: string; params: unknown[] }> = [];
let queryNumber = 0;
const dataSource = {
query: async (sql: string, params: unknown[]) => {
calls.push({ sql, params });
queryNumber += 1;
if (queryNumber === 1) {
return [{
id: '11111111-1111-4111-8111-111111111111',
code: 'INSP-00002-17-09-26',
status: 'DRAFT',
plannedStartAt: new Date('2026-09-17T23:42:00.000Z'),
areaId: '22222222-2222-4222-8222-222222222222',
companyId: '33333333-3333-4333-8333-333333333333',
areaCode: 'PRES-AREA-TEST',
areaName: 'Barrancas',
companyCode: 'PRES-ORG-TEST',
companyName: 'Petróleos Sudamericanos Energy S.A.',
}];
}
return [];
},
} as unknown as DataSource;
const result = await new FieldBriefingService(dataSource).forVisit(
'11111111-1111-4111-8111-111111111111',
);
assert.equal(calls.length, 2);
assert.equal(calls[1]?.params[3], '2026-09-17');
assert.equal(result.plannedOn, '2026-09-17');
assert.deepEqual(result.summary, {
acts: 0,
findings: 0,
inventoryItems: 0,
responseOverdue: 0,
commitmentOverdue: 0,
verificationPending: 0,
});
assert.deepEqual(result.acts, []);
});
@@ -7,7 +7,6 @@ import { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/
import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto';
import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from '../../src/inspection-visits/dto/update-inspection-visit.dto';
const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5';
const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61';
@@ -26,25 +25,6 @@ test('visit DTO accepts only the minimum planning data required for quick creati
assert.equal(dto.leadInspectorUserId, USER_ID);
});
test('visit update DTO keeps Area, Yacimiento and Operadora immutable after creation', async () => {
const contextAttempt = plainToInstance(UpdateInspectionVisitDto, {
operationalAreaId: AREA_ID,
scopeAssetId: AREA_ID,
operatorCompanyId: COMPANY_ID,
});
const errors = await validate(contextAttempt);
assert.equal(errors.some((error) => error.property === 'operationalAreaId'), true);
assert.equal(errors.some((error) => error.property === 'scopeAssetId'), true);
assert.equal(errors.some((error) => error.property === 'operatorCompanyId'), true);
const ordinaryUpdate = plainToInstance(UpdateInspectionVisitDto, {
objective: 'Control preventivo programado',
plannedStartAt: '2026-09-17T12:00:00.000Z',
instructions: 'Coordinar ingreso con la Operadora.',
});
assert.deepEqual(await validate(ordinaryUpdate), []);
});
test('asset and team DTOs reject duplicate references', async () => {
const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), {
assetIds: [AREA_ID, AREA_ID],
-136
View File
@@ -1,136 +0,0 @@
# F7 · Modelo simple de Inventarios
Este documento fija el nuevo eje funcional de Inventarios para DH Inspección V2. Su objetivo es reducir la complejidad visible y orientar toda la experiencia al trabajo real de inspección.
## 1. Jerarquía
La estructura física es única:
**Departamento → Área → Yacimiento → Instalación → Subinstalación**
- Departamento y Área organizan el territorio.
- Yacimiento, Instalación y Subinstalación son niveles válidos para registrar Hallazgos.
- Una Instalación pertenece a un Yacimiento.
- Una Subinstalación pertenece a una Instalación.
## 2. Empresas
- Un Área puede estar explotada por varias Empresas al mismo tiempo.
- Cada Yacimiento tiene una sola Empresa operadora vigente.
- La Empresa del Yacimiento se elige entre las Empresas vinculadas al Área.
- Instalaciones y Subinstalaciones heredan el contexto de Empresa desde su Yacimiento.
- Cambiar la Empresa de un Yacimiento no mueve ni recrea su Inventario físico.
## 3. Datos simples
Todos los registros usan una base común reducida:
- Nombre.
- Código, opcional y autogenerable.
- Ubicación jerárquica.
- Estado.
- GPS cuando corresponda.
- Observaciones sólo cuando aporten valor.
Departamento, Área y Yacimiento no necesitan un constructor técnico complejo.
Instalaciones y Subinstalaciones pueden tener campos específicos configurados por el Superadmin técnico.
Los tipos de campo visibles para la puesta a punto deben mantenerse simples:
- Texto.
- Número.
- Fecha.
- Sí / No.
Teléfono, email o identificadores simples pueden resolverse como Texto. GPS es un dato básico del registro, no un atributo técnico configurable.
## 4. Administración de tipos
Sólo el Superadmin técnico modifica la configuración.
La pantalla administrativa debe permitir únicamente:
1. Crear/ocultar Tipos de Instalación.
2. Crear/ocultar Tipos de Subinstalación.
3. Definir qué Tipos de Subinstalación puede contener cada Tipo de Instalación.
4. Agregar, mostrar/ocultar y marcar como obligatorios los campos simples de cada tipo.
Los términos técnicos internos como `inventory_families`, reglas de compatibilidad o schemas no deben exponerse al usuario.
## 5. Cambio de tipo / función
La identidad física del elemento no cambia cuando cambia su función.
Ejemplo: `TK-57` sigue siendo `TK-57`, aunque hoy funcione como tanque de petróleo y mañana como tanque de agua.
El cambio debe registrarse cronológicamente con:
- tipo / función anterior;
- tipo / función nueva;
- fecha efectiva;
- observación opcional.
No se crea un Inventario nuevo para representar un cambio de función.
## 6. Cronología útil
Para Yacimiento, Instalación y Subinstalación la cronología debe concentrar sólo hechos operativos relevantes:
- Hallazgos.
- Acta asociada.
- Empresa operadora correspondiente a la fecha.
- Cambio de tipo / función.
- Cambio de ubicación cuando corresponda.
- Alta e inactivación.
La experiencia normal no debe obligar a navegar por procedencia, snapshots, registros técnicos o paneles administrativos separados.
## 7. Filosofía de la APK
La APK existe para **inspeccionar, generar Acta y generar Informe**.
El Inspector no administra Inventarios como tarea separada.
La mayoría de las altas reales ocurren en campo mientras se registra un Hallazgo.
Flujo:
1. La Inspección se planifica desde WEB y ya conoce el Yacimiento.
2. El Inspector abre la Inspección al llegar.
3. Pulsa `+ Crear hallazgo`.
4. Decide si el Hallazgo corresponde a Yacimiento, Instalación o Subinstalación.
5. Si la Instalación no existe, puede crearla rápidamente sin abandonar el flujo.
6. Si necesita una Subinstalación que no existe, puede crearla rápidamente dentro de la Instalación seleccionada.
7. El catálogo ayuda, pero `OTRO` siempre está disponible.
8. Con el tiempo, el Inventario se completa naturalmente y disminuye la necesidad de altas nuevas.
## 8. Planificación y recorrido
La planificación WEB define:
- Área.
- Empresa.
- Yacimiento.
- Inspector.
- Fecha/hora.
- Instalaciones/Subinstalaciones elegidas preventivamente.
- pendientes por vencer, mostrados por elemento a controlar.
- pendientes vencidos, mostrados por elemento a controlar.
La APK presenta ese conjunto como recorrido sugerido. No es una ruta rígida: el Inspector puede registrar Hallazgos nuevos en cualquier momento.
## 9. Actas e Informes
- Abrir una Inspección no crea automáticamente un Acta.
- El Inspector pulsa `+ Nueva Acta` cuando empieza a documentar.
- El número definitivo del Acta se genera al cerrarla.
- Una Inspección puede tener varias Actas.
- Al cerrar un Acta se pueden agregar uno o más acompañantes con Nombre + Email.
- El Acta se envía a Empresa, Inspector y acompañantes.
- El Informe se genera automáticamente en Word y se envía únicamente al Inspector que realizó el Acta.
- La firma o validación legal definitiva queda para una etapa posterior; la prioridad actual es la usabilidad.
## 10. Regla de diseño
Si una pantalla, campo o estado necesita explicación técnica para ser usado correctamente, debe simplificarse antes de incorporarse a la experiencia cotidiana.
+62 -90
View File
@@ -1,124 +1,96 @@
# Modelo documental de inspecciones
Este documento fija el modelo funcional vigente para Inspecciones, Actas,
Hallazgos e Informes. La estructura visual definitiva de los documentos todavía
puede cambiar cuando Hidrocarburos entregue las plantillas oficiales; el contrato
de datos, la trazabilidad y las reglas de inmutabilidad deben permanecer estables.
Este documento fija el modelo funcional validado a partir del Informe Técnico
Nº 124/2026, el acta manuscrita adjunta y la planilla de hallazgos entregada.
## Canales de operación
| Función | APK móvil | Dashboard web |
|---|---:|---:|
| Planificar Inspección, alcance y equipo | Consulta | Administra |
| Administrar Inventarios y catálogo de Hallazgos | Consulta | Administra |
| Iniciar y ejecutar la Inspección | Inspector | No |
| Crear Actas y registrar Hallazgos de campo | Inspector | Sólo consulta |
| Planificar visita, activos y equipo | Consulta | Administra |
| Administrar activos y catálogo de hallazgos | Consulta | Administra |
| Iniciar y ejecutar la visita | Inspector | No |
| Crear o modificar el acta y sus hallazgos | Inspector | Sólo consulta |
| Incorporar evidencia de campo y GPS | Inspector | Sólo consulta |
| Identificar responsable, firmar y sellar Acta | Inspector | Sólo consulta |
| Preparar y revisar el INF | Consulta | Administra |
| Registrar oficialización GEDO/IF y PDF oficial | Consulta | Administra |
| Registrar respuesta empresarial y seguimientos | Consulta | Administra |
| Consultar Actas, Hallazgos e Informes sincronizados | Sí | Sí |
| Identificar responsable, firmar y cerrar | Inspector | Sólo consulta |
| Registrar respuesta empresarial, PDF y próximo control | Consulta | Administra |
| Consultar acta, hallazgos e informe sincronizados | Sí | Sí |
La API aplica esta separación además de la interfaz. Las escrituras operativas de
campo se realizan desde la aplicación móvil con las autorizaciones correspondientes;
el dashboard administra planificación, consulta y circuito documental posterior.
La API aplica esta separación además de la interfaz: las escrituras operativas
requieren autenticación bearer de la aplicación móvil y el rol `inspector`.
Una sesión web no puede eludir la restricción llamando directamente al endpoint.
## Cardinalidades vigentes
## Cardinalidades
| Origen | Relación | Destino |
|---|---:|---|
| Inspección o visita | 1 a muchos | Actas de inspección |
| Inspección o visita | 1 a 1 | Acta de inspección |
| Acta | 1 a muchos | Hallazgos |
| Hallazgo | muchos a 1 | Elemento de Inventario |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable, o propuesta OTROS |
| Hallazgo | 1 a muchos | Evidencias y seguimientos |
| Hallazgo | muchos a 1 | Activo o equipo del Maestro |
| Hallazgo | muchos a 1 | Ítem del catálogo configurable |
| Hallazgo | 1 a muchos | Evidencias, principalmente fotografías |
| Acta | 1 a muchos | Firmas de inspectores |
| Acta | 0 o 1 | Firma/manifestación del responsable de empresa |
| Acta sellada | 0 o 1 | Informe técnico (INF) |
| Informe técnico | 1 a 1 | Acta fuente |
| Informe técnico | incorpora | Acta, Hallazgos, Inventarios y evidencias congelados |
Una Inspección puede, por lo tanto, contener varias Actas y generar varios INF,
pero cada INF conserva una única Acta fuente. No se mezcla el contenido de dos
Actas dentro de un mismo Informe.
| Acta | 1 a 1 | Firma del responsable que atendió la visita |
| Inspección o visita | 1 a 1 | Informe técnico |
| Informe técnico | incorpora | Acta, hallazgos y evidencias congelados |
## Acta digital
El Acta debe poder reunir como mínimo:
El acta debe reunir como mínimo:
- número y año oficial del Acta;
- Inspección vinculada, fecha y hora;
- Departamento, Área, Yacimiento, Operadora y ubicación de la actuación;
- objeto, alcance y descripción de lo actuado;
- Instalaciones y Subinstalaciones inspeccionadas;
- todos los Hallazgos del Acta, con descripción, Inventario afectado, base legal,
reincidencia, gravedad y plazos que correspondan;
- número anual oficial, fecha, hora y ubicación;
- empresa, área, yacimiento o instalación inspeccionada;
- objeto y resultado de la inspección;
- todos los hallazgos de la visita y sus plazos de corrección;
- inspectores intervinientes;
- identidad, DNI, cargo y correo del responsable de la empresa que recibió al
equipo, cuando corresponda;
- firma de inspectores y manifestación/firma del responsable de empresa, incluida
conformidad, disidencia, negativa o ausencia según el flujo aplicable;
- versión inmutable, hash, sello de tiempo y mecanismo de verificación documental;
- referencias a fotografías/evidencias cuando el formulario oficial determine que
deben formar parte del documento.
equipo;
- firmas manuscritas capturadas en pantalla de inspectores y responsable;
- versión inmutable, hash, sello de tiempo y token de verificación.
Al sellarse, el Acta queda inmutable. Los cambios posteriores pertenecen al
seguimiento y no reescriben el contenido constatado. El sistema conserva la
instantánea y hashes necesarios para que el PDF se pueda regenerar o sustituir su
renderer sin alterar la evidencia fuente.
Al finalizar la visita, el acta se cierra y ya no admite edición. Se genera el
PDF verificable y se envía al correo del responsable de la empresa y a una
dirección interna de Hidrocarburos definida por configuración. Cada intento y
resultado de entrega debe quedar auditado.
Si se requiere corregir un acta cerrada, se emitirá una rectificativa vinculada
sin reemplazar ni borrar el documento original.
## Hallazgos
El catálogo de Hallazgos es administrable, importable y versionado. Cada Hallazgo
operativo conserva una instantánea de su descripción y base legal para que cambios
posteriores del catálogo no modifiquen documentos ya emitidos.
La planilla entregada contiene 170 modelos de hallazgo agrupados en 19
categorías de equipos, con descripción, normativa y glosario. Se implementará
como catálogo administrable, importable y versionado. No se copiarán esas
opciones como constantes en la interfaz.
Cada Hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y fecha
comprometida por la empresa, la fecha de próximo control y las verificaciones
posteriores. La respuesta empresarial no cierra por sí sola un Hallazgo: el cierre
surge del flujo de verificación autorizado.
Cada hallazgo operativo conservará una instantánea de su descripción y base
legal para que posteriores cambios del catálogo no alteren documentos ya
emitidos. También podrá registrar estado, observación específica, activo,
ubicación/GPS, plazo y evidencias.
## Informe técnico (INF)
Cada hallazgo nace abierto. Se distinguen el plazo exigido, la respuesta y
fecha comprometida por la empresa, y la fecha de próximo control fijada por el
organismo. La respuesta empresarial no cierra el hallazgo: sólo una verificación
posterior autorizada puede hacerlo.
El INF nace desde una Acta sellada y tiene numeración propia. Mientras está en
preparación dispone de un Word editable para el desarrollo técnico; modificar ese
Word o los campos narrativos no modifica el Acta ni sus Hallazgos fuente.
## Informe técnico
El contrato proyectado del Informe contempla:
El informe se genera después del cierre del acta, con numeración anual global
independiente. Siguiendo el modelo entregado, contempla:
- número/año del INF y Acta referenciada;
- Inspección, Empresa, Departamento, Área, Yacimiento y autores;
- objetivos y antecedentes;
- título, número, acta referenciada, empresa, área/yacimiento y autores;
- objetivos;
- antecedentes;
- marco legal;
- resumen ejecutivo;
- descripción y análisis técnico de la inspección;
- Inventarios inspeccionados;
- detalle de los Hallazgos, reincidencias, gravedad y plazos;
- registro fotográfico/evidencias seleccionadas;
- conclusiones y recomendaciones;
- trazabilidad e integridad de la fuente;
- Acta digital completa como anexo final.
- descripción de la inspección y de todos los hallazgos;
- registro fotográfico;
- conclusión;
- acta digital completa como anexo final.
Cuando GEDO devuelve el identificador IF y su PDF oficial, ambos se registran de
forma inmutable junto con la fecha de oficialización y el hash del archivo. La
carga del IF/PDF oficial no se interpreta por sí sola como notificación a la
empresa ni inicia plazos que dependan de una notificación posterior.
El informe congela las versiones exactas del acta, hallazgos, activos,
evidencias y normativa que incorpora.
## Plantillas PDF provisionales
Hasta recibir los formularios oficiales, la implementación se divide en dos capas:
1. **Contrato de datos estable.** Define qué información debe estar disponible para
Actas e Informes, de dónde proviene, qué versión se congela y qué hashes prueban
su integridad.
2. **Renderer reemplazable.** Encabezado, logos, tipografías, márgenes, cuadros,
orden visual, QR y anexos podrán adaptarse al modelo institucional definitivo
sin modificar el dominio ni volver a diseñar el circuito documental.
El dashboard muestra una sección **PDF preliminar · estructura proyectada** tanto
en la ficha del Acta como en la del Informe. Esa sección no pretende reemplazar el
formulario oficial: sirve para comprobar ahora que el sistema está capturando toda
la información que después deberá volcarse al documento.
La especificación detallada se mantiene en `docs/PDF_PROVISIONAL_STRUCTURE.md`.
La solicitud de creación del informe corresponde al flujo del inspector en la
APK después de cerrar la visita. El backend numera, compone y sella el PDF; el
dashboard sólo lo consulta, descarga y administra su seguimiento documental.
-184
View File
@@ -1,184 +0,0 @@
# Estructura provisional de PDFs · PREVIEW-1
Estado: **provisional hasta recepción de formularios oficiales**.
El objetivo de este documento no es fijar todavía la estética institucional. Fija
el conjunto de datos que DH Inspección debe ser capaz de colocar en los PDFs de
Actas e Informes y separa ese contrato del renderer visual.
## Principio de implementación
El dominio documental no debe depender de coordenadas, cuadros o textos impresos
de una plantilla determinada. Los datos se congelan primero en snapshots
versionados; luego un renderer transforma esa fuente a PDF. Cuando llegue el
formulario oficial se reemplaza o adapta el renderer, no la lógica de negocio.
Se consideran **estables**: identidad del documento, vínculos entre entidades,
contenido constatado, Hallazgos, firmas/manifestaciones, fechas, versiones, hashes
y trazabilidad. Se consideran **reemplazables**: logo, membrete, encabezado,
footer, tipografía, colores, tamaños, posiciones, cantidad de columnas, QR y forma
de los anexos.
## PDF de Acta
### 1. Cabecera institucional
Pendiente de modelo oficial. Debe reservar espacio para organismo, dependencia,
logo/membrete, denominación del documento y numeración.
### 2. Identificación
- número y año del Acta;
- código interno;
- código de Inspección/visita;
- fecha y hora de actuación;
- estado documental y versión.
### 3. Contexto territorial y operativo
- Departamento;
- Área;
- Yacimiento;
- Empresa/Operadora vigente;
- ubicación/GPS cuando corresponda;
- Instalación y Subinstalación relacionadas cuando el Acta tenga ese alcance.
### 4. Personas intervinientes
- Inspectores actuantes;
- responsable de la empresa;
- DNI/identificación;
- cargo o función;
- correo/dato de contacto que exija el formulario.
### 5. Objeto y descripción de lo actuado
- objeto o motivo;
- resumen/constatación;
- observaciones;
- Inventarios inspeccionados con código, nombre, tipo y ruta jerárquica.
### 6. Hallazgos
Por cada Hallazgo:
- código;
- Inventario asociado;
- ítem de catálogo u origen OTROS;
- título/denominación;
- descripción constatada;
- base legal congelada;
- reincidencia y referencia al antecedente, si existe;
- gravedad cuando aplique;
- plazo y tipo de días cuando corresponda;
- referencias a evidencia.
### 7. Firmas y manifestaciones
- firma de cada Inspector interviniente;
- firma/manifestación del responsable de empresa;
- conformidad o disidencia;
- texto de disidencia;
- negativa o ausencia con motivo cuando aplique;
- fecha/hora de cada constancia.
### 8. Integridad
- hash del contenido bloqueado;
- hash final del Acta sellada;
- sello de tiempo;
- versión del esquema;
- espacio para token/QR de verificación si el modelo institucional lo utiliza.
### 9. Anexo de evidencias
El sistema debe estar preparado para incorporar fotografías/evidencias, pero su
inclusión dentro del PDF principal queda pendiente de la plantilla oficial.
## Informe técnico · INF
Cada INF tiene **una sola Acta fuente**. Una Inspección puede contener múltiples
Actas y, por lo tanto, múltiples INF independientes.
### 1. Cabecera e identificación
- número/año y código del INF;
- Acta fuente;
- Inspección;
- fecha de generación;
- autores/Inspector responsable;
- Empresa/Operadora;
- Departamento, Área y Yacimiento.
### 2. Objetivos
Texto editable durante la preparación, si la plantilla oficial lo requiere como
campo separado.
### 3. Antecedentes
Antecedentes relevantes para comprender la inspección y el contexto técnico.
### 4. Marco legal
Normativa aplicable y bases legales congeladas con los Hallazgos del Acta fuente.
### 5. Resumen ejecutivo
Campo narrativo editable en estado de preparación.
### 6. Descripción y análisis técnico
Desarrollo técnico editable que no modifica el contenido del Acta ni de sus
Hallazgos.
### 7. Inventarios inspeccionados
Tabla o listado con código, nombre, tipo y ruta territorial/técnica.
### 8. Hallazgos
Detalle de los Hallazgos del Acta fuente, con descripción, base legal,
reincidencia, gravedad, plazos y vínculo con el Inventario.
### 9. Registro fotográfico
Fotografías/evidencias seleccionadas, identificando Hallazgo/Inventario, fecha y
metadatos disponibles. La maquetación se definirá con el modelo oficial.
### 10. Conclusiones y recomendaciones
Bloque técnico editable durante la preparación del INF.
### 11. GEDO
Al oficializar:
- identificador IF;
- fecha/hora de oficialización;
- nombre del PDF oficial;
- SHA-256 del PDF oficial.
La oficialización no equivale automáticamente a notificación a la empresa.
### 12. Integridad y anexo
- hash del snapshot fuente del INF;
- hash del cierre del Acta;
- versión de Acta utilizada;
- Acta fuente completa como anexo final.
## Reemplazo por la plantilla oficial
Cuando se reciba el Acta o Informe oficial en PDF/Word se deberá:
1. inventariar cada campo del formulario;
2. mapearlo contra este contrato;
3. identificar únicamente los datos faltantes;
4. añadir esos datos al dominio sólo si son información real y no mero diseño;
5. construir el renderer definitivo;
6. comparar visualmente una muestra contra el original;
7. conservar el renderer PREVIEW-1 sólo para pruebas históricas si resultara útil.
No se deben alterar Actas ya selladas para acomodar una nueva plantilla. Los
nuevos renderers deben poder representar snapshots históricos de forma trazable.
+11 -33
View File
@@ -4,59 +4,37 @@ 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"
CREATE_PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
ADMIN_PAGE="$ROOT/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx"
API="$ROOT/web-v2/src/lib/inventoryStructureApi.ts"
PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx"
for file in "$LAYOUT" "$APP" "$CREATE_PAGE" "$ADMIN_PAGE" "$API"; do
for file in "$LAYOUT" "$APP" "$PAGE"; do
test -f "$file" || {
echo "F7 WEB contract: falta $file"
echo "F4 WEB contract: falta $file"
exit 1
}
done
if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then
echo "F7 WEB contract: Relevamientos no debe volver al menú principal"
echo "F4 WEB contract: Relevamientos no debe volver al menú principal"
exit 1
fi
if grep -Fq 'path="/relevamiento/*"' "$APP"; then
echo "F7 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
echo "F4 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas"
exit 1
fi
grep -Fq 'path="/inventarios/nuevo"' "$APP"
grep -Fq 'InventoryCreatePage' "$APP"
for kind in DEPARTAMENTO AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$CREATE_PAGE"
for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do
grep -Fq "kind: '$kind'" "$PAGE"
done
if grep -Fq "kind: 'EQUIPO'" "$CREATE_PAGE"; then
echo "F7 WEB contract: EQUIPO no debe restaurarse como nivel estructural"
if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then
echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural"
exit 1
fi
# F7: Yacimiento elige una Empresa ya vinculada al Área; concesión deja de ser una decisión del alta cotidiana.
grep -Fq 'listCompaniesForInventoryArea' "$CREATE_PAGE"
if grep -Fq 'concessionTypeId' "$CREATE_PAGE"; then
echo "F7 WEB contract: el alta simple no debe exponer Tipo de concesión"
exit 1
fi
grep -Fq 'getInventoryFamilyFindings' "$PAGE"
# El alta normal usa el endpoint simple; el endpoint histórico queda sólo por compatibilidad transitoria.
grep -Fq "'/inventory-structure/simple'" "$API"
# La configuración técnica visible se reduce a tipos, contención y campos sencillos.
grep -Fq 'Tipos y campos' "$ADMIN_PAGE"
grep -Fq 'Tipos de Instalación' "$ADMIN_PAGE"
grep -Fq 'Tipos de Subinstalación' "$ADMIN_PAGE"
grep -Fq '+ Agregar campo' "$ADMIN_PAGE"
# El catálogo de Hallazgos no debe mezclarse con el formulario de alta de Inventario.
if grep -Fq 'getInventoryFamilyFindings' "$CREATE_PAGE"; then
echo "F7 WEB contract: el alta simple no debe cargar catálogo de Hallazgos"
exit 1
fi
echo 'F7 WEB contract: OK'
echo 'F4 WEB contract: OK'
+5 -5
View File
@@ -14,10 +14,10 @@ import { AccessDeniedPage, NotFoundPage } from '../pages/SystemPages';
import { UserDetailPage } from '../pages/UserDetailPage';
import { UsersPage } from '../pages/UsersPage';
import { AssetsPage } from '../pages/AssetsPage';
import { InventoryDetailPage } from '../pages/InventoryDetailPage';
import { AssetEditorPage } from '../pages/AssetEditorPage';
import { InventoryCreatePage } from '../pages/InventoryCreatePage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AuthoritativeInventoryConfigPage } from '../pages/AuthoritativeInventoryConfigPage';
import { AssetTypesPage } from '../pages/AssetTypesPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
@@ -49,9 +49,9 @@ export function App() {
<Route element={<PermissionRoute permission="assets.read" />}><Route path="/mapa" element={<MapPage />} /></Route>
<Route element={<PermissionRoute permission="assets.read" />}>
<Route path="/inventarios" element={<AssetsPage />} />
<Route path="/inventarios/:id" element={<InventoryDetailPage />} />
<Route path="/inventarios/:id" element={<AssetEditorPage />} />
<Route path="/activos" element={<Navigate to="/inventarios" replace />} />
<Route path="/activos/:id" element={<InventoryDetailPage />} />
<Route path="/activos/:id" element={<AssetEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
@@ -79,7 +79,7 @@ export function App() {
<Route element={<PermissionRoute permission="users.create" />}><Route path="/admin/users/new" element={<NewUserPage />} /></Route>
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AuthoritativeInventoryConfigPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AssetTypesPage />} /></Route>
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
<Route path="/sin-acceso" element={<AccessDeniedPage />} />
-26
View File
@@ -1,26 +0,0 @@
import type { AuthUser } from '../lib/api';
function normalizeRole(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
const ADMIN_ROLES = new Set([
'admin',
'administrator',
'administrador',
'superadmin',
'super admin',
'superadministrator',
'super administrator',
'superadministrador',
'super administrador',
]);
export function hasAdministratorRole(user: AuthUser | null) {
return Boolean(user?.roles.some((role) => ADMIN_ROLES.has(normalizeRole(role))));
}
@@ -1,109 +0,0 @@
import { Icon } from './Icon';
type ProjectionKind = 'act' | 'report';
interface ProjectionSection {
title: string;
description: string;
}
const actSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año de Acta, Inspección vinculada, fecha y hora, Departamento, Área, Yacimiento, Operadora y ubicación de la actuación.',
},
{
title: 'Intervinientes',
description: 'Inspectores actuantes y responsable de la empresa: nombre, DNI, cargo, correo y demás datos de identificación que exija el modelo oficial.',
},
{
title: 'Alcance y constatación',
description: 'Objeto de la actuación, descripción de lo actuado, observaciones e Inventarios inspeccionados con su ruta Instalación / Subinstalación.',
},
{
title: 'Hallazgos',
description: 'Código, Inventario afectado, descripción, base legal, reincidencia, gravedad y plazos aplicables, conservando la instantánea emitida en campo.',
},
{
title: 'Firmas y manifestaciones',
description: 'Firmas de inspectores y responsable de empresa, conformidad o disidencia, manifestación textual, negativa/ausencia si correspondiera y marcas de tiempo.',
},
{
title: 'Integridad y verificación',
description: 'Versión sellada, hash SHA-256, sello de tiempo y espacio reservado para token o QR de verificación documental.',
},
{
title: 'Anexos',
description: 'Registro fotográfico y evidencias sólo si el formulario oficial exige incorporarlos al Acta; el modelo queda preparado para hacerlo sin alterar el dato fuente.',
},
];
const reportSections: ProjectionSection[] = [
{
title: 'Identificación y contexto',
description: 'Número y año del INF, Acta fuente, Inspección, Empresa, Departamento, Área, Yacimiento, fechas y autores/intervinientes.',
},
{
title: 'Objetivos y antecedentes',
description: 'Motivo de la inspección, antecedentes relevantes y alcance técnico del Informe.',
},
{
title: 'Marco legal',
description: 'Normativa y bases legales aplicables, incluyendo las referencias congeladas de los Hallazgos del Acta fuente.',
},
{
title: 'Resumen ejecutivo y desarrollo técnico',
description: 'Resumen ejecutivo, descripción de la inspección, análisis técnico y consideraciones editables durante la preparación del INF.',
},
{
title: 'Inventarios y Hallazgos',
description: 'Inventarios inspeccionados y detalle de Hallazgos con descripción, reincidencia, gravedad, plazos y trazabilidad con el Acta.',
},
{
title: 'Registro fotográfico',
description: 'Fotografías y evidencias seleccionadas, con referencia al Inventario/Hallazgo, fecha y metadatos disponibles.',
},
{
title: 'Conclusiones',
description: 'Conclusión técnica, recomendaciones y texto final sujeto a revisión antes de la oficialización.',
},
{
title: 'GEDO e integridad',
description: 'Identificador IF, fecha de oficialización, hash del PDF oficial y hash de la instantánea congelada que originó el Informe.',
},
{
title: 'Anexo final',
description: 'Acta fuente completa e inmutable como anexo del Informe, manteniendo la relación exacta INF → Acta.',
},
];
export function DocumentPdfProjection({ kind }: { kind: ProjectionKind }) {
const isAct = kind === 'act';
const sections = isAct ? actSections : reportSections;
return <section className="panel document-pdf-projection">
<div className="panel-heading">
<div>
<span className="eyebrow">PDF PRELIMINAR · ESTRUCTURA PROYECTADA</span>
<h2>{isAct ? 'Contenido previsto del Acta' : 'Contenido previsto del Informe'}</h2>
<p className="section-copy">El diseño institucional definitivo todavía no fue recibido. Esta vista fija qué información debe poder alimentar el documento, sin comprometer encabezados, tipografías, logos ni distribución final.</p>
</div>
<span className="status-badge pending">Plantilla oficial pendiente</span>
</div>
<div className="temporal-notice">
<Icon name="clipboard" />
<p><strong>Contrato de datos estable, renderer reemplazable.</strong> Cuando llegue el PDF/Word oficial se adapta la composición visual sobre estos mismos datos, sin rehacer el circuito de Inspecciones, Actas, Hallazgos ni trazabilidad.</p>
</div>
<div className="dossier-link-list">
{sections.map((section, index) => <div key={section.title}>
<div>
<strong>{index + 1}. {section.title}</strong>
<small>{section.description}</small>
</div>
<span>Previsto</span>
</div>)}
</div>
</section>;
}
+20 -7
View File
@@ -10,18 +10,31 @@ interface OperationalContextValue {
clearContext: () => void;
}
const LEGACY_STORAGE_KEY = 'dhv2.operational-context';
const STORAGE_KEY = 'dhv2.operational-context';
const OperationalContext = createContext<OperationalContextValue | null>(null);
function readStoredContext(): { areaId: string; companyId: string } {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return { areaId: '', companyId: '' };
const parsed = JSON.parse(raw) as { areaId?: unknown; companyId?: unknown };
return {
areaId: typeof parsed.areaId === 'string' ? parsed.areaId : '',
companyId: typeof parsed.companyId === 'string' ? parsed.companyId : '',
};
} catch {
return { areaId: '', companyId: '' };
}
}
export function OperationalContextProvider({ children }: PropsWithChildren) {
const [areaId, setAreaState] = useState('');
const [companyId, setCompanyState] = useState('');
const initial = useMemo(readStoredContext, []);
const [areaId, setAreaState] = useState(initial.areaId);
const [companyId, setCompanyState] = useState(initial.companyId);
useEffect(() => {
// F6.2 retiró la barra global Área/Operadora. Un contexto persistido sin
// controles visibles podía seguir filtrando pantallas de forma inesperada.
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
}, []);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ areaId, companyId }));
}, [areaId, companyId]);
const value = useMemo<OperationalContextValue>(() => ({
areaId,
@@ -81,12 +81,10 @@ export function AssetDossierPanel({ assetId }: { assetId: string }) {
const load = () => {
setLoading(true);
setError('');
setMergeStatus(null);
Promise.all([
getAssetDossier(assetId),
getAsset(assetId),
// La conciliación es auxiliar: nunca debe bloquear la Actividad del Inventario.
getInventoryMergeStatus(assetId).catch(() => null),
getInventoryMergeStatus(assetId),
])
.then(([loadedDossier, loadedAsset, loadedMerge]) => {
setDossier(loadedDossier as ExtendedDossier);
@@ -4,7 +4,7 @@ import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem, AssetType } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api';
import {
listInventoryCompanies,
listInventoryDepartments,
@@ -18,61 +18,16 @@ import type {
} from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
const structuredInventorySections = [
{
code: 'empresa',
label: 'Empresas',
description: 'Maestro de empresas operadoras. Se vinculan a una o más Áreas sin formar parte del árbol territorial.',
icon: 'users' as const,
},
{
code: 'departamento',
label: 'Departamentos',
description: 'Primer nivel territorial. Desde cada Departamento se accede a sus Áreas.',
icon: 'map' as const,
},
{
code: 'area',
label: 'Áreas',
description: 'Cada Área pertenece a un Departamento y puede tener una o más Empresas operadoras vinculadas.',
icon: 'map' as const,
},
{
code: 'yacimiento',
label: 'Yacimientos',
description: 'Nivel operativo dentro de un Área. Organiza las Instalaciones que pertenecen a ese Yacimiento.',
icon: 'map' as const,
},
{
code: 'instalacion',
label: 'Instalaciones',
description: 'Elementos técnicos ubicados dentro de un Yacimiento y clasificados por su familia de Inventario.',
icon: 'layers' as const,
},
{
code: 'subinstalacion',
label: 'Subinstalaciones',
description: 'Último nivel estructural. Dependen de una Instalación y conservan su clasificación técnica.',
icon: 'layers' as const,
},
] as const;
function navigationHref(base: URLSearchParams, parentId?: string) {
const params = new URLSearchParams(base);
params.delete('view');
params.delete('page');
params.delete('section');
params.delete('companyId');
params.delete('typeId');
parentId ? params.set('parentId', parentId) : params.delete('parentId');
return `/inventarios${params.size ? `?${params}` : ''}`;
}
function inventoryTypeForSection(types: AssetType[], code: string) {
if (code === 'empresa') return types.find((type) => type.operationalRole === 'COMPANY') ?? null;
return types.find((type) => type.code.toLowerCase() === code) ?? null;
}
function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
@@ -125,7 +80,7 @@ function nextLevelLabel(typeCode: string | undefined) {
}
}
export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery; types: AssetType[] }) {
export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams();
@@ -168,48 +123,24 @@ export function AssetHierarchyView({ filters, types }: { filters: InventoryQuery
return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading">
<div>
<span className="eyebrow">INVENTARIOS ESTRUCTURADOS</span>
<h2>Elegí qué querés administrar</h2>
<p>La estructura queda fija y fácil de leer: Empresas como maestro de operadoras y, por separado, Departamento Área Yacimiento Instalación Subinstalación.</p>
</div>
<div><span className="eyebrow">INVENTARIO COMPLETO</span><h2>Departamentos</h2><p>Entrá por Departamento y navegá Área Yacimiento Instalación Subinstalación hasta llegar a su clasificación y Hallazgos asociados.</p></div>
<div className="asset-browser-current-actions"><span className="count-pill">{departments.length} Departamentos</span><span className="count-pill">{companies.length} Empresas</span></div>
</div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-list" aria-label="Secciones estructuradas de Inventarios">
{structuredInventorySections.map((section, index) => {
const assetType = inventoryTypeForSection(types, section.code);
const content = <>
<span className="asset-browser-item-icon"><Icon name={section.icon} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{section.label}</strong>
<small>{section.description}</small>
</span>
<span className="asset-browser-item-status"><small>{section.code === 'empresa' ? 'Maestro independiente' : `Nivel ${index}`}</small></span>
{assetType && <Icon name="chevron" size={16} />}
</>;
return assetType
? <Link key={section.code} className="asset-browser-item" to={`/inventarios?view=list&typeId=${assetType.id}`}>{content}</Link>
: <div key={section.code} className="asset-browser-item" aria-disabled="true">{content}</div>;
})}
</div>
<div className="asset-browser-levels" aria-label="Jerarquía territorial de Inventarios">
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Departamento</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>operadoras vinculadas</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>dentro del Departamento</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>clasificación técnica</small></div><i></i>
<div><span>5</span><strong>Subinstalación</strong><small>clasificación técnica</small></div>
</div>
<section className="asset-browser-group" style={{ marginTop: 20 }}>
<div className="asset-browser-group-heading">
<div><h3>Recorrido por territorio</h3><p>También podés navegar como árbol: entrá por un Departamento y descendé hasta la Instalación o Subinstalación concreta.</p></div>
<span>{departments.length}</span>
</div>
{departments.length === 0
? <EmptyState title="Todavía no hay Departamentos" text="Creá el primer registro para comenzar a construir el Inventario manualmente." />
: <div className="asset-browser-list">{departments.map((department) => <DepartmentCard key={department.id} department={department} href={navigationHref(searchParams, department.id)} />)}</div>}
<div className="asset-browser-group-heading"><div><h3>Empresas</h3><p>Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.</p></div><span>{companies.length}</span></div>
{companies.length === 0 ? <div className="inline-empty">Todavía no hay Empresas cargadas.</div> : <div className="asset-browser-list">{companies.map((company) => <CompanyCard key={company.id} company={company} />)}</div>}
</section>
</div>;
}
@@ -34,10 +34,10 @@ function supportsFunctionChange(code: string, name: string): boolean {
export function AssetHistoryPanel({
assetId,
refreshKey = 0,
refreshKey,
}: {
assetId: string;
refreshKey?: number;
refreshKey: number;
}) {
const [versions, setVersions] = useState<AssetVersionSummary[]>([]);
const [total, setTotal] = useState(0);
@@ -52,12 +52,12 @@ export function AssetMediaPanel({
assetId,
assetName,
canManage,
onChanged = () => undefined,
onChanged,
}: {
assetId: string;
assetName: string;
canManage: boolean;
onChanged?: () => void;
onChanged: () => void;
}) {
const fileRef = useRef<HTMLInputElement | null>(null);
const [items, setItems] = useState<AssetMedia[]>([]);
@@ -1,43 +0,0 @@
export interface InspectionPreventiveCandidate {
id: string;
code: string;
name: string;
typeName: string;
}
interface PreventiveCandidateResponse {
data: InspectionPreventiveCandidate[];
}
export async function listInspectionPreventiveCandidates(
visitId: string,
search = '',
): Promise<InspectionPreventiveCandidate[]> {
const query = new URLSearchParams();
const term = search.trim();
if (term) query.set('search', term);
const response = await fetch(
`/api/v3/inspection-visits/${visitId}/preventive-candidates${query.size ? `?${query}` : ''}`,
{
method: 'GET',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
},
);
if (!response.ok) {
let message = 'No se pudo cargar el Inventario disponible para esta Inspección';
try {
const payload = await response.json() as { message?: string | string[] };
if (Array.isArray(payload.message)) message = payload.message.join('. ');
else if (payload.message) message = payload.message;
} catch {
// Conserva el mensaje funcional cuando la respuesta no sea JSON.
}
throw new Error(message);
}
const payload = await response.json() as PreventiveCandidateResponse;
return payload.data ?? [];
}
+2
View File
@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { APP_PHASE, APP_VERSION } from '../config/version';
import { Icon } from '../components/Icon';
import { OperationalContextBar } from '../components/OperationalContextBar';
import type { IconName } from '../components/Icon';
interface NavItem {
@@ -117,6 +118,7 @@ export function AppLayout() {
<strong>DH Inspección</strong>
<span className="mobile-version">v{APP_VERSION}</span>
</header>
<OperationalContextBar />
<main className="content"><Outlet /></main>
<footer className="app-footer" aria-label="Versión de la aplicación">
<span>DH Inspección V2</span><span>·</span><strong>v{APP_VERSION}</strong><span>·</span><span>{APP_PHASE}</span>
+3 -17
View File
@@ -21,16 +21,7 @@ export interface InventoryTechnicalValues {
values:Record<string,unknown>;
}
export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; }
export interface InventoryStructureOption { id:string; code:string; name:string; }
export interface InventoryAreaCompanyOption extends InventoryStructureOption { commonName?:string|null; typeName?:string; }
export interface InventoryStructureOptions {
independentMasters:InventoryStructureLevel[];
levels:InventoryStructureLevel[];
companies:InventoryStructureOption[];
concessionTypes:InventoryStructureOption[];
installationFamilies:InventoryFamily[];
subinstallationFamilies:InventoryFamily[];
}
export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; 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[]};
@@ -44,9 +35,7 @@ export interface InventoryFamilyFindings { family:InventoryFamily; items:Invento
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}; operationalArea?:null|{id:string;code:string;name:string};
operatorCompany?:null|{id:string;code:string;name:string};
concessionType?:null|{id:string;code:string;name:string};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels?:string[]};
inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]};
}
export function getInventoryStructureOptions(){return apiRequest<InventoryStructureOptions>('/inventory-structure');}
@@ -54,9 +43,6 @@ export async function listInventoryStructureParents(kind:InventoryStructureKind,
const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:'';
return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data;
}
export async function listCompaniesForInventoryArea(areaId:string){
return (await apiRequest<{data:InventoryAreaCompanyOption[]}>(`/asset-operational-relations/areas/${areaId}/companies`)).data;
}
export function getInventoryFamilyFindings(familyId:string){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);}
export function getInventoryFamilyAttributes(familyId:string){return apiRequest<InventoryFamilyAttributes>(`/inventory-families/${familyId}/attributes`);}
export function getInventoryTechnicalValues(assetId:string){return apiRequest<InventoryTechnicalValues>(`/assets/${assetId}/technical-values`);}
@@ -67,4 +53,4 @@ export function updateInventoryFamily(familyId:string,input:{name?:string;parent
export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});}
export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest<InventoryFamilyAttribute>(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});}
export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure/simple',{method:'POST',body:JSON.stringify(input)});}
export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;description?:string|null}){return apiRequest<CreatedInventoryStructure>('/inventory-structure',{method:'POST',body:JSON.stringify(input)});}
-215
View File
@@ -1,215 +0,0 @@
.asset-compact-form {
gap: 0;
padding: 0;
overflow: hidden;
}
.asset-compact-section {
display: grid;
gap: 14px;
padding: 20px 22px;
border-bottom: 1px solid var(--line);
}
.asset-compact-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.asset-compact-section-heading h2 {
margin: 0;
font-size: 17px;
}
.asset-compact-primary-grid {
gap: 12px;
}
.asset-compact-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.asset-compact-meta > span {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 10px;
border: 1px solid #e0e5ed;
border-radius: 8px;
background: #f8fafc;
}
.asset-compact-meta small {
color: var(--muted);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
}
.asset-compact-meta strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ink);
font-size: 11px;
white-space: nowrap;
}
.asset-compact-context-button {
min-height: 34px;
padding: 7px 11px;
white-space: nowrap;
}
.asset-compact-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.asset-compact-context-item {
min-width: 0;
display: grid;
gap: 3px;
padding: 12px 13px;
border: 1px solid #e1e6ee;
border-radius: 10px;
background: #f8fafc;
}
.asset-compact-context-item small {
color: var(--muted);
font-size: 9px;
font-weight: 750;
text-transform: uppercase;
letter-spacing: .05em;
}
.asset-compact-context-item strong {
overflow: hidden;
color: var(--ink);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-compact-context-item span {
overflow: hidden;
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-compact-details {
border-bottom: 1px solid var(--line);
background: #fff;
}
.asset-compact-details > summary {
min-height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 13px 22px;
cursor: pointer;
list-style: none;
user-select: none;
}
.asset-compact-details > summary::-webkit-details-marker {
display: none;
}
.asset-compact-details > summary:hover {
background: #f8fafc;
}
.asset-compact-details > summary > span:first-child {
min-width: 0;
display: grid;
gap: 2px;
}
.asset-compact-details > summary strong {
color: var(--ink);
font-size: 12px;
}
.asset-compact-details > summary small {
overflow: hidden;
color: var(--muted);
font-size: 10px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-compact-details > summary > span:last-child {
color: #7b8496;
font-size: 16px;
transition: transform .18s ease;
}
.asset-compact-details[open] > summary > span:last-child {
transform: rotate(180deg);
}
.asset-compact-details-body {
display: grid;
gap: 16px;
padding: 2px 22px 20px;
}
.asset-compact-technical {
display: grid;
gap: 12px;
padding-top: 2px;
}
.asset-compact-technical h3 {
margin: 0;
}
.asset-compact-form > .form-actions {
padding: 16px 22px 18px;
background: #fafbfc;
}
@media (max-width: 720px) {
.asset-compact-section {
padding: 17px;
}
.asset-compact-section-heading {
display: grid;
}
.asset-compact-context-button {
width: 100%;
}
.asset-compact-context-grid {
grid-template-columns: 1fr;
}
.asset-compact-details > summary {
padding: 13px 17px;
}
.asset-compact-details-body {
padding: 2px 17px 18px;
}
.asset-compact-form > .form-actions {
padding: 14px 17px 17px;
}
}
+82 -791
View File
@@ -1,18 +1,11 @@
import './AssetEditorPage.css';
import { SearchableSelect } from '../components/SearchableSelect';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import type { FormEvent } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
assetOperationalStatusLabel,
assetStatusClass,
assetStatusLabel,
} from '../features/assets/assetPresentation';
import { ASSET_OPERATIONAL_STATUSES, ASSET_STATUSES, assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from '../features/assets/assetPresentation';
import { AssetHistoryPanel } from '../features/assets/AssetHistoryPanel';
import { AssetDossierPanel } from '../features/assets/AssetDossierPanel';
import { AssetMediaPanel } from '../features/assets/AssetMediaPanel';
@@ -22,31 +15,15 @@ import { AssetRegistryPanel } from '../features/assets/AssetRegistryPanel';
import { AssetContextHistoryPanel } from '../features/assets/AssetContextHistoryPanel';
import { AssetFindingCatalogPanel } from '../features/assets/AssetFindingCatalogPanel';
import {
createAsset,
getAsset,
getAssetLineage,
listAssetParentOptions,
listAssetTypes,
listCompaniesForArea,
listOperationalAreas,
updateAsset,
updateAssetInformationStatus,
updateAssetOperationalStatus,
createAsset, getAsset, getAssetLineage, listAssetParentOptions, listAssetTypes, listCompaniesForArea,
listOperationalAreas, updateAsset, updateAssetInformationStatus, updateAssetOperationalStatus,
} from '../lib/api';
import type {
AssetAttributeDefinition,
AssetDetail,
AssetInformationStatus,
AssetOperationalStatus,
AssetLineageItem,
AssetListItem,
AssetType,
OperationalAssetSummary,
AssetAttributeDefinition, AssetDetail, AssetInformationStatus, AssetOperationalStatus,
AssetLineageItem, AssetListItem, AssetType, OperationalAssetSummary,
} from '../lib/api';
const AssetGeometryEditor = lazy(() =>
import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })),
);
const AssetGeometryEditor = lazy(() => import('../features/map/AssetGeometryEditor').then((module) => ({ default: module.AssetGeometryEditor })));
type DetailTab = 'summary' | 'dossier' | 'findings' | 'location' | 'registry' | 'files' | 'history';
@@ -58,34 +35,16 @@ function localDateTime(value: unknown): string {
return local.toISOString().slice(0, 16);
}
function normalizeAttributeValues(
definitions: AssetAttributeDefinition[],
values: Record<string, unknown>,
): Record<string, unknown> {
function normalizeAttributeValues(definitions: AssetAttributeDefinition[], values: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
definitions.filter((item) => item.isActive).forEach((definition) => {
const raw = values[definition.id];
if (definition.dataType === 'BOOLEAN') result[definition.id] = Boolean(raw);
else if (raw !== undefined && raw !== null && raw !== '') {
result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
}
else if (raw !== undefined && raw !== null && raw !== '') result[definition.id] = definition.dataType === 'NUMBER' ? Number(raw) : raw;
});
return result;
}
function hasAttributeValue(definition: AssetAttributeDefinition, value: unknown): boolean {
if (definition.dataType === 'BOOLEAN') return value === true;
return value !== undefined && value !== null && String(value).trim() !== '';
}
function normalizeStructuralType(value: string | null | undefined): string {
return (value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase();
}
export function AssetEditorPage() {
const { id } = useParams();
const editing = Boolean(id);
@@ -93,11 +52,7 @@ export function AssetEditorPage() {
const [searchParams] = useSearchParams();
const contextParentId = !editing ? searchParams.get('parentId') : null;
const requestedTab = searchParams.get('tab') as DetailTab | null;
const tab: DetailTab = requestedTab
&& ['summary', 'dossier', 'findings', 'location', 'registry', 'files', 'history'].includes(requestedTab)
? requestedTab
: 'summary';
const tab: DetailTab = requestedTab && ['summary','dossier','findings','location','registry','files','history'].includes(requestedTab) ? requestedTab : 'summary';
const { hasPermission } = useAuth();
const canEdit = editing ? hasPermission('assets.update') : hasPermission('assets.create');
const canCreate = hasPermission('assets.create');
@@ -149,14 +104,10 @@ export function AssetEditorPage() {
const [success, setSuccess] = useState('');
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const canDirectContextEdit = !editing
|| Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
const canDirectContextEdit = !editing || Boolean(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT');
const selectedType = types.find((type) => type.id === typeId) ?? null;
const definitions = useMemo(
() => selectedType?.attributes.filter((item) => item.isActive) ?? [],
[selectedType],
);
const definitions = useMemo(() => selectedType?.attributes.filter((item) => item.isActive) ?? [], [selectedType]);
useEffect(() => {
Promise.all([
@@ -168,26 +119,13 @@ export function AssetEditorPage() {
setTypes(loadedTypes);
setLineage(loadedLineage);
if (loadedAsset) {
setAsset(loadedAsset);
setCode(loadedAsset.code);
setName(loadedAsset.name);
setCommonName(loadedAsset.commonName ?? '');
setTypeId(loadedAsset.type.id);
setParentId(loadedAsset.parent?.id ?? '');
setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? '');
setDescription(loadedAsset.description ?? '');
setStatus(loadedAsset.informationStatus);
setOperationalStatus(loadedAsset.operationalStatus);
setAttributeValues(Object.fromEntries(
loadedAsset.attributes.map((attribute) => [
attribute.definitionId,
attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? '',
]),
));
setAsset(loadedAsset); setCode(loadedAsset.code); setName(loadedAsset.name); setCommonName(loadedAsset.commonName ?? ''); setTypeId(loadedAsset.type.id);
setParentId(loadedAsset.parent?.id ?? ''); setOperationalAreaId(loadedAsset.operationalArea?.id ?? '');
setOperatorCompanyId(loadedAsset.operatorCompany?.id ?? ''); setDescription(loadedAsset.description ?? '');
setStatus(loadedAsset.informationStatus); setOperationalStatus(loadedAsset.operationalStatus);
setAttributeValues(Object.fromEntries(loadedAsset.attributes.map((attribute) => [attribute.definitionId, attribute.dataType === 'DATETIME' ? localDateTime(attribute.value) : attribute.value ?? ''])));
} else if (!contextParentId) {
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot)
?? loadedTypes.find((type) => type.isActive);
const first = loadedTypes.find((type) => type.isActive && type.canBeRoot) ?? loadedTypes.find((type) => type.isActive);
if (first) setTypeId(first.id);
}
})
@@ -197,56 +135,38 @@ export function AssetEditorPage() {
useEffect(() => {
if (editing || !contextParentId || types.length === 0) return;
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)])
.then(([parent, parentLineage]) => {
setContextParent(parent);
setLineage(parentLineage);
const compatible = types.find(
(type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id),
);
if (compatible) {
setTypeId(compatible.id);
setParentId(parent.id);
const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
}
})
.catch((requestError) => setError(errorMessage(requestError)));
Promise.all([getAsset(contextParentId), getAssetLineage(contextParentId)]).then(([parent, parentLineage]) => {
setContextParent(parent);
setLineage(parentLineage);
const compatible = types.find((type) => type.isActive && type.allowedParentTypes.some((allowed) => allowed.id === parent.type.id));
if (compatible) {
setTypeId(compatible.id); setParentId(parent.id);
const parentType = types.find((type) => type.id === parent.type.id);
if (parentType?.operationalRole === 'AREA') setOperationalAreaId(parent.id);
else if (parent.operationalArea) setOperationalAreaId(parent.operationalArea.id);
}
}).catch((requestError) => setError(errorMessage(requestError)));
}, [editing, contextParentId, types]);
useEffect(() => {
if (!typeId) {
setParents([]);
return;
}
const timer = window.setTimeout(
() => listAssetParentOptions(typeId, id, parentSearch)
.then(setParents)
.catch((requestError) => setError(errorMessage(requestError))),
220,
);
if (!typeId) { setParents([]); return; }
const timer = window.setTimeout(() => listAssetParentOptions(typeId, id, parentSearch).then(setParents).catch((requestError) => setError(errorMessage(requestError))), 220);
return () => window.clearTimeout(timer);
}, [typeId, id, parentSearch]);
useEffect(() => {
if (!canReadRelations || selectedType?.operationalRole !== 'GENERIC' || !parentId) {
setOperationalAreas([]);
if (!parentId) {
if (!parentId) { setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); }
return;
}
listOperationalAreas(parentId).then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
return;
}
listOperationalAreas(parentId)
.then((loadedAreas) => {
setOperationalAreas(loadedAreas);
if (operationalAreaId && !loadedAreas.some((area) => area.id === operationalAreaId)) {
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
}
})
.catch((requestError) => setError(errorMessage(requestError)));
}).catch((requestError) => setError(errorMessage(requestError)));
}, [canReadRelations, selectedType?.operationalRole, parentId, editing]);
useEffect(() => {
@@ -254,85 +174,39 @@ export function AssetEditorPage() {
setOperationalCompanies([]);
return;
}
listCompaniesForArea(operationalAreaId)
.then((loadedCompanies) => {
setOperationalCompanies(loadedCompanies);
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
? current
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
})
.catch((requestError) => {
setOperationalCompanies([]);
setOperatorCompanyId('');
setError(errorMessage(requestError));
});
listCompaniesForArea(operationalAreaId).then((loadedCompanies) => {
setOperationalCompanies(loadedCompanies);
setOperatorCompanyId((current) => loadedCompanies.some((company) => company.id === current)
? current
: loadedCompanies.length === 1 ? (loadedCompanies[0]?.id ?? '') : '');
}).catch((requestError) => {
setOperationalCompanies([]);
setOperatorCompanyId('');
setError(errorMessage(requestError));
});
}, [editing, canReadRelations, operationalAreaId, selectedType?.operationalRole]);
const changeType = (nextTypeId: string) => {
setTypeId(nextTypeId);
setParentId('');
setOperationalAreaId('');
if (!editing) setOperatorCompanyId('');
setAttributeValues({});
setParentSearch('');
};
const setAttribute = (definitionId: string, value: unknown) =>
setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const changeType = (nextTypeId: string) => { setTypeId(nextTypeId); setParentId(''); setOperationalAreaId(''); if (!editing) setOperatorCompanyId(''); setAttributeValues({}); setParentSearch(''); };
const setAttribute = (definitionId: string, value: unknown) => setAttributeValues((current) => ({ ...current, [definitionId]: value }));
const save = async (event: FormEvent) => {
event.preventDefault();
if (!selectedType) return;
setSaving(true);
setError('');
setSuccess('');
event.preventDefault(); if (!selectedType) return;
setSaving(true); setError(''); setSuccess('');
try {
const attributes = normalizeAttributeValues(definitions, attributeValues);
let saved: AssetDetail;
if (editing && id) {
if (canEdit) {
saved = await updateAsset(id, {
typeId: canDirectContextEdit ? typeId : undefined,
code,
name,
commonName: commonName.trim() || null,
parentId: canDirectContextEdit ? parentId || null : undefined,
operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined,
description: description.trim() || null,
attributes,
});
} else if (asset) saved = asset;
else return;
if (canChangeStatus && saved.informationStatus !== status) {
saved = await updateAssetInformationStatus(id, status);
}
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) {
saved = await updateAssetOperationalStatus(id, operationalStatus);
}
setAsset(saved);
setSuccess('Registro actualizado correctamente');
setHistoryRefreshKey((current) => current + 1);
if (canEdit) saved = await updateAsset(id, { typeId: canDirectContextEdit ? typeId : undefined, code, name, commonName: commonName.trim() || null, parentId: canDirectContextEdit ? parentId || null : undefined, operationalAreaId: canDirectContextEdit ? operationalAreaId || null : undefined, description: description.trim() || null, attributes });
else if (asset) saved = asset; else return;
if (canChangeStatus && saved.informationStatus !== status) saved = await updateAssetInformationStatus(id, status);
if (canChangeOperationalStatus && saved.operationalStatus !== operationalStatus) saved = await updateAssetOperationalStatus(id, operationalStatus);
setAsset(saved); setSuccess('Registro actualizado correctamente'); setHistoryRefreshKey((current) => current + 1);
} else {
saved = await createAsset({
code,
name,
commonName: commonName.trim() || null,
typeId,
parentId: parentId || null,
operationalAreaId: operationalAreaId || null,
operatorCompanyId: operatorCompanyId || null,
description: description.trim() || null,
informationStatus: canChangeStatus ? status : 'DRAFT',
attributes,
});
saved = await createAsset({ code, name, commonName: commonName.trim() || null, typeId, parentId: parentId || null, operationalAreaId: operationalAreaId || null, operatorCompanyId: operatorCompanyId || null, description: description.trim() || null, informationStatus: canChangeStatus ? status : 'DRAFT', attributes });
navigate(`/inventarios/${saved.id}`, { replace: true });
}
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando registro…" />;
@@ -347,627 +221,44 @@ export function AssetEditorPage() {
{ key: 'history', label: 'Historial', show: editing && canReadHistory },
] as const;
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa'
? 'companies'
: 'territory';
const structuralTypeCode = normalizeStructuralType(selectedType?.code);
const structuralTypeName = normalizeStructuralType(selectedType?.name);
const compactStructuralSummary = editing && (
['instalacion', 'instalacion-superficie', 'instalacion_de_superficie', 'instalacion-de-superficie', 'subinstalacion']
.includes(structuralTypeCode)
|| structuralTypeName === 'instalacion'
|| structuralTypeName === 'instalacion de superficie'
|| structuralTypeName === 'subinstalacion'
);
const technicalFilledCount = definitions.filter(
(definition) => hasAttributeValue(definition, attributeValues[definition.id]),
).length;
const missingRequiredTechnical = definitions.some(
(definition) => definition.isRequired && !hasAttributeValue(definition, attributeValues[definition.id]),
);
const handleContextChanged = (saved: AssetDetail) => {
setAsset(saved);
setParentId(saved.parent?.id ?? '');
setOperationalAreaId(saved.operationalArea?.id ?? '');
setOperatorCompanyId(saved.operatorCompany?.id ?? '');
setLineage([]);
getAssetLineage(saved.id).then(setLineage).catch(() => undefined);
setHistoryRefreshKey((current) => current + 1);
};
const renderAttributes = (): ReactNode => {
if (definitions.length === 0) {
return <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div>;
}
return <div className="dynamic-attributes">
{definitions.map((definition) => {
const value = attributeValues[definition.id];
const label = <span>
{definition.name}{definition.unit ? ` (${definition.unit})` : ''}
{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}
</span>;
if (definition.dataType === 'BOOLEAN') {
return <label className="check-row attribute-check" key={definition.id}>
<input
type="checkbox"
checked={Boolean(value)}
onChange={(event) => setAttribute(definition.id, event.target.checked)}
disabled={!canEdit}
/>
<span><strong>{definition.name}</strong><small>{definition.code}</small></span>
</label>;
}
if (definition.dataType === 'SELECT') {
return <label className="field" key={definition.id}>
{label}
<SearchableSelect
value={String(value ?? '')}
onChange={(event) => setAttribute(definition.id, event.target.value)}
disabled={!canEdit}
required={definition.isRequired}
>
<option value="">Seleccionar</option>
{definition.options?.map((option) =>
<option key={option} value={option}>{option}</option>)}
</SearchableSelect>
</label>;
}
const inputType = definition.dataType === 'NUMBER'
? 'number'
: definition.dataType === 'DATE'
? 'date'
: definition.dataType === 'DATETIME'
? 'datetime-local'
: 'text';
return <label className="field" key={definition.id}>
{label}
<input
type={inputType}
value={String(value ?? '')}
onChange={(event) => setAttribute(definition.id, event.target.value)}
disabled={!canEdit}
required={definition.isRequired}
step={definition.dataType === 'NUMBER' ? 'any' : undefined}
maxLength={definition.dataType === 'TEXT' ? 4000 : undefined}
/>
</label>;
})}
</div>;
};
const renderSaveActions = () => canSave && <div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={saving}>
<Icon name="check" />
{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}
</button>
</div>;
const renderCompactStructuralForm = () => <form className="panel form-panel asset-compact-form" onSubmit={save}>
<section className="asset-compact-section">
<div className="asset-compact-section-heading">
<div>
<h2>Datos principales</h2>
<p className="section-copy">Lo que se usa todos los días para reconocer este registro.</p>
</div>
</div>
<div className="form-grid asset-compact-primary-grid">
<label className="field">
<span>Nombre técnico</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
disabled={!canEdit}
required
maxLength={200}
/>
</label>
<label className="field">
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
<input
value={commonName}
onChange={(event) => setCommonName(event.target.value)}
disabled={!canEdit}
maxLength={200}
placeholder="Ej.: planta vieja, celda principal…"
/>
</label>
</div>
<div className="asset-compact-meta">
<span><small>Código DH</small><strong>{code}</strong></span>
<span><small>Tipo</small><strong>{selectedType?.name ?? 'Sin tipo'}</strong></span>
</div>
</section>
<section className="asset-compact-section">
<div className="asset-compact-section-heading">
<div>
<h2>Ubicación actual</h2>
<p className="section-copy">Dónde está contenido el elemento dentro del Inventario.</p>
</div>
{canReadHistory && id && <Link className="button secondary asset-compact-context-button" to={`/inventarios/${id}?tab=history`}>
<Icon name="edit" />Cambiar / ver historial
</Link>}
</div>
<div className="asset-compact-context-grid">
<div className="asset-compact-context-item">
<small>Registro padre</small>
<strong>{asset?.parent?.name ?? 'Sin padre'}</strong>
{asset?.parent?.code && <span>{asset.parent.code}</span>}
</div>
<div className="asset-compact-context-item">
<small>Área</small>
<strong>{asset?.operationalArea?.name ?? 'Sin área asignada'}</strong>
{asset?.operationalArea?.code && <span>{asset.operationalArea.code}</span>}
</div>
</div>
</section>
{(canChangeStatus || canChangeOperationalStatus) && <details className="asset-compact-details">
<summary>
<span><strong>Estado y opciones</strong><small>Calidad del dato y situación operativa</small></span>
<span aria-hidden="true"></span>
</summary>
<div className="asset-compact-details-body">
<div className="form-grid">
{canChangeStatus && <label className="field">
<span>Estado del dato</span>
<SearchableSelect
value={status}
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
disabled={!canEdit && !canChangeStatus}
>
{ASSET_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
</label>}
{canChangeOperationalStatus && <label className="field">
<span>Estado operativo</span>
<SearchableSelect
value={operationalStatus}
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
>
{ASSET_OPERATIONAL_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
</label>}
</div>
</div>
</details>}
<details className="asset-compact-details" open={missingRequiredTechnical || canDirectContextEdit}>
<summary>
<span>
<strong>Más datos</strong>
<small>
{description.trim() ? 'Descripción cargada' : 'Sin descripción'}
{' · '}
{technicalFilledCount}/{definitions.length} datos técnicos cargados
</small>
</span>
<span aria-hidden="true"></span>
</summary>
<div className="asset-compact-details-body">
<div className="form-grid">
<label className="field">
<span>Código DH</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.toUpperCase())}
disabled={!canEdit}
required
maxLength={120}
pattern="[A-Z0-9][A-Z0-9._/-]*"
/>
</label>
{canDirectContextEdit && <label className="field">
<span>Tipo de elemento</span>
<SearchableSelect
value={typeId}
onChange={(event) => changeType(event.target.value)}
disabled={!canEdit}
required
>
<option value="">Seleccionar</option>
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
<option key={type.id} value={type.id}>{type.name}</option>)}
</SearchableSelect>
</label>}
</div>
{canDirectContextEdit && <div className="form-grid">
<div className="field parent-picker">
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
<input
className="parent-search"
value={parentSearch}
onChange={(event) => setParentSearch(event.target.value)}
disabled={!canEdit}
placeholder="Buscar planta, batería, estación…"
/>
<SearchableSelect
value={parentId}
onChange={(event) => {
setParentId(event.target.value);
setParentSearch('');
}}
disabled={!canEdit}
required={!selectedType?.canBeRoot}
>
<option value="">
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
</option>
{parents.map((parent) =>
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
</SearchableSelect>
</div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <label className="field">
<span>Área</span>
<SearchableSelect
value={operationalAreaId}
onChange={(event) => setOperationalAreaId(event.target.value)}
disabled={!canEdit}
>
<option value="">Sin asignación operativa</option>
{operationalAreas.map((area) =>
<option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
</label>}
</div>}
<label className="field">
<span>Descripción <em>opcional</em></span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={!canEdit}
maxLength={4000}
rows={2}
/>
</label>
<div className="asset-compact-technical">
<div>
<h3>Datos técnicos</h3>
<p className="section-copy">Sólo completá lo que corresponda para este tipo.</p>
</div>
{renderAttributes()}
</div>
</div>
</details>
{renderSaveActions()}
</form>;
const renderFullForm = () => <form className="panel form-panel" onSubmit={save}>
<div className="form-section">
<div>
<h2>Identificación</h2>
<p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p>
</div>
<div className="form-grid">
<label className="field">
<span>Código DH</span>
<input
value={code}
onChange={(event) => setCode(event.target.value.toUpperCase())}
disabled={!canEdit}
required
maxLength={120}
pattern="[A-Z0-9][A-Z0-9._/-]*"
/>
</label>
<label className="field">
<span>Nombre técnico</span>
<input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} />
</label>
<label className="field">
<span>Nombre habitual / sobrenombre <em>opcional</em></span>
<input
value={commonName}
onChange={(event) => setCommonName(event.target.value)}
disabled={!canEdit}
maxLength={200}
placeholder="Ej.: tanque grande, ET vieja, batería norte…"
/>
<small>También se usa en las búsquedas del Inventario.</small>
</label>
</div>
<label className="field">
<span>Descripción <em>opcional</em></span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={!canEdit}
maxLength={4000}
rows={2}
/>
</label>
</div>
<div className="form-section">
<div>
<h2>Ubicación en la estructura</h2>
<p className="section-copy">
Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área.
Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.
</p>
</div>
<div className="form-grid">
<label className="field">
<span>Tipo de elemento</span>
<SearchableSelect
value={typeId}
onChange={(event) => changeType(event.target.value)}
disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit}
required
>
<option value="">Seleccionar</option>
{types.filter((type) => type.isActive || type.id === typeId).map((type) =>
<option key={type.id} value={type.id}>{type.name}</option>)}
</SearchableSelect>
</label>
<div className="field parent-picker">
<span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span>
<input
className="parent-search"
value={parentSearch}
onChange={(event) => setParentSearch(event.target.value)}
disabled={!canEdit || !canDirectContextEdit}
placeholder="Buscar planta, batería, estación…"
/>
<SearchableSelect
value={parentId}
onChange={(event) => {
setParentId(event.target.value);
setParentSearch('');
}}
disabled={!canEdit || !canDirectContextEdit}
required={!selectedType?.canBeRoot}
>
<option value="">
{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}
</option>
{parents.map((parent) =>
<option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}
</SearchableSelect>
<small>Escribí para buscar entre los registros compatibles.</small>
</div>
</div>
<div className="form-grid">
{canChangeStatus && <label className="field">
<span>Estado del dato</span>
<SearchableSelect
value={status}
onChange={(event) => setStatus(event.target.value as AssetInformationStatus)}
disabled={!canEdit && !canChangeStatus}
>
{ASSET_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
<small>Calidad y validación del registro.</small>
</label>}
{editing && canChangeOperationalStatus && <label className="field">
<span>Estado operativo</span>
<SearchableSelect
value={operationalStatus}
onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}
>
{ASSET_OPERATIONAL_STATUSES.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>)}
</SearchableSelect>
<small>Situación física u operativa del elemento.</small>
</label>}
</div>
</div>
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section">
<div>
<h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2>
<p className="section-copy">
{editing
? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.'
: 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}
</p>
</div>
<div className="form-grid">
<label className="field">
<span>Área</span>
<SearchableSelect
value={operationalAreaId}
onChange={(event) => {
setOperationalAreaId(event.target.value);
if (!editing) setOperatorCompanyId('');
}}
disabled={!canEdit || !canDirectContextEdit}
>
<option value="">Sin asignación operativa</option>
{operationalAreas.map((area) =>
<option key={area.id} value={area.id}>{area.name}</option>)}
</SearchableSelect>
{editing && <small>La Operadora se resuelve por la relación ÁreaEmpresa vigente.</small>}
</label>
{!editing && <label className="field">
<span>Operadora {operationalAreaId && <em>obligatoria</em>}</span>
<SearchableSelect
value={operatorCompanyId}
onChange={(event) => setOperatorCompanyId(event.target.value)}
disabled={!canEdit || !operationalAreaId}
required={Boolean(operationalAreaId)}
>
<option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>
{operationalCompanies.map((company) =>
<option key={company.id} value={company.id}>{company.name}</option>)}
</SearchableSelect>
</label>}
</div>
{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice">
<Icon name="check" />
<p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p>
</div>}
{editing && asset?.operatorCompany && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p>
</div>}
</div>}
<div className="form-section">
<div>
<h2>Datos técnicos</h2>
<p className="section-copy">Campos definidos para el tipo seleccionado.</p>
</div>
{renderAttributes()}
</div>
{renderSaveActions()}
</form>;
const breadcrumbSection = asset?.type.code === 'empresa' || contextParent?.type.code === 'empresa' ? 'companies' : 'territory';
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>
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>
{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}
</Link>
<Link to={breadcrumbSection === 'companies' ? '/inventarios?section=companies' : '/inventarios?section=territory'}>{breadcrumbSection === 'companies' ? 'Empresas' : 'Áreas y yacimientos'}</Link>
{lineage.map((item, index) => {
const isLast = index === lineage.length - 1;
const showAsCurrent = editing ? isLast : false;
return <span className="asset-detail-crumb-part" key={item.id}>
<span></span>
{showAsCurrent
? <strong>{item.name}</strong>
: <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}
</span>;
return <span className="asset-detail-crumb-part" key={item.id}><span></span>{showAsCurrent ? <strong>{item.name}</strong> : <Link to={`/inventarios?section=${breadcrumbSection}&parentId=${item.id}`}>{item.name}</Link>}</span>;
})}
{!editing && <><span></span><strong>Nuevo registro</strong></>}
</nav>
<div className="page-heading asset-editor-heading"><div><span className="eyebrow">INVENTARIO</span><h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1><p>{editing ? `${asset?.type.name} · ${asset?.code}` : contextParent ? `El sistema heredará el contexto disponible de ${contextParent.code}.` : 'Creá una entidad con identidad, jerarquía y atributos propios.'}</p></div><div className="asset-heading-actions">{editing && asset && <div className="heading-statuses"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div>}{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}><Icon name="plus" />Agregar registro aquí</Link>}</div></div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">INVENTARIO</span>
<h1>{editing ? asset?.name ?? 'Registro' : contextParent ? `Agregar en ${contextParent.name}` : 'Nuevo registro'}</h1>
<p>
{editing
? `${asset?.type.name} · ${asset?.code}`
: contextParent
? `El sistema heredará el contexto disponible de ${contextParent.code}.`
: 'Creá una entidad con identidad, jerarquía y atributos propios.'}
</p>
</div>
<div className="asset-heading-actions">
{editing && asset && <div className="heading-statuses">
<span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>
{assetStatusLabel(asset.informationStatus)}
</span>
<small>{assetOperationalStatusLabel(asset.operationalStatus)}</small>
</div>}
{editing && id && canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${id}`}>
<Icon name="plus" />Agregar registro aquí
</Link>}
</div>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
{editing && <nav className="asset-detail-tabs">
{detailTabs.filter((item) => item.show).map((item) =>
<Link
key={item.key}
className={tab === item.key ? 'active' : ''}
to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}
>
{item.label}
</Link>)}
</nav>}
{editing && <nav className="asset-detail-tabs">{detailTabs.filter((item) => item.show).map((item) => <Link key={item.key} className={tab === item.key ? 'active' : ''} to={`/inventarios/${id}${item.key === 'summary' ? '' : `?tab=${item.key}`}`}>{item.label}</Link>)}</nav>}
{(!editing || tab === 'summary') && <>
{contextParent && !editing && <div className="context-create-banner">
<Icon name="layers" />
<div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div>
<Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link>
</div>}
{contextParent && !editing && <div className="context-create-banner"><Icon name="layers" /><div><strong>Alta contextual</strong><span>{contextParent.name} · {contextParent.code}</span></div><Link to={`/inventarios/${contextParent.id}`}>Ver padre</Link></div>}
<form className="panel form-panel" onSubmit={save}>
<div className="form-section"><div><h2>Identificación</h2><p className="section-copy">Conservá el nombre técnico y, cuando exista, agregá el nombre habitual usado en campo.</p></div><div className="form-grid"><label className="field"><span>Código DH</span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} disabled={!canEdit} required maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" /></label><label className="field"><span>Nombre técnico</span><input value={name} onChange={(event) => setName(event.target.value)} disabled={!canEdit} required maxLength={200} /></label><label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} disabled={!canEdit} maxLength={200} placeholder="Ej.: tanque grande, ET vieja, batería norte…" /><small>También se usa en las búsquedas del Inventario.</small></label></div><label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canEdit} maxLength={4000} rows={2} /></label></div>
{compactStructuralSummary ? renderCompactStructuralForm() : renderFullForm()}
<div className="form-section"><div><h2>Ubicación en la estructura</h2><p className="section-copy">Elegí qué es y dónde está contenido. La ubicación física es independiente de la Operadora del Área. Los registros ya consolidados cambian de ubicación desde el bloque histórico inferior.</p></div><div className="form-grid"><label className="field"><span>Tipo de elemento</span><SearchableSelect value={typeId} onChange={(event) => changeType(event.target.value)} disabled={(editing && !(asset?.dataOrigin === 'FIELD_SURVEY' && asset.informationStatus === 'DRAFT')) || !canEdit} required><option value="">Seleccionar</option>{types.filter((type) => type.isActive || type.id === typeId).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label><div className="field parent-picker"><span>Registro padre {!selectedType?.canBeRoot && <em>obligatorio</em>}</span><input className="parent-search" value={parentSearch} onChange={(event) => setParentSearch(event.target.value)} disabled={!canEdit || !canDirectContextEdit} placeholder="Buscar planta, batería, estación…" /><SearchableSelect value={parentId} onChange={(event) => { setParentId(event.target.value); setParentSearch(''); }} disabled={!canEdit || !canDirectContextEdit} required={!selectedType?.canBeRoot}><option value="">{selectedType?.canBeRoot ? 'Sin padre · registro raíz' : 'Seleccionar registro padre…'}</option>{parents.map((parent) => <option key={parent.id} value={parent.id}>{parent.name} · {parent.code} ({parent.type.name})</option>)}</SearchableSelect><small>Escribí para buscar entre los registros compatibles.</small></div></div><div className="form-grid">{canChangeStatus && <label className="field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value as AssetInformationStatus)} disabled={!canEdit && !canChangeStatus}>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Calidad y validación del registro.</small></label>}{editing && canChangeOperationalStatus && <label className="field"><span>Estado operativo</span><SearchableSelect value={operationalStatus} onChange={(event) => setOperationalStatus(event.target.value as AssetOperationalStatus)}>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect><small>Situación física u operativa del elemento.</small></label>}</div></div>
{!compactStructuralSummary && editing && id && asset && selectedType && canReadHistory
&& <AssetContextHistoryPanel
asset={asset}
type={selectedType}
canManage={canManageContext}
onChanged={handleContextChanged}
/>}
{selectedType?.operationalRole === 'GENERIC' && canReadRelations && <div className="form-section"><div><h2>{editing ? 'Área física' : 'Área y operadora al alta'}</h2><p className="section-copy">{editing ? 'El Inventario pertenece físicamente al Área. La Operadora vigente se administra en las relaciones temporales del Área y no se reescribe dentro del Inventario.' : 'Al crear el registro, la Operadora activa del Área queda guardada únicamente como snapshot histórico de alta.'}</p></div><div className="form-grid"><label className="field"><span>Área</span><SearchableSelect value={operationalAreaId} onChange={(event) => { setOperationalAreaId(event.target.value); if (!editing) setOperatorCompanyId(''); }} disabled={!canEdit || !canDirectContextEdit}><option value="">Sin asignación operativa</option>{operationalAreas.map((area) => <option key={area.id} value={area.id}>{area.name}</option>)}</SearchableSelect>{editing && <small>La Operadora se resuelve por la relación ÁreaEmpresa vigente.</small>}</label>{!editing && <label className="field"><span>Operadora {operationalAreaId && <em>obligatoria</em>}</span><SearchableSelect value={operatorCompanyId} onChange={(event) => setOperatorCompanyId(event.target.value)} disabled={!canEdit || !operationalAreaId} required={Boolean(operationalAreaId)}><option value="">{operationalAreaId ? 'Seleccionar operadora…' : 'Primero seleccioná un área'}</option>{operationalCompanies.map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}</SearchableSelect></label>}</div>{!editing && operationalAreaId && operatorCompanyId && <div className="temporal-notice"><Icon name="check" /><p><strong>Contexto de alta confirmado.</strong> La Operadora seleccionada se conservará como referencia histórica; futuros cambios se harán en la relación temporal del Área.</p></div>}{editing && asset?.operatorCompany && <div className="temporal-notice"><Icon name="layers" /><p><strong>Snapshot histórico de alta:</strong> {asset.operatorCompany.name}. No se modifica desde este registro.</p></div>}</div>}
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC'
&& <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
<div className="form-section"><div><h2>Datos técnicos</h2><p className="section-copy">Campos definidos para el tipo seleccionado.</p></div>{definitions.length === 0 ? <div className="inline-empty">Este tipo no requiere datos técnicos adicionales.</div> : <div className="dynamic-attributes">{definitions.map((definition) => { const value = attributeValues[definition.id]; const label = <span>{definition.name}{definition.unit ? ` (${definition.unit})` : ''}{definition.isRequired ? <em>obligatorio</em> : <em>opcional</em>}</span>; if (definition.dataType === 'BOOLEAN') return <label className="check-row attribute-check" key={definition.id}><input type="checkbox" checked={Boolean(value)} onChange={(event) => setAttribute(definition.id, event.target.checked)} disabled={!canEdit} /><span><strong>{definition.name}</strong><small>{definition.code}</small></span></label>; if (definition.dataType === 'SELECT') return <label className="field" key={definition.id}>{label}<SearchableSelect value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired}><option value="">Seleccionar</option>{definition.options?.map((option) => <option key={option} value={option}>{option}</option>)}</SearchableSelect></label>; const inputType = definition.dataType === 'NUMBER' ? 'number' : definition.dataType === 'DATE' ? 'date' : definition.dataType === 'DATETIME' ? 'datetime-local' : 'text'; return <label className="field" key={definition.id}>{label}<input type={inputType} value={String(value ?? '')} onChange={(event) => setAttribute(definition.id, event.target.value)} disabled={!canEdit} required={definition.isRequired} step={definition.dataType === 'NUMBER' ? 'any' : undefined} maxLength={definition.dataType === 'TEXT' ? 4000 : undefined} /></label>; })}</div>}</div>
{canSave && <div className="form-actions"><Link className="button secondary" to="/inventarios">Cancelar</Link><button className="button primary" disabled={saving}><Icon name="check" />{saving ? 'Guardando…' : editing && !canEdit ? 'Actualizar estado' : editing ? 'Guardar cambios' : 'Crear registro'}</button></div>}
</form>
{editing && id && asset && selectedType && canReadHistory && <AssetContextHistoryPanel asset={asset} type={selectedType} canManage={canManageContext} onChanged={(saved) => { setAsset(saved); setParentId(saved.parent?.id ?? ''); setOperationalAreaId(saved.operationalArea?.id ?? ''); setOperatorCompanyId(saved.operatorCompany?.id ?? ''); setLineage([]); getAssetLineage(saved.id).then(setLineage).catch(() => undefined); setHistoryRefreshKey((current) => current + 1); }} />}
{editing && id && asset && canReadRelations && selectedType && selectedType.operationalRole !== 'GENERIC' && <AssetOperationalRelationsPanel assetId={id} role={selectedType.operationalRole} canManage={canManageRelations} />}
</>}
{editing && id && asset && tab === 'findings' && canReadFindingCatalog
&& <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
{editing && id && asset && tab === 'dossier' && canReadDossier
&& <AssetDossierPanel assetId={id} />}
{editing && id && asset && tab === 'location'
&& <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}>
<AssetGeometryEditor
assetId={id}
assetName={asset.name}
canEdit={canEditGeometry}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>
</Suspense>}
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">
{canReadRegistry && selectedType && <AssetRegistryPanel
assetId={id}
assetName={asset.name}
role={selectedType.operationalRole}
canManage={canManageRegistry}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>}
{canReadProvenance && <AssetProvenancePanel
assetId={id}
canManage={canManageProvenance}
canVerify={canVerifyProvenance}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>}
</div>}
{editing && id && asset && tab === 'files' && canReadMedia
&& <AssetMediaPanel
assetId={id}
assetName={asset.name}
canManage={canManageMedia}
onChanged={() => setHistoryRefreshKey((current) => current + 1)}
/>}
{editing && id && tab === 'history' && canReadHistory && <div className="asset-tab-stack">
{compactStructuralSummary && asset && selectedType && <AssetContextHistoryPanel
asset={asset}
type={selectedType}
canManage={canManageContext}
onChanged={handleContextChanged}
/>}
<AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />
</div>}
{editing && id && asset && tab === 'findings' && canReadFindingCatalog && <AssetFindingCatalogPanel assetId={id} canManage={canManageFindingCatalog} />}
{editing && id && asset && tab === 'dossier' && canReadDossier && <AssetDossierPanel assetId={id} />}
{editing && id && asset && tab === 'location' && <Suspense fallback={<div className="panel"><LoadingBlock label="Cargando ubicación…" /></div>}><AssetGeometryEditor assetId={id} assetName={asset.name} canEdit={canEditGeometry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} /></Suspense>}
{editing && id && asset && tab === 'registry' && <div className="asset-tab-stack">{canReadRegistry && selectedType && <AssetRegistryPanel assetId={id} assetName={asset.name} role={selectedType.operationalRole} canManage={canManageRegistry} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}{canReadProvenance && <AssetProvenancePanel assetId={id} canManage={canManageProvenance} canVerify={canVerifyProvenance} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}</div>}
{editing && id && asset && tab === 'files' && canReadMedia && <AssetMediaPanel assetId={id} assetName={asset.name} canManage={canManageMedia} onChanged={() => setHistoryRefreshKey((current) => current + 1)} />}
{editing && id && tab === 'history' && canReadHistory && <AssetHistoryPanel assetId={id} refreshKey={historyRefreshKey} />}
</section>;
}
+8 -51
View File
@@ -33,40 +33,6 @@ const quickViews = [
{ key: 'out', label: 'Fuera de servicio' },
] as const;
const structuredTypeCopy: Record<string, { title: string; description: string }> = {
departamento: {
title: 'Departamentos',
description: 'Raíz territorial del Inventario. Cada Departamento contiene sus Áreas.',
},
area: {
title: 'Áreas',
description: 'Áreas dentro de un Departamento, con sus Empresas operadoras vinculadas.',
},
yacimiento: {
title: 'Yacimientos',
description: 'Yacimientos pertenecientes a un Área y utilizados para organizar sus Instalaciones.',
},
instalacion: {
title: 'Instalaciones',
description: 'Instalaciones dentro de un Yacimiento, con clasificación técnica y contexto operativo.',
},
subinstalacion: {
title: 'Subinstalaciones',
description: 'Subinstalaciones dependientes de una Instalación, último nivel de la estructura.',
},
};
function structuredPresentation(type: AssetType | undefined) {
if (!type) return null;
if (type.operationalRole === 'COMPANY') {
return {
title: 'Empresas',
description: 'Maestro independiente de Empresas operadoras. Se vinculan a una o más Áreas.',
};
}
return structuredTypeCopy[type.code.toLowerCase()] ?? null;
}
export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext();
@@ -92,8 +58,6 @@ export function AssetsPage() {
const needsValidation = quick === 'validation' ? true : undefined;
const hasGeometry = quick === 'location' ? false : undefined;
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
const selectedType = types.find((type) => type.id === typeId);
const selectedPresentation = structuredPresentation(selectedType);
useEffect(() => {
listAssetTypes().then((data) => setTypes(data.filter((type) =>
@@ -138,22 +102,15 @@ export function AssetsPage() {
value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next);
};
const advancedActive = Boolean(typeId || status || rawOperationalStatus);
const sectionTitle = view === 'list' && selectedPresentation ? selectedPresentation.title : 'Inventarios';
const sectionDescription = view === 'list' && selectedPresentation
? selectedPresentation.description
: 'Empresas y estructura territorial: Departamento → Área → Yacimiento → Instalación → Subinstalación.';
return <section>
<div className="page-heading asset-center-heading">
<div>
<span className="eyebrow">{selectedPresentation && view === 'list' ? 'INVENTARIOS · SECCIÓN' : 'INVENTARIOS'}</span>
<h1>{sectionTitle}</h1>
<p>{sectionDescription}</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{selectedPresentation && view === 'list' && <Link className="button secondary" to="/inventarios"><Icon name="layers" />Ver estructura</Link>}
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
<span className="eyebrow">INVENTARIOS</span>
<h1>Inventarios</h1>
<p>Todos los registros: Departamento Área Yacimiento Instalación Subinstalación, más el maestro independiente de Empresas.</p>
</div>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div>
<AssetCenterTabs active={view === 'hierarchy' ? 'navigate' : 'list'} />
@@ -169,7 +126,7 @@ export function AssetsPage() {
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div>
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Sección</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todas las secciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.operationalRole === 'COMPANY' ? 'Empresas' : structuredTypeCopy[type.code.toLowerCase()]?.title ?? type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos los niveles</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -178,14 +135,14 @@ export function AssetsPage() {
{error && <Alert>{error}</Alert>}
{view === 'hierarchy'
? <AssetHierarchyView filters={filters} types={types} />
? <AssetHierarchyView filters={filters} />
: loading
? <LoadingBlock label="Cargando Inventarios…" />
: assets.length === 0
? <EmptyState title="Todavía no hay registros" text={selectedPresentation ? `No hay registros cargados en ${selectedPresentation.title}.` : 'La base está lista para comenzar la carga manual desde Departamento.'} />
? <EmptyState title="Todavía no hay registros" text="La base está lista para comenzar la carga manual desde Departamento." />
: <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Sección</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Nivel</th><th>Ubicación / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td>
@@ -1,305 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import { Alert, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import {
createInventoryFamily,
createInventoryFamilyAttribute,
getInventoryFamilyAttributes,
listInventoryFamiliesAdmin,
updateInventoryFamily,
updateInventoryFamilyAttribute,
} from '../lib/inventoryStructureApi';
import type {
InventoryFamily,
InventoryFamilyAttribute,
InventoryFamilyAttributeDataType,
} from '../lib/inventoryStructureApi';
type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION';
const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' },
{ value: 'NUMBER', label: 'Número' },
{ value: 'DATE', label: 'Fecha' },
{ value: 'BOOLEAN', label: 'Sí / No' },
];
function fieldTypeLabel(value: InventoryFamilyAttributeDataType) {
return FIELD_TYPES.find((item) => item.value === value)?.label
?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value);
}
function fieldCode(name: string) {
return name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 100) || `campo_${Date.now()}`;
}
export function AuthoritativeInventoryConfigPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage');
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [level, setLevel] = useState<FamilyLevel>('INSTALLATION');
const [selectedFamilyId, setSelectedFamilyId] = useState('');
const [attributes, setAttributes] = useState<InventoryFamilyAttribute[]>([]);
const [newTypeName, setNewTypeName] = useState('');
const [newTypeParents, setNewTypeParents] = useState<string[]>([]);
const [newFieldName, setNewFieldName] = useState('');
const [newFieldType, setNewFieldType] = useState<InventoryFamilyAttributeDataType>('TEXT');
const [newFieldRequired, setNewFieldRequired] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const refreshFamilies = async () => {
const loaded = await listInventoryFamiliesAdmin();
setFamilies(loaded);
return loaded;
};
useEffect(() => {
refreshFamilies()
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
const installationFamilies = useMemo(
() => families.filter((family) => family.level === 'INSTALLATION'),
[families],
);
const visibleFamilies = useMemo(
() => families.filter((family) => family.level === level),
[families, level],
);
const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null;
useEffect(() => {
const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null;
setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? '');
}, [level, visibleFamilies]);
useEffect(() => {
if (!selectedFamilyId) {
setAttributes([]);
return;
}
getInventoryFamilyAttributes(selectedFamilyId)
.then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))))
.catch((requestError) => setError(errorMessage(requestError)));
}, [selectedFamilyId]);
const run = async (action: () => Promise<void>, message: string) => {
setSaving(true);
setError('');
setSuccess('');
try {
await action();
setSuccess(message);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
setSaving(false);
}
};
const createType = async () => {
if (!newTypeName.trim()) return;
if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) {
setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.');
return;
}
await run(async () => {
const created = await createInventoryFamily({
level,
name: newTypeName.trim(),
parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined,
});
await refreshFamilies();
setSelectedFamilyId(created.id);
setNewTypeName('');
setNewTypeParents([]);
}, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
};
const toggleParent = (parentId: string) => {
setNewTypeParents((current) => current.includes(parentId)
? current.filter((id) => id !== parentId)
: [...current, parentId]);
};
const toggleSelectedParent = async (parentId: string) => {
if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return;
const next = selectedFamily.parentFamilyIds.includes(parentId)
? selectedFamily.parentFamilyIds.filter((id) => id !== parentId)
: [...selectedFamily.parentFamilyIds, parentId];
if (next.length === 0) {
setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.');
return;
}
await run(async () => {
await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next });
await refreshFamilies();
}, 'Tipos permitidos actualizados.');
};
const createField = async () => {
if (!selectedFamily || !newFieldName.trim()) return;
await run(async () => {
await createInventoryFamilyAttribute(selectedFamily.id, {
code: fieldCode(newFieldName),
name: newFieldName.trim(),
dataType: newFieldType,
isRequired: newFieldRequired,
sortOrder: attributes.length,
});
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
setNewFieldName('');
setNewFieldType('TEXT');
setNewFieldRequired(false);
}, 'Campo agregado.');
};
const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => {
if (!selectedFamily) return;
await run(async () => {
await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] });
const loaded = await getInventoryFamilyAttributes(selectedFamily.id);
setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)));
}, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.');
};
const toggleFamilyActive = async () => {
if (!selectedFamily) return;
await run(async () => {
await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false });
await refreshFamilies();
}, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.');
};
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section className="inventory-config-page">
<div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN · INVENTARIOS</span>
<h1>Tipos y campos</h1>
<p>Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.</p>
</div>
</div>
{error && <Alert>{error}</Alert>}
{success && <Alert type="success">{success}</Alert>}
<article className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading">
<div>
<span className="eyebrow">ESTRUCTURA FIJA</span>
<h2>Departamento Área Yacimiento Instalación Subinstalación</h2>
<p className="section-copy">Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.</p>
</div>
</div>
<div className="temporal-notice">
<Icon name="map" />
<p><strong>Campos básicos del sistema:</strong> nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.</p>
</div>
</article>
<div className="quick-view-row" style={{ marginBottom: 18 }}>
<button type="button" className={level === 'INSTALLATION' ? 'active' : ''} onClick={() => setLevel('INSTALLATION')}>Tipos de Instalación</button>
<button type="button" className={level === 'SUBINSTALLATION' ? 'active' : ''} onClick={() => setLevel('SUBINSTALLATION')}>Tipos de Subinstalación</button>
</div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading">
<div>
<span className="eyebrow">TIPOS</span>
<h2>{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}</h2>
<p className="section-copy">Seleccioná un tipo para administrar sus campos.</p>
</div>
</div>
<div className="attribute-list" style={{ maxHeight: 520, overflow: 'auto' }}>
{visibleFamilies.length === 0 && <p className="muted">Todavía no hay tipos configurados.</p>}
{visibleFamilies.map((family) => <button
type="button"
key={family.id}
onClick={() => setSelectedFamilyId(family.id)}
className="attribute-card"
style={{ width: '100%', textAlign: 'left', borderColor: selectedFamilyId === family.id ? 'var(--primary)' : undefined, opacity: family.isActive === false ? .55 : 1 }}
>
<span className="asset-symbol"><Icon name="layers" size={16} /></span>
<span><strong>{family.name}</strong><small>{family.isActive === false ? 'Oculto' : `${family.technicalAttributeCount ?? 0} campos`}</small></span>
<Icon name="chevron" size={16} />
</button>)}
</div>
{canManage && <div style={{ marginTop: 18, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Nuevo tipo</h3>
<label className="field"><span>Nombre</span><input value={newTypeName} onChange={(event) => setNewTypeName(event.target.value)} placeholder={level === 'INSTALLATION' ? 'Ej. Planta de tratamiento' : 'Ej. Bomba centrífuga'} /></label>
{level === 'SUBINSTALLATION' && <div className="field"><span>Puede estar dentro de</span><div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}><input type="checkbox" checked={newTypeParents.includes(family.id)} onChange={() => toggleParent(family.id)} />{family.name}</label>)}
</div></div>}
<button type="button" className="button primary" disabled={saving || !newTypeName.trim()} onClick={() => void createType()}><Icon name="plus" />Crear tipo</button>
</div>}
</article>
<article className="panel">
{!selectedFamily ? <div className="inline-empty">Seleccioná un tipo para ver sus campos.</div> : <>
<div className="panel-heading">
<div>
<span className="eyebrow">{selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'}</span>
<h2>{selectedFamily.name}</h2>
<p className="section-copy">Campos sencillos que se muestran al cargar este tipo.</p>
</div>
{canManage && <button type="button" className="button secondary" onClick={() => void toggleFamilyActive()}>{selectedFamily.isActive === false ? 'Activar tipo' : 'Ocultar tipo'}</button>}
</div>
{selectedFamily.level === 'SUBINSTALLATION' && <div style={{ marginBottom: 22 }}>
<strong>Puede estar dentro de:</strong>
<div style={{ display: 'grid', gap: 8, marginTop: 10 }}>
{installationFamilies.filter((family) => family.isActive !== false).map((family) => <label key={family.id} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" disabled={!canManage || saving} checked={selectedFamily.parentFamilyIds.includes(family.id)} onChange={() => void toggleSelectedParent(family.id)} />
{family.name}
</label>)}
</div>
</div>}
<div className="attribute-list">
{attributes.length === 0 && <p className="muted">Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.</p>}
{attributes.map((attribute) => <div className="attribute-card" key={attribute.id} style={{ opacity: attribute.isActive ? 1 : .5 }}>
<span className="attribute-order">{attribute.sortOrder + 1}</span>
<span>
<strong>{attribute.name}</strong>
<small>{fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'}</small>
</span>
{canManage && <span className="attribute-flags" style={{ display: 'flex', gap: 6 }}>
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isRequired')}>{attribute.isRequired ? 'Hacer opcional' : 'Hacer obligatorio'}</button>
<button type="button" className="button secondary" disabled={saving} onClick={() => void toggleField(attribute, 'isActive')}>{attribute.isActive ? 'Ocultar' : 'Mostrar'}</button>
</span>}
</div>)}
</div>
{canManage && selectedFamily.isActive !== false && <div style={{ marginTop: 22, borderTop: '1px solid var(--border)', paddingTop: 18 }}>
<h3 style={{ marginTop: 0 }}>+ Agregar campo</h3>
<div className="form-grid">
<label className="field"><span>Nombre</span><input value={newFieldName} onChange={(event) => setNewFieldName(event.target.value)} placeholder="Ej. Capacidad" /></label>
<label className="field"><span>Tipo</span><select value={newFieldType} onChange={(event) => setNewFieldType(event.target.value as InventoryFamilyAttributeDataType)}>{FIELD_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}><input type="checkbox" checked={newFieldRequired} onChange={(event) => setNewFieldRequired(event.target.checked)} />Obligatorio</label>
<button type="button" className="button primary" disabled={saving || !newFieldName.trim()} onClick={() => void createField()}><Icon name="plus" />Agregar campo</button>
<p className="muted" style={{ marginBottom: 0, marginTop: 12 }}>Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.</p>
</div>}
</>}
</article>
</div>
</section>;
}

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