Compare commits

..
Author SHA1 Message Date
admin 9f582be25e chore(android): publish F6 APK artifact identity 2026-09-09 11:12:21 -03:00
admin 712cb167d3 fix(f6): separate technical and finding permissions 2026-09-09 11:09:29 -03:00
admin 3ea6ea772e feat(f6): show exact classification findings in asset dossier 2026-09-09 11:06:14 -03:00
admin 4ae339cc5c test(f6): advance Android installable cut contract 2026-09-09 11:04:38 -03:00
admin 560734c0b8 test(f6): advance health metadata contract 2026-09-09 11:04:21 -03:00
admin a3f642d13f chore(android): bump F6 debug version 2026-09-09 10:58:20 -03:00
admin 17fb0d2d15 chore(f6): bump WEB package version 2026-09-09 10:58:01 -03:00
admin 0e64007996 chore(f6): bump API package version 2026-09-09 10:57:47 -03:00
admin 709f43c1e9 chore(f6): publish API phase metadata 2026-09-09 10:57:30 -03:00
admin ab5ce42d47 test(f6): lock definitive inventory and mobile contracts 2026-09-09 10:48:50 -03:00
admin 3f71323555 test(f6): retire one-to-one family expectation 2026-09-09 10:47:53 -03:00
admin f3b72ecfc3 feat(android): browse field inventory as strict hierarchy 2026-09-09 10:43:53 -03:00
admin 5c5e5de26b fix(f6): gate mobile findings by area ancestry and temporal operator 2026-09-09 10:42:03 -03:00
admin 792b115449 fix(f6): resolve field findings by technical classification 2026-09-09 10:41:17 -03:00
admin 46cb5e0eb9 fix(f6): allow atomic field classification handoff 2026-09-09 10:40:57 -03:00
admin 8708c9c93f feat(f6): agregar datos técnicos por clasificación a la ficha 2026-09-09 09:34:55 -03:00
admin 2186810be3 feat(f6): agregar API de valores técnicos por objeto 2026-09-09 09:34:25 -03:00
admin 052384bfe0 feat(f6): registrar servicio de valores técnicos 2026-09-09 09:33:56 -03:00
admin a758c9528a feat(f6): exponer valores técnicos por objeto 2026-09-09 09:33:39 -03:00
admin 5023685043 feat(f6): persistir y validar valores técnicos del Inventario 2026-09-09 09:33:28 -03:00
admin d05ea8405a feat(f6): agregar DTO de valores técnicos por objeto 2026-09-09 09:33:02 -03:00
admin dc0e1d9762 feat(f6): filtrar subinstalaciones por compatibilidad múltiple 2026-09-09 09:27:14 -03:00
admin 3f662b86ea feat(f6): mostrar compatibilidades múltiples en catálogo 2026-09-09 09:26:32 -03:00
admin 636be088e8 feat(f6): consolidar configuración técnica por clasificación 2026-09-09 09:25:57 -03:00
admin 96738bfdcb feat(f6): consumir compatibilidades y campos técnicos de familias 2026-09-09 09:24:06 -03:00
admin 0b074923be feat(f6): aplicar compatibilidad múltiple al alta de Inventarios 2026-09-09 09:23:32 -03:00
admin e181dea4cb feat(f6): exponer campos técnicos por clasificación 2026-09-09 09:22:31 -03:00
admin 76163a5ebb feat(f6): administrar compatibilidades y campos técnicos por familia 2026-09-09 09:22:13 -03:00
admin 74516ede1e feat(f6): agregar DTOs de campos técnicos por clasificación 2026-09-09 09:20:59 -03:00
admin 9f09aca30f feat(f6): aceptar compatibilidades múltiples por clasificación 2026-09-09 09:18:33 -03:00
admin 169aab5cd3 feat(f6): solidificar compatibilidades y campos técnicos por clasificación 2026-09-09 09:18:16 -03:00
160 changed files with 1085 additions and 14178 deletions
+19 -94
View File
@@ -1,36 +1,35 @@
name: Android CI / RC name: Android APK
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile. # F6: genera una APK debug verificable contra la API productiva F6.
on: on:
push: push:
branches: branches:
- 'main' - 'main'
- 'release/f6-1*' - 'release/f5-android-test'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
pull_request: pull_request:
branches:
- 'main'
paths: paths:
- 'android-app/**' - 'android-app/**'
- 'api-v3/src/**' - 'api-v3/src/auth/**'
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/inspection-acts/**'
- '.github/workflows/android.yml' - '.github/workflows/android.yml'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
android: build-debug-apk:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 35 timeout-minutes: 30
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -52,92 +51,18 @@ jobs:
with: with:
gradle-version: '8.13' gradle-version: '8.13'
- name: Validate mobile security and identity contract - name: Assemble debug
run: |
set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Require real unit-test results
run: |
set -Eeuo pipefail
test_dir="android-app/app/build/test-results/testDebugUnitTest"
test -d "$test_dir"
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
test "$total" -gt 0
echo "Android unit tests discovered: $total"
- name: Assemble debug APK
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:assembleDebug run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant - name: Unit tests
working-directory: android-app working-directory: android-app
run: gradle --no-daemon :app:assembleRelease run: gradle --no-daemon :app:testDebugUnitTest
- name: Package RC artifact and checksum - name: Upload APK
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "phase=F6.1"
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug name: DH-Inspeccion-F6-0.14.0-debug
path: android-app/dist/* path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error if-no-files-found: error
retention-days: 30 retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14
+23 -134
View File
@@ -69,7 +69,7 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose - name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations and real API startup on clean PostGIS - name: Rehearse migrations on clean PostGIS
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
cleanup() { cleanup() {
@@ -116,10 +116,9 @@ jobs:
docker compose --env-file .env.example --profile tools run --rm migrate docker compose --env-file .env.example --profile tools run --rm migrate
# The completed chain must end in the exact authoritative SQL model: # F5.1 intentionally ends with zero operational/domain instances. The
# 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de # technical family and finding masters remain, but territory preload,
# concesión, 14/109 technical families, 181 Hallazgos and their exact # imports, applicability links and old audits are deliberately gone.
# 48 + 880 contextual mappings.
docker compose --env-file .env.example exec -T db \ docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$ DO $$
@@ -128,23 +127,12 @@ jobs:
domain_assets integer; domain_assets integer;
audits integer; audits integer;
applicability integer; applicability integer;
legacy_territory_sources integer; territory_sources integer;
legacy_presentation_sources integer;
authoritative_sources integer;
source_installations integer; source_installations integer;
source_subinstallations integer; source_subinstallations integer;
source_findings integer; source_findings integer;
concession_types integer;
departments integer;
areas integer;
yacimientos integer;
companies integer;
invalid_yacimientos integer;
department_types integer; department_types integer;
area_department_rules integer; area_department_rules integer;
yacimiento_area_rules integer;
installation_yacimiento_rules integer;
subinstallation_installation_rules integer;
BEGIN BEGIN
SELECT COUNT(*) INTO f5_migrations SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations FROM typeorm_migrations
@@ -161,86 +149,42 @@ jobs:
END IF; END IF;
SELECT COUNT(*) INTO domain_assets FROM assets; SELECT COUNT(*) INTO domain_assets FROM assets;
IF domain_assets <> 314 THEN IF domain_assets <> 0 THEN
RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets; RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets;
END IF; END IF;
SELECT COUNT(*) INTO audits FROM audit_events; SELECT COUNT(*) INTO audits FROM audit_events;
IF audits <> 0 THEN IF audits <> 0 THEN
RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits; RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits;
END IF; END IF;
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families; SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
IF applicability <> 928 THEN IF applicability <> 0 THEN
RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability; RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability;
END IF; END IF;
SELECT COUNT(*) INTO legacy_territory_sources SELECT COUNT(*) INTO territory_sources
FROM source_documents FROM source_documents
WHERE document_number='DH-F5-TERRITORY'; WHERE document_number='DH-F5-TERRITORY';
IF legacy_territory_sources <> 0 THEN IF territory_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources; RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
END IF;
SELECT COUNT(*) INTO legacy_presentation_sources
FROM source_documents
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
IF legacy_presentation_sources <> 0 THEN
RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources;
END IF;
SELECT COUNT(*) INTO authoritative_sources
FROM source_documents
WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911');
IF authoritative_sources <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_sources;
END IF; END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'), SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION') COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations INTO source_installations,source_subinstallations
FROM inventory_families 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 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 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
END IF; END IF;
SELECT COUNT(*) INTO source_findings SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='authmodel' AND item.is_active=true; WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 181 THEN IF source_findings <> 177 THEN
RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings; RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings;
END IF;
SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true;
IF concession_types <> 2 THEN
RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types;
END IF;
SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'),
COUNT(*) FILTER (WHERE lower(type.code)='area'),
COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'),
COUNT(*) FILTER (WHERE type.operational_role='COMPANY')
INTO departments,areas,yacimientos,companies
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id;
IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN
RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies;
END IF;
SELECT COUNT(*) INTO invalid_yacimientos
FROM assets yacimiento
JOIN asset_types type ON type.id=yacimiento.asset_type_id
JOIN assets area ON area.id=yacimiento.parent_id
WHERE lower(type.code)='yacimiento'
AND (
yacimiento.operational_area_id IS DISTINCT FROM area.id
OR yacimiento.operator_company_id IS NULL
OR yacimiento.concession_type_id IS NULL
);
IF invalid_yacimientos <> 0 THEN
RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos;
END IF; END IF;
SELECT COUNT(*) INTO department_types SELECT COUNT(*) INTO department_types
@@ -258,76 +202,21 @@ jobs:
IF area_department_rules <> 1 THEN IF area_department_rules <> 1 THEN
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules; RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
END IF; 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 $$; END $$;
SQL SQL
# Destructive cuts are intentionally one-way: production rollback is # F5.1 is intentionally one-way: production rollback is the PRE database
# the PRE database backup, not migration:revert. Prove the completed # backup, not migration:revert. Prove instead that the completed chain is
# chain is idempotent and has no pending migration on a second run. # idempotent and has no pending migration on a second run.
rerun_log="$(mktemp)" rerun_log="$(mktemp)"
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log" 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" || { grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
echo "ERROR: authoritative migration chain is not idempotent." >&2 echo "ERROR: F5.1 migration chain is not idempotent." >&2
cat "$rerun_log" >&2 cat "$rerun_log" >&2
exit 1 exit 1
} }
rm -f "$rerun_log" rm -f "$rerun_log"
- name: VPS-equivalent isolated API preflight
# A build-only preflight cannot catch Nest dependency-injection or
# runtime configuration failures. Start the production API image against
# the migrated database and require the public health endpoint to answer.
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
export SMTP_SETTINGS_MASTER_KEY=''
docker compose --env-file .env.example build api
docker compose --env-file .env.example up -d api
api_ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
sleep 2
done
if [ "$api_ready" -ne 1 ]; then
echo 'ERROR: production API image did not become healthy.' >&2
docker compose --env-file .env.example logs --no-color api >&2 || true
exit 1
fi
grep -Fq '"status":"ok"' /tmp/dhv2-health.json || {
echo 'ERROR: /api/v3/health did not report status ok.' >&2
cat /tmp/dhv2-health.json >&2
exit 1
}
- name: Isolated builder test preflight
run: | run: |
set -Eeuo pipefail set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}" image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
@@ -1,26 +0,0 @@
name: Inspection planning smoke
on:
pull_request:
branches: [main]
paths:
- 'api-v3/src/inspection-visits/**'
- 'api-v3/src/database/migrations/**'
- 'api-v3/src/reference-data/**'
- 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx'
- 'web-v2/src/pages/FieldBriefingsPage.tsx'
- 'web-v2/src/layout/AppLayout.tsx'
- 'scripts/ci-inspection-planning-smoke.sh'
- '.github/workflows/inspection-planning-smoke.yml'
permissions:
contents: read
jobs:
create-inspection:
name: F6.1 · real inspection create
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rehearse hierarchy and create a real Inspection
run: bash scripts/ci-inspection-planning-smoke.sh
-48
View File
@@ -1,48 +0,0 @@
name: Production dependency audit
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-production-audit-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
name: API · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
web:
name: WEB · production dependencies
runs-on: ubuntu-latest
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- name: Reject high/critical runtime advisories
run: npm audit --omit=dev --audit-level=high
+5 -26
View File
@@ -2,30 +2,9 @@
Repositorio del sistema DH Inspección V2. Repositorio del sistema DH Inspección V2.
## Componentes ## Estructura
- `api-v3/`: API NestJS/TypeORM y contratos de dominio. - `api-v3/`: API v3.
- `web-v2/`: aplicación WEB React/Vite. - `web-v2/`: aplicación web.
- `android-app/`: aplicación Android para trabajo de campo. - `scripts/`: utilidades operativas y de despliegue.
- `scripts/`: utilidades operativas, preflight y despliegue. - `docs/`: documentación técnica.
- `docs/`: documentación funcional y técnica.
## Documentación vigente
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
- [Contrato de release Android F6.1](android-app/RELEASE.md)
## Contratos que no deben romperse
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
-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.
-97
View File
@@ -1,97 +0,0 @@
# DH Inspección Android · release final de campo 0.19.0
## Candidata vigente
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.0**.
- `versionCode`: **28**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
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;
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.
## 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.
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
## Criterio de distribución
Antes de distribuir una APK productiva:
- todas las barreras de CI del SHA exacto deben estar verdes;
- comprobar certificado/huella de firma contra la versión histórica 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;
- registrar el SHA Git y SHA-256 de la APK distribuida.
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion" applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 29 versionCode = 21
versionName = "0.20.0" versionName = "0.14.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -3,14 +3,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_mendoza_launcher_exact"
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.DHInspeccion" android:theme="@style/Theme.DHInspeccion"
@@ -115,7 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openVisit(id: String) = launchBusy { fun openVisit(id: String) = launchBusy {
visit = repository.visit(id) visit = repository.visit(id)
inventory = emptyList() inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.operationalArea?.id
fieldTypes = emptyList() fieldTypes = emptyList()
selectedFieldAsset = null selectedFieldAsset = null
clearFindingState() clearFindingState()
@@ -137,7 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val id = visit?.id ?: return val id = visit?.id ?: return
launchBusy { launchBusy {
visit = repository.startVisit(id) visit = repository.startVisit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id inventoryParentId = visit?.operationalArea?.id
notice = "Inspección iniciada." notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true) loadActsInternal(id, selectDraft = true)
loadVisitsInternal() loadVisitsInternal()
@@ -168,14 +168,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Elegí si el Acta es urgente o no urgente." error = "Elegí si el Acta es urgente o no urgente."
return return
} }
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) { 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 return
} }
launchBusy { launchBusy {
val created = actsRepository.create( val created = actsRepository.create(
currentVisit.id, currentVisit.id,
asset?.id, asset.id,
currentVisit.code, currentVisit.code,
urgency, urgency,
) )
@@ -184,25 +188,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente" 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." 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) loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
} }
} }
} }
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
fun searchInventory(search: String, parentId: String? = null) { fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { 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) { fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return val currentVisit = visit ?: return
launchBusy { launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id val effectiveParentId = parentId ?: currentVisit.operationalArea?.id
inventoryParentId = effectiveParentId inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).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) { 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 inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
} }
@@ -241,7 +244,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double, latitude: Double,
longitude: Double, longitude: Double,
accuracyM: Double?, accuracyM: Double?,
description: String? = null,
) { ) {
val visitId = visit?.id ?: return val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") { if (visit?.status != "IN_PROGRESS") {
@@ -259,7 +261,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
familyId = familyId, familyId = familyId,
name = name.trim(), name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes, attributes = attributes,
deviceLatitude = latitude, deviceLatitude = latitude,
deviceLongitude = longitude, deviceLongitude = longitude,
@@ -342,7 +343,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct() val draft = selectedDraftAct()
if (draft == null) { 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 return
} }
launchBusy { launchBusy {
@@ -364,7 +365,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id val actId = selectedDraftAct()?.id
if (actId == null) { if (actId == null) {
error = "No hay un Acta en elaboración seleccionada." error = "No hay un Acta en borrador seleccionada."
return return
} }
if (description.isBlank()) { if (description.isBlank()) {
@@ -473,7 +474,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
absenceReason = reason.trim(), 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 { launchBusy {
actClosure = actsRepository.lock(actId) actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(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() { 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( fun signSelectedActAsInspector(
@@ -548,7 +549,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshSelectedActInternal(actId) refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false) loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState() 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 { launchBusy {
visit = actsRepository.closeVisit(visitId) visit = actsRepository.closeVisit(visitId)
loadVisitsInternal() 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 code: String,
val objective: String? = null, val objective: String? = null,
val status: String, val status: String,
val scopeAsset: AssetSummary? = null,
val operationalArea: AssetSummary? = null, val operationalArea: AssetSummary? = null,
val operatorCompany: AssetSummary? = null, val operatorCompany: AssetSummary? = null,
val leadInspector: PersonSummary? = null, val leadInspector: PersonSummary? = null,
@@ -207,7 +207,7 @@ data class MobileCloseVisitRequest(
) )
private interface MobileActsApi { private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts/mobile") @GET("inspection-visits/{visitId}/acts")
suspend fun listActs( suspend fun listActs(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Path("visitId") visitId: String, @Path("visitId") visitId: String,
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
suspend fun create( suspend fun create(
visitId: String, visitId: String,
assetId: String? = null, assetId: String,
visitCode: String, visitCode: String,
urgency: String = "NON_URGENT", urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session -> ): MobileActDetail = authorized { session ->
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
urgency = urgency, urgency = urgency,
title = "Acta de inspección $visitCode", 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.", 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,103 +0,0 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
data class MobilePlanningAsset(
val id: String,
val code: String,
val name: String,
)
data class MobilePlanningAssetResponse(
val data: List<MobilePlanningAsset> = emptyList(),
)
data class OpenMobileInspectionRequest(
val operationalAreaId: String,
val operatorCompanyId: String,
)
private interface MobileInspectionOpenApi {
@GET("inspection-visits/mobile/planning-context/areas")
suspend fun areas(
@Header("Authorization") authorization: String,
): MobilePlanningAssetResponse
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
suspend fun operators(
@Header("Authorization") authorization: String,
@Path("areaId") areaId: String,
): MobilePlanningAssetResponse
@POST("inspection-visits/mobile/open")
suspend fun open(
@Header("Authorization") authorization: String,
@Body request: OpenMobileInspectionRequest,
): VisitDetail
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
class MobileInspectionOpenRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileInspectionOpenApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileInspectionOpenApi::class.java)
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
api.areas("Bearer ${session.accessToken}")
}
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
api.operators("Bearer ${session.accessToken}", areaId)
}
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
api.open(
"Bearer ${session.accessToken}",
OpenMobileInspectionRequest(areaId, companyId),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -547,17 +547,13 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
try { client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) .addOnSuccessListener { location ->
.addOnSuccessListener { location -> if (!continuation.isActive) return@addOnSuccessListener
if (!continuation.isActive) return@addOnSuccessListener if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) }
} .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -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 = true
}, },
) )
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
inventoryMode = false
actsMode = true
})
else -> F3VisitOverview( else -> F3VisitOverview(
model = model, model = model,
onInventory = { inventoryMode = true }, onInventory = { inventoryMode = true },
@@ -122,9 +119,6 @@ private fun F3VisitOverview(
} }
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}") 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 { visit.plannedStartAt?.let {
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall) Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
} }
@@ -144,10 +138,10 @@ private fun F3VisitOverview(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold) Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Al iniciar, pasás directamente a las Actas de esta inspección.") Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Button( Button(
onClick = { model.startVisit(); onActs() }, onClick = { model.startVisit() },
enabled = !model.busy, enabled = !model.busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") } ) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -156,12 +150,12 @@ private fun F3VisitOverview(
} }
if (visit.status == "IN_PROGRESS") { if (visit.status == "IN_PROGRESS") {
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } Text("Abrir Inventario de campo")
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
} }
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos") 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") { } else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -169,7 +163,7 @@ private fun F3VisitOverview(
} }
} else if (visit.status == "PLANNED") { } else if (visit.status == "PLANNED") {
Text( 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, style = MaterialTheme.typography.bodySmall,
) )
} }
@@ -215,9 +209,7 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
var search by rememberSaveable(visit.id) { mutableStateOf("") } var search by rememberSaveable(visit.id) { mutableStateOf("") }
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) } var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var name by rememberSaveable(visit.id) { mutableStateOf("") } var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") } var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) } var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -331,10 +323,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
OutlinedButton(onClick = onBack) { Text("Acta") } OutlinedButton(onClick = onBack) { Text("Volver") }
Column(horizontalAlignment = Alignment.End) { Column(horizontalAlignment = Alignment.End) {
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall) Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall)
} }
} }
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) } 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)) { Row(
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Modifier.fillMaxWidth().padding(horizontal = 16.dp),
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold) verticalAlignment = Alignment.CenterVertically,
OutlinedTextField( ) {
value = search, OutlinedTextField(
onValueChange = { search = it }, value = search,
label = { Text("Buscar instalación o subinstalación") }, onValueChange = { search = it },
modifier = Modifier.fillMaxWidth(), label = { Text("Buscar por nombre o código") },
singleLine = true, modifier = Modifier.weight(1f),
) singleLine = true,
Row( )
Modifier.fillMaxWidth(), Spacer(Modifier.width(8.dp))
horizontalArrangement = Arrangement.spacedBy(8.dp), Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
) { }
Button(
onClick = { model.searchInventory(search) }, Row(
enabled = !model.busy, Modifier.fillMaxWidth().padding(16.dp),
modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceBetween,
) { Text("Buscar") } verticalAlignment = Alignment.CenterVertically,
OutlinedButton( ) {
onClick = { Column {
showCreate = !showCreate Text("Alta en campo", fontWeight = FontWeight.Bold)
if (showCreate) model.loadFieldTypes(parentId) Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
},
modifier = Modifier.weight(1f),
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
}
} }
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
} }
if (showCreate) { if (showCreate) {
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { Column(
Column( Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .padding(horizontal = 16.dp)
.padding(12.dp) .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()) .weight(1f, fill = false),
.weight(1f, fill = false), verticalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), ) {
) { if (parentId != null) {
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) OutlinedButton(onClick = {
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall) parentId = null
if (parentId != null) { parentLabel = "Área de la inspección"
OutlinedButton(onClick = { resetCreateForm()
parentId = null selectedTypeId = null
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" model.loadFieldTypes(null)
resetCreateForm() }) { Text("Volver al Área") }
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") }
} }
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( Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
LazyColumn( LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp), Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items( items(model.inventory, key = { it.id }) { item ->
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
F3InventoryCard( F3InventoryCard(
item = item, item = item,
onInspect = { model.selectExisting(item) }, onInspect = { model.selectExisting(item) },
@@ -631,10 +611,10 @@ private fun F3CaptureCard(
if (captureRequired) { if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos") Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) { 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") } Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else { } else {
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary) Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
} }
} else { } else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall) Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -651,8 +631,8 @@ private fun F3InventoryCard(
) { ) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty() val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion") val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode == "instalacion" val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = "+ Agregar subinstalación" val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -666,12 +646,12 @@ private fun F3InventoryCard(
} }
if (canHaveFinding) { if (canHaveFinding) {
Text( 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, color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo") Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
} }
} }
if (canHaveChild) { if (canHaveChild) {
@@ -747,20 +727,16 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
try { client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) .addOnSuccessListener { location ->
.addOnSuccessListener { location -> if (!continuation.isActive) return@addOnSuccessListener
if (!continuation.isActive) return@addOnSuccessListener if (location == null) {
if (location == null) { continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) } else {
} else { continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
} }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } }
} catch (error: SecurityException) { .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -1,793 +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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.ArrowBack
import androidx.compose.material.icons.filled.Assignment
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Factory
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WarningAmber
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.IconButton
import androidx.compose.material3.LinearProgressIndicator
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.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.style.TextOverflow
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.PlannedAsset
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.time.Instant
import java.time.LocalDate
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private enum class F8VisitScreen { OVERVIEW, ACTS, FINDING_TARGET, PICK_ASSET, CREATE_ASSET }
private enum class F8Target { YACIMIENTO, INSTALACION, SUBINSTALACION }
private data class F8Geo(val latitude: Double, val longitude: Double, val accuracyM: Double?)
@Composable
fun F8VisitRoot(model: MainViewModel) {
val visit = model.visit ?: return
var screenName by rememberSaveable(visit.id) { mutableStateOf(F8VisitScreen.OVERVIEW.name) }
var targetName by rememberSaveable(visit.id) { mutableStateOf(F8Target.INSTALACION.name) }
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
val screen = runCatching { F8VisitScreen.valueOf(screenName) }.getOrDefault(F8VisitScreen.OVERVIEW)
val target = runCatching { F8Target.valueOf(targetName) }.getOrDefault(F8Target.INSTALACION)
fun goOverview() {
screenName = F8VisitScreen.OVERVIEW.name
parentId = null
parentLabel = ""
model.clearSelectedFieldAsset()
}
when (screen) {
F8VisitScreen.OVERVIEW -> F8VisitOverview(
model = model,
onActs = { screenName = F8VisitScreen.ACTS.name },
onCreateFinding = { screenName = F8VisitScreen.FINDING_TARGET.name },
)
F8VisitScreen.ACTS -> ModernMobileActsScreen(
model = model,
onBack = { screenName = F8VisitScreen.OVERVIEW.name },
onGoInventory = { screenName = F8VisitScreen.FINDING_TARGET.name },
)
F8VisitScreen.FINDING_TARGET -> F8FindingTargetScreen(
model = model,
onBack = { screenName = F8VisitScreen.OVERVIEW.name },
onYacimiento = {
val scope = visit.scopeAsset
if (scope != null) {
model.selectExisting(
FieldInventoryItem(
id = scope.id,
code = scope.code,
name = scope.name,
type = scope,
readyForFinding = true,
),
)
}
},
onInstallation = {
targetName = F8Target.INSTALACION.name
parentId = null
parentLabel = visit.scopeAsset?.name.orEmpty()
screenName = F8VisitScreen.PICK_ASSET.name
},
onSubinstallation = {
targetName = F8Target.SUBINSTALACION.name
parentId = null
parentLabel = ""
screenName = F8VisitScreen.PICK_ASSET.name
},
)
F8VisitScreen.PICK_ASSET -> F8AssetPicker(
model = model,
target = target,
parentId = parentId,
parentLabel = parentLabel,
onBack = {
if (target == F8Target.SUBINSTALACION && parentId != null) {
parentId = null
parentLabel = ""
} else {
screenName = F8VisitScreen.FINDING_TARGET.name
}
},
onParent = { item ->
parentId = item.id
parentLabel = item.name
},
onCreate = {
screenName = F8VisitScreen.CREATE_ASSET.name
},
)
F8VisitScreen.CREATE_ASSET -> F8QuickCreateAsset(
model = model,
target = target,
parentId = parentId,
parentLabel = parentLabel.ifBlank { visit.scopeAsset?.name.orEmpty() },
onBack = { screenName = F8VisitScreen.PICK_ASSET.name },
onFinishedWithoutFinding = { goOverview() },
)
}
}
@Composable
private fun F8VisitOverview(
model: MainViewModel,
onActs: () -> Unit,
onCreateFinding: () -> Unit,
) {
val visit = model.visit ?: return
val draft = model.acts.firstOrNull { it.status == "DRAFT" }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
F8Header("Inspección en campo", visit.code) { model.closeVisitView() }
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(visit.scopeAsset?.name ?: "Yacimiento", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
visit.operationalArea?.name?.takeIf { it.isNotBlank() }?.let {
Text("Área · $it", style = MaterialTheme.typography.bodyMedium)
}
Text(f8VisitStatus(visit.status), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
}
F8Messages(model)
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
F8RouteCard(visit)
if (visit.status == "PLANNED") {
Button(
onClick = { model.startVisit() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (model.busy) "Iniciando…" else "Iniciar inspección")
}
}
if (visit.status == "IN_PROGRESS") {
if (draft == null) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Empezar a documentar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Creá un Acta cuando empieces a registrar lo observado. El número definitivo se asignará al cerrarla.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.createAct("NON_URGENT") },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text("Nueva Acta")
}
OutlinedButton(
onClick = { model.createAct("URGENT") },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Nueva Acta urgente") }
}
}
} else {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Icon(Icons.Filled.Assignment, null, tint = MaterialTheme.colorScheme.primary)
Column {
Text("Acta en elaboración", fontWeight = FontWeight.Bold)
Text("${draft.findingCount} Hallazgo${if (draft.findingCount == 1) "" else "s"}", style = MaterialTheme.typography.bodySmall)
}
}
Button(onClick = onCreateFinding, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(7.dp))
Text("Crear hallazgo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Acta actual") }
}
}
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver Actas") }
}
if (model.acts.isNotEmpty()) {
Text("Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
model.acts.take(4).forEach { act ->
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, tonalElevation = 1.dp) {
Row(Modifier.padding(13.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(if (act.status == "DRAFT") "Acta en elaboración" else act.code, fontWeight = FontWeight.SemiBold)
Text("${act.findingCount} hallazgos · ${f8ActStatus(act.status)}", style = MaterialTheme.typography.bodySmall)
}
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.secondary)
}
}
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { Text("Ver todas las Actas") }
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun F8RouteCard(visit: VisitDetail) {
val planned = visit.planningAssets.filter { it.included }
val checklist = visit.checklist.items.filter { it.assetIncluded && it.asset != null }
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Recorrido sugerido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Usalo como guía. Podés registrar cualquier Hallazgo nuevo aunque no esté planificado.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (planned.isEmpty() && checklist.isEmpty()) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("No hay controles pendientes cargados para este recorrido.", Modifier.padding(12.dp))
}
} else {
planned.take(10).forEach { asset -> F8RouteLine(asset, "Planificado", false) }
checklist.take(12).forEach { item ->
val due = item.referenceOn?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() }
val overdue = due?.isBefore(LocalDate.now()) == true || item.findingStatus?.uppercase() == "OVERDUE"
val label = if (overdue) "Vencido" else "Por vencer"
val asset = item.asset
if (asset != null) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(
if (overdue) Icons.Filled.WarningAmber else Icons.Filled.LocationOn,
contentDescription = null,
tint = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
Column(Modifier.weight(1f)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(label, item.findingCode, item.findingTitle).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = if (overdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@Composable
private fun F8RouteLine(asset: PlannedAsset, label: String, error: Boolean) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
Icon(Icons.Filled.LocationOn, null, tint = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f)) {
Text(asset.name, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(label, asset.typeName?.takeIf { it.isNotBlank() }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun F8FindingTargetScreen(
model: MainViewModel,
onBack: () -> Unit,
onYacimiento: () -> Unit,
onInstallation: () -> Unit,
onSubinstallation: () -> Unit,
) {
val visit = model.visit ?: return
val hasDraft = model.acts.any { it.status == "DRAFT" }
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
F8Header("Crear hallazgo", visit.scopeAsset?.name ?: visit.code, onBack)
F8Messages(model)
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
if (!hasDraft) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Primero necesitás un Acta", fontWeight = FontWeight.Bold)
Text("Volvé y tocá Nueva Acta. Así cada Hallazgo queda asociado correctamente.")
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") }
}
}
return@Column
}
Text("¿Dónde está el hallazgo?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text("Elegí el lugar. Si todavía no existe en el Inventario, lo vas a poder crear en el siguiente paso.", color = MaterialTheme.colorScheme.onSurfaceVariant)
F8TargetCard("En el Yacimiento", visit.scopeAsset?.name ?: "Yacimiento", Icons.Filled.LocationOn, onYacimiento)
F8TargetCard("En una Instalación", "Elegir o crear Instalación", Icons.Filled.Factory, onInstallation)
F8TargetCard("En una Subinstalación", "Elegir Instalación y luego el elemento", Icons.Filled.Assignment, onSubinstallation)
}
}
@Composable
private fun F8TargetCard(title: String, subtitle: String, icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) {
ElevatedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.padding(18.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Icon(icon, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun F8AssetPicker(
model: MainViewModel,
target: F8Target,
parentId: String?,
parentLabel: String,
onBack: () -> Unit,
onParent: (FieldInventoryItem) -> Unit,
onCreate: () -> Unit,
) {
val visit = model.visit ?: return
var search by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") }
val pickingParent = target == F8Target.SUBINSTALACION && parentId == null
val title = when {
pickingParent -> "Elegir Instalación"
target == F8Target.SUBINSTALACION -> "Elegir Subinstalación"
else -> "Elegir Instalación"
}
LaunchedEffect(visit.id, target.name, parentId, search) {
delay(220)
val effectiveParent = if (pickingParent || target == F8Target.INSTALACION) visit.scopeAsset?.id else parentId
model.searchInventory(search.trim(), effectiveParent)
}
val rows = model.inventory.filter { item ->
val code = f8Norm(item.type?.code ?: item.type?.name)
if (pickingParent || target == F8Target.INSTALACION) {
code.contains("instalacion") && !code.contains("subinstalacion")
} else {
code.contains("subinstalacion")
}
}
Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
F8Header(title, if (parentLabel.isBlank()) visit.scopeAsset?.name ?: visit.code else parentLabel, onBack)
F8Messages(model)
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre o código") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
if (!pickingParent) {
OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, null)
Spacer(Modifier.width(6.dp))
Text(if (target == F8Target.SUBINSTALACION) "Crear Subinstalación" else "Crear Instalación")
}
} else if (rows.isEmpty() && !model.busy) {
OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
Text("No existe: crear Instalación")
}
}
if (rows.isEmpty() && !model.busy) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
Text("No encontramos coincidencias. Podés crear el elemento sin salir del Hallazgo.", Modifier.padding(14.dp))
}
}
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(rows, key = { it.id }) { item ->
ElevatedCard(
onClick = { if (pickingParent) onParent(item) else model.selectExisting(item) },
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(Icons.Filled.Factory, null, tint = MaterialTheme.colorScheme.primary)
Column(Modifier.weight(1f)) {
Text(item.name, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
}
}
}
}
}
@Composable
private fun F8QuickCreateAsset(
model: MainViewModel,
target: F8Target,
parentId: String?,
parentLabel: String,
onBack: () -> Unit,
onFinishedWithoutFinding: () -> Unit,
) {
val visit = model.visit ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
var name by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
var selectedFamilyId by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
val attributes = remember { mutableStateMapOf<String, String>() }
var localError by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf<String?>(null) }
var pendingAutoPhoto by rememberSaveable(visit.id, target.name, parentId) { mutableStateOf(false) }
var photoFile by remember { mutableStateOf<File?>(null) }
var photoGeo by remember { mutableStateOf<F8Geo?>(null) }
LaunchedEffect(visit.id, target.name, parentId) {
model.loadFieldTypes(if (target == F8Target.SUBINSTALACION) parentId else null)
}
LaunchedEffect(model.fieldTypes) {
if (model.fieldTypes.none { it.id == selectedTypeId }) {
selectedTypeId = model.fieldTypes.firstOrNull()?.id
selectedFamilyId = null
attributes.clear()
}
}
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
val selectedAsset = model.selectedFieldAsset
val takePhoto = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = photoFile
val geo = photoGeo
if (success && file != null && geo != null) {
runCatching { f8WriteExif(file, geo) }
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
} else if (!success) {
localError = "La foto es necesaria para continuar con el Hallazgo."
}
photoFile = null
photoGeo = null
}
fun beginPhoto() {
scope.launch {
runCatching { f8CurrentGeo(context) }
.onSuccess { geo ->
val (file, uri) = f8NewPhoto(context)
photoFile = file
photoGeo = geo
takePhoto.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo obtener la ubicación." }
}
}
fun createNow() {
val type = selectedType ?: return
scope.launch {
runCatching { f8CurrentGeo(context) }
.onSuccess { geo ->
pendingAutoPhoto = true
model.createFieldAsset(
type = type,
parentId = if (target == F8Target.SUBINSTALACION) parentId else null,
familyId = selectedFamilyId,
name = name,
commonName = null,
description = null,
attributes = f8Attributes(type, attributes),
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
)
}
.onFailure { localError = it.message ?: "No se pudo capturar el GPS." }
}
}
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val camera = result[Manifest.permission.CAMERA] == true || ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f8HasLocation(context)
if (camera && location) {
if (selectedAsset?.capture?.captureRequired == true) beginPhoto() else createNow()
} else {
localError = "Para el alta en campo se necesitan cámara y ubicación."
}
}
fun requestCreate() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) createNow()
else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
}
fun requestPhoto() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED && f8HasLocation(context)) beginPhoto()
else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
}
LaunchedEffect(selectedAsset?.asset?.id, pendingAutoPhoto) {
if (pendingAutoPhoto && selectedAsset?.capture?.captureRequired == true && selectedAsset.capture.creationGpsCaptured && selectedAsset.capture.fieldPhotoCount == 0) {
pendingAutoPhoto = false
requestPhoto()
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(18.dp), verticalArrangement = Arrangement.spacedBy(11.dp)) {
F8Header(
if (target == F8Target.SUBINSTALACION) "Nueva Subinstalación" else "Nueva Instalación",
parentLabel,
onBack,
)
F8Messages(model)
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (model.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
if (selectedAsset != null && selectedAsset.capture.captureRequired) {
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(selectedAsset.asset.name, fontWeight = FontWeight.Bold)
Text("GPS ${if (selectedAsset.capture.creationGpsCaptured) "✓" else "pendiente"} · Foto ${if (selectedAsset.capture.fieldPhotoCount > 0) "✓" else "pendiente"}")
if (!selectedAsset.capture.readyForFinding) {
Button(onClick = { requestPhoto() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
Icon(Icons.Filled.CameraAlt, null)
Spacer(Modifier.width(7.dp))
Text("Tomar foto y continuar")
}
} else {
Button(onClick = { model.openFindingForSelected() }, modifier = Modifier.fillMaxWidth(), enabled = !model.busy) {
Text("Continuar con el Hallazgo")
}
}
OutlinedButton(onClick = onFinishedWithoutFinding, modifier = Modifier.fillMaxWidth()) { Text("Volver a la inspección") }
}
}
} else {
Text("Datos básicos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
OutlinedTextField(name, { name = it }, label = { Text("Nombre *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
if (model.fieldTypes.size > 1) {
Text("Tipo", fontWeight = FontWeight.SemiBold)
model.fieldTypes.forEach { type ->
FilterChip(
selected = selectedTypeId == type.id,
onClick = { selectedTypeId = type.id; selectedFamilyId = null; attributes.clear() },
label = { Text(type.name) },
)
}
} else {
selectedType?.let { Text("Tipo · ${it.name}", color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
if (selectedType?.familyRequired == true) {
Text("Función / clasificación", fontWeight = FontWeight.SemiBold)
selectedType.families.forEach { family ->
FilterChip(
selected = selectedFamilyId == family.id,
onClick = { selectedFamilyId = family.id },
label = { Text(family.name) },
)
}
}
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributes[definition.code].orEmpty(),
onValueChange = { attributes[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(9.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.CameraAlt, null, tint = MaterialTheme.colorScheme.secondary)
Text("Al guardar se toma GPS y foto. Después volvés directamente al Hallazgo.", style = MaterialTheme.typography.bodySmall)
}
}
val requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributes[it.code].orEmpty().isNotBlank() } ?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = { requestCreate() },
enabled = name.isNotBlank() && selectedType != null && requiredReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CameraAlt, null)
Spacer(Modifier.width(7.dp))
Text("Guardar y tomar foto")
}
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun F8Header(title: String, subtitle: String, onBack: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Volver") }
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun F8Messages(model: MainViewModel) {
model.error?.let {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onErrorContainer)
}
}
model.notice?.let {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
Text(it, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
}
private fun f8VisitStatus(status: String): String = when (status) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun f8ActStatus(status: String): String = when (status) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Pendiente de firma"
"SEALED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun f8Norm(value: String?): String = (value ?: "")
.normalize(java.text.Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
.trim()
.lowercase()
private fun f8Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
type.attributes.associate { definition -> definition.code to f8AttributeValue(definition, values[definition.code].orEmpty()) }
private fun f8AttributeValue(definition: FieldAttributeDefinition, raw: String): Any? {
val value = raw.trim()
if (value.isBlank()) return null
return when (definition.dataType.uppercase()) {
"NUMBER", "DECIMAL", "FLOAT" -> value.replace(',', '.').toDoubleOrNull() ?: value
"INTEGER", "INT" -> value.toLongOrNull() ?: value
"BOOLEAN", "BOOL" -> value.equals("true", true) || value.equals("si", true) || value.equals("", true) || value == "1"
else -> value
}
}
private fun f8HasLocation(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 f8CurrentGeo(context: Context): F8Geo = suspendCancellableCoroutine { continuation ->
if (!f8HasLocation(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(F8Geo(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 f8NewPhoto(context: Context): Pair<File, Uri> {
val dir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) ?: context.filesDir
val file = File(dir, "dh_field_${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
return file to uri
}
private fun f8WriteExif(file: File, geo: F8Geo) {
val exif = ExifInterface(file)
exif.setGpsInfo(android.location.Location("DH").apply {
latitude = geo.latitude
longitude = geo.longitude
accuracy = geo.accuracyM?.toFloat() ?: 0f
time = System.currentTimeMillis()
})
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, Instant.now().toString())
exif.saveAttributes()
}
@@ -185,7 +185,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Text(asset.name, fontWeight = FontWeight.Bold) Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall) Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text( 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, color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
@@ -210,7 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold) 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( Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}", "Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -389,17 +389,13 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context) val client = LocationServices.getFusedLocationProviderClient(context)
try { client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) .addOnSuccessListener { location ->
.addOnSuccessListener { location -> if (!continuation.isActive) return@addOnSuccessListener
if (!continuation.isActive) return@addOnSuccessListener if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual.")) else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble())) }
} .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -3,13 +3,11 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context import android.content.Context
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility 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.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@@ -41,7 +37,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric" private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled" private const val BIOMETRIC_ENABLED = "enabled"
@@ -79,7 +74,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
if (model.session == null) unlocked = false if (model.session == null) unlocked = false
} }
DhTheme { MaterialTheme {
Surface(Modifier.fillMaxSize()) { Surface(Modifier.fillMaxSize()) {
when { when {
model.session == null -> EnhancedLoginScreen(model) { model.session == null -> EnhancedLoginScreen(model) {
@@ -96,8 +91,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
}, },
) )
model.fieldFindingOptions != null -> FieldFindingScreen(model) model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> F8VisitRoot(model) model.visit != null -> F3VisitRoot(model)
else -> MobileHomeScreen(model) else -> DhApp(model)
} }
} }
} }
@@ -110,13 +105,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
var passwordVisible by rememberSaveable { mutableStateOf(false) } var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos") Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -124,7 +113,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField( OutlinedTextField(
value = identifier, value = identifier,
onValueChange = { identifier = it }, onValueChange = { identifier = it },
label = { Text("Usuario o correo electrónico") }, label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
@@ -209,12 +198,6 @@ private fun BiometricUnlockScreen(
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella") Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -126,14 +126,13 @@ fun MobileActsScreen(
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
} }
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}") Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
F32ActMessage(model) F32ActMessage(model)
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { 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()) { 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 -> model.acts.forEach { act ->
val active = selected?.id == act.id val active = selected?.id == act.id
@@ -157,15 +156,15 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) { ) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) { if (model.acts.any { it.status == "LOCKED" }) {
Text( Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.", "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, style = MaterialTheme.typography.bodySmall,
) )
} }
Text("Urgencia", fontWeight = FontWeight.Bold) Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip( AssistChip(
onClick = { newActUrgency = "NON_URGENT" }, onClick = { newActUrgency = "NON_URGENT" },
@@ -184,15 +183,20 @@ fun MobileActsScreen(
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( val selectedInventory = model.selectedFieldAsset?.asset
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.", if (selectedInventory == null) {
style = MaterialTheme.typography.bodySmall, 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()) {
Button( Text("Ir a Inventario y elegir")
onClick = { model.createAct(newActUrgency) }, }
enabled = !model.busy, } else {
modifier = Modifier.fillMaxWidth(), Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
) { Text("Crear nueva Acta") } Button(
onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
} }
} }
} }
@@ -216,21 +220,7 @@ fun MobileActsScreen(
when (selected.status) { when (selected.status) {
"DRAFT" -> { "DRAFT" -> {
Card( Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("+ Agregar Hallazgo")
}
}
}
HorizontalDivider()
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
@@ -269,7 +259,12 @@ fun MobileActsScreen(
} }
HorizontalDivider() 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.") Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button( Button(
onClick = { model.prepareSelectedAct() }, onClick = { model.prepareSelectedAct() },
@@ -436,17 +431,13 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
return@suspendCancellableCoroutine return@suspendCancellableCoroutine
} }
val source = CancellationTokenSource() val source = CancellationTokenSource()
try { LocationServices.getFusedLocationProviderClient(context)
LocationServices.getFusedLocationProviderClient(context) .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) .addOnSuccessListener { location ->
.addOnSuccessListener { location -> if (!continuation.isActive) return@addOnSuccessListener
if (!continuation.isActive) return@addOnSuccessListener if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) }
} .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
continuation.invokeOnCancellation { source.cancel() } continuation.invokeOnCancellation { source.cancel() }
} }
@@ -1,231 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@Composable
fun MobileHomeScreen(model: MainViewModel) {
val session = model.session ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
var showOpen by rememberSaveable { mutableStateOf(false) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(null) }
fun loadAreas() {
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.areas().data }
.onSuccess { areas = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
fun chooseArea(areaId: String) {
selectedAreaId = areaId
selectedCompanyId = null
operators = emptyList()
scope.launch {
loadingContext = true
localError = null
runCatching { openRepository.operators(areaId).data }
.onSuccess { operators = it }
.onFailure { localError = DhRepository.humanError(it) }
loadingContext = false
}
}
LaunchedEffect(showOpen) {
if (showOpen && areas.isEmpty()) loadAreas()
}
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row {
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
Text("Actualizar")
}
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
}
}
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(
onClick = {
showOpen = !showOpen
localError = null
},
modifier = Modifier.fillMaxWidth(),
enabled = !opening,
) {
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
}
if (showOpen) {
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
style = MaterialTheme.typography.bodySmall,
)
Text("1. Área", fontWeight = FontWeight.SemiBold)
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(areas, key = { it.id }) { area ->
AssistChip(
onClick = { chooseArea(area.id) },
label = { Text(if (area.id == selectedAreaId) "${area.name}" else area.name) },
)
}
}
if (selectedAreaId != null) {
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
if (loadingContext && operators.isEmpty()) {
CircularProgressIndicator()
} else if (operators.isEmpty()) {
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
}
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(operators, key = { it.id }) { company ->
AssistChip(
onClick = { selectedCompanyId = company.id },
label = { Text(if (company.id == selectedCompanyId) "${company.name}" else company.name) },
)
}
}
}
Button(
onClick = {
val areaId = selectedAreaId ?: return@Button
val companyId = selectedCompanyId ?: return@Button
scope.launch {
opening = true
localError = null
runCatching { openRepository.open(areaId, companyId) }
.onSuccess { opened ->
showOpen = false
model.openVisit(opened.id)
}
.onFailure { localError = DhRepository.humanError(it) }
opening = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
) {
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
}
}
}
}
}
if (model.busy && model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (model.visits.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
}
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
items(model.visits, key = { it.id }) { visit ->
MobileVisitCard(visit) { model.openVisit(visit.id) }
}
item { Spacer(Modifier.height(24.dp)) }
}
}
}
}
@Composable
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(visit.code, fontWeight = FontWeight.Bold)
Text(visitStatusLabelEs(visit.status))
}
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
visit.plannedStartAt?.let {
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
}
Text(
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@@ -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)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text( 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, style = MaterialTheme.typography.bodySmall,
) )
Canvas( Canvas(
@@ -1,5 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import java.text.Normalizer
internal fun String.normalize(form: Normalizer.Form): String = Normalizer.normalize(this, form)
@@ -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() }
}
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Launcher de presentación F6.1 inspirado en el escudo institucional publicado
por Gobierno de Mendoza en mendoza.gov.ar. Se mantiene como vector local para
evitar el ícono genérico del sistema y no depender de assets remotos.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- fondo institucional -->
<path
android:fillColor="#0B5FA5"
android:pathData="M54,4 A50,50 0,1 0,54 104 A50,50 0,1 0,54 4" />
<!-- escudo exterior -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,13 C72,13 84,22 87,38 L83,65 C80,81 68,93 54,99 C40,93 28,81 25,65 L21,38 C24,22 36,13 54,13 Z" />
<!-- campo superior celeste -->
<path
android:fillColor="#22A9E0"
android:pathData="M27,38 C30,27 39,21 54,21 C69,21 78,27 81,38 L78,59 L30,59 Z" />
<!-- campo inferior dorado -->
<path
android:fillColor="#C6A66A"
android:pathData="M30,63 L78,63 L76,68 C73,80 65,89 54,94 C43,89 35,80 32,68 Z" />
<!-- gorro frigio simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M47,31 C52,26 61,25 67,28 C64,31 63,35 64,39 C58,37 52,37 46,40 C44,36 44,33 47,31 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,39 L57,39 L56,48 L52,48 Z" />
<!-- racimo de uva simplificado -->
<path
android:fillColor="#FFFFFF"
android:pathData="M53,69 C56,69 58,71 58,74 C58,77 56,79 53,79 C50,79 48,77 48,74 C48,71 50,69 53,69 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M45,73 C48,73 50,75 50,78 C50,81 48,83 45,83 C42,83 40,81 40,78 C40,75 42,73 45,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M61,73 C64,73 66,75 66,78 C66,81 64,83 61,83 C58,83 56,81 56,78 C56,75 58,73 61,73 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M53,80 C56,80 58,82 58,85 C58,88 56,90 53,90 C50,90 48,88 48,85 C48,82 50,80 53,80 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M52,67 C54,63 58,61 62,61 C60,65 57,68 53,70 Z" />
</vector>
Binary file not shown.

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\")"))
}
}
@@ -1,20 +0,0 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugBuildKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(29, BuildConfig.VERSION_CODE)
assertEquals("0.20.0-debug", BuildConfig.VERSION_NAME)
}
@Test
fun fieldBuildTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
+8 -8
View File
@@ -1,12 +1,12 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.20.0-2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.20.0-2",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
@@ -4166,9 +4166,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.3.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
@@ -4666,9 +4666,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.16.0", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
+1 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "dhv2-api", "name": "dhv2-api",
"version": "0.29.0-1", "version": "0.28.0-1",
"private": true, "private": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
@@ -42,9 +42,5 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.0" "typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
} }
} }
@@ -36,20 +36,6 @@ export interface FieldBriefingAct {
findings: FieldBriefingFinding[]; 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() @Injectable()
export class FieldBriefingService { export class FieldBriefingService {
constructor(private readonly dataSource: DataSource) {} constructor(private readonly dataSource: DataSource) {}
@@ -81,9 +67,7 @@ export class FieldBriefingService {
}); });
} }
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10) const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
const plannedOn = toIsoDate(visit.plannedStartAt);
const rows = (await this.dataSource.query(` const rows = (await this.dataSource.query(`
WITH prior_acts AS ( WITH prior_acts AS (
@@ -24,7 +24,6 @@ import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller'; import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller'; import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
@@ -60,7 +59,6 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetTypesService, AssetTypesService,
AssetsService, AssetsService,
InventoryStructureService, InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService, InventoryFamilyCatalogService,
InventoryTechnicalValuesService, InventoryTechnicalValuesService,
InventoryFunctionService, InventoryFunctionService,
@@ -142,8 +142,27 @@ export class AssetOperationalRelationsService {
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
} }
// F7: un Área puede estar explotada por varias Empresas simultáneamente. if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área. const [currentOperator] = (await manager.query(`
SELECT relation.id,company.name AS "companyName"
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND relation.company_id<>$2
ORDER BY relation.valid_from DESC
LIMIT 1
FOR UPDATE OF relation
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
if (currentOperator) {
throw new ConflictException({
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
});
}
}
const [row] = (await manager.query(` const [row] = (await manager.query(`
INSERT INTO area_company_relations ( INSERT INTO area_company_relations (
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
@@ -151,6 +170,20 @@ export class AssetOperationalRelationsService {
RETURNING id RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
const created = await this.loadRelation(manager, row.id); const created = await this.loadRelation(manager, row.id);
await this.audit.record({ await this.audit.record({
...administrationAuditContext(principal, request), ...administrationAuditContext(principal, request),
@@ -159,7 +192,7 @@ export class AssetOperationalRelationsService {
entityId: row.id, entityId: row.id,
afterData: this.auditView(created), afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true } ? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
: undefined, : undefined,
}, manager); }, manager);
return created; return created;
@@ -190,7 +223,8 @@ export class AssetOperationalRelationsService {
}); });
} }
// Ending an operator relation never moves, rewrites or blocks existing Inventory. // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory.
await manager.query(` await manager.query(`
UPDATE area_company_relations UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP, SET valid_until = CURRENT_TIMESTAMP,
@@ -209,7 +243,6 @@ export class AssetOperationalRelationsService {
afterData: this.auditView(updated), afterData: this.auditView(updated),
metadata: { metadata: {
inventoryHierarchyChanged:false, inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount, retainedCompatibilitySnapshotCount: before.assignedAssetCount,
}, },
}, manager); }, manager);
@@ -137,41 +137,21 @@ export class AssetTemporalService {
if (!row) throw temporalAssetNotFound(); if (!row) throw temporalAssetNotFound();
const asOf = new Date(query.at); const asOf = new Date(query.at);
row.asOf = asOf; row.asOf = asOf;
// Physical context is reconstructed from the Inventory history, while the
// operator is resolved independently from the temporal Area↔Empresa ledger.
// This keeps historical operator changes from rewriting the Inventory.
const [context] = (await this.dataSource.query(` const [context] = (await this.dataSource.query(`
SELECT SELECT
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
'id',operator_company.id,'code',operator_company.code,'name',operator_company.name
) END AS "operatorCompany"
FROM asset_context_history history FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN LATERAL ( LEFT JOIN assets company ON company.id=history.operator_company_id
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id=history.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
LIMIT 1
) operator_relation ON true
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id
WHERE history.asset_id=$1 WHERE history.asset_id=$1
AND history.valid_from <= $2 AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2) AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC ORDER BY history.valid_from DESC
LIMIT 1 LIMIT 1
`, [assetId, asOf])) as Array<{ `, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
parent: Record<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | null;
}>;
if (context && row.snapshot) { if (context && row.snapshot) {
row.snapshot = { row.snapshot = {
...row.snapshot, ...row.snapshot,
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto { export class ChangeAssetContextDto {
@IsOptional() @IsOptional()
@@ -10,13 +10,8 @@ export class ChangeAssetContextDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string | null; operationalAreaId?: string | null;
/**
* Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física
* del Inventario de la Operadora temporal del Área, por lo que este valor no
* puede modificarse desde un cambio de contexto del Inventario.
*/
@IsOptional() @IsOptional()
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' }) @IsUUID('4')
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional() @IsOptional()
@@ -54,10 +54,6 @@ export class CreateAssetDto {
@IsUUID('4') @IsUUID('4')
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null, typeof value === 'string' && value.trim() ? value.trim() : null,
@@ -9,9 +9,6 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
// La Empresa y el Tipo de concesión se relacionan directamente con el Yacimiento.
export const INVENTORY_STRUCTURE_KINDS = [ export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA', 'EMPRESA',
'DEPARTAMENTO', 'DEPARTAMENTO',
@@ -53,14 +50,6 @@ export class CreateInventoryStructureDto {
@IsUUID('4') @IsUUID('4')
familyId?: string | null; familyId?: string | null;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@IsUUID('4')
concessionTypeId?: string | null;
@IsOptional() @IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null) @Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString() @IsString()
@@ -1,5 +1,5 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities'; import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto { export class ListAssetTreeQueryDto {
@@ -24,12 +24,8 @@ export class ListAssetTreeQueryDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string; operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
* filtrar por el snapshot histórico de Empresa del Inventario.
*/
@IsOptional() @IsOptional()
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' }) @IsUUID('4')
operatorCompanyId?: string; operatorCompanyId?: string;
@IsOptional() @IsOptional()
@@ -1,7 +1,6 @@
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsEmpty,
IsEnum, IsEnum,
IsInt, IsInt,
IsOptional, IsOptional,
@@ -67,12 +66,7 @@ export class ListAssetsQueryDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId?: string; operationalAreaId?: string;
/**
* Retenido sólo para compatibilidad tipada con consumidores históricos.
* F5/F6 no permite buscar ownership actual por el snapshot de Empresa del
* Inventario; la navegación Área↔Operadora usa area_company_relations.
*/
@IsOptional() @IsOptional()
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' }) @IsUUID('4')
operatorCompanyId?: string; operatorCompanyId?: string;
} }
@@ -1,6 +1,5 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsEmpty,
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
@@ -49,13 +48,9 @@ export class UpdateAssetDto {
operationalAreaId?: string | null; operationalAreaId?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' }) @IsUUID('4')
operatorCompanyId?: string | null; 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() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : null, 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 type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto'; import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service'; import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure') @Controller('inventory-structure')
export class InventoryStructureController { export class InventoryStructureController {
constructor( constructor(private readonly inventoryStructure: InventoryStructureService) {}
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
@Get() @Get()
@RequirePermissions('assets.read') @RequirePermissions('assets.read')
@@ -28,17 +24,6 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search); 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() @Post()
@RequirePermissions('assets.create') @RequirePermissions('assets.create')
create( create(
@@ -20,7 +20,6 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
import { AssetHistoryService } from './asset-history.service'; import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string }; type StructureTypeRow = { id: string; code: string; name: string };
type SimpleOptionRow = { id: string; code: string; name: string };
type FamilyParent = { id: string; code: string; name: string }; type FamilyParent = { id: string; code: string; name: string };
type FamilyRow = { type FamilyRow = {
id: string; id: string;
@@ -38,17 +37,9 @@ type ParentRow = {
name: string; name: string;
typeCode: string; typeCode: string;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
}; };
type IdRow = { id: string }; type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = { const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento', DEPARTAMENTO: 'departamento',
AREA: 'area', AREA: 'area',
@@ -123,21 +114,6 @@ export class InventoryStructureService {
ORDER BY family.level,family.name,family.code ORDER BY family.level,family.name,family.code
`)) as FamilyRow[]; `)) 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 { return {
independentMasters: [ independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false }, { 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: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true }, { kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
], ],
companies,
concessionTypes,
installationFamilies: families.filter((item) => item.level==='INSTALLATION'), installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
}; };
@@ -205,43 +179,25 @@ export class InventoryStructureService {
const type = await this.requireStructureType(manager, dto.kind); const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
const yacimientoContext = await this.requireYacimientoContext(manager, dto);
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind) const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
? await this.resolveAreaId(manager, parent) ? await this.resolveAreaId(manager, parent)
: null; : 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(` const inserted = (await manager.query(`
INSERT INTO assets ( 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, code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES ( ) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid, $1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status, $5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid $11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id ) RETURNING id
`, [ `, [
type.id, type.id,
parent?.id ?? null, parent?.id ?? null,
operationalAreaId, operationalAreaId,
operatorCompanyId,
concessionTypeId,
family?.id ?? null, family?.id ?? null,
generatedCode, generatedCode,
dto.name, dto.name,
@@ -250,13 +206,9 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT, AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN, AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL, 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()}`, dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
? `Clasificación técnica: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
principal.userId, principal.userId,
])) as Array<{ id: string }>; ])) as Array<{ id: string }>;
const id = inserted[0]?.id; 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 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]); `,[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( const versionNumber = await this.history.capture(
manager,id,AssetVersionChangeType.CREATED,principal,request, manager,id,AssetVersionChangeType.CREATED,principal,request,
@@ -286,14 +229,10 @@ export class InventoryStructureService {
INSERT INTO asset_context_history ( INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from, asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8) ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [ `, [
id,parent?.id ?? null,operationalAreaId,operatorCompanyId, id,parent?.id ?? null,operationalAreaId,
dto.kind === 'YACIMIENTO' dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F6' : 'Alta manual de estructura de Inventario F6',
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
: dto.kind === 'EMPRESA'
? 'Alta manual de Empresa independiente'
: 'Alta manual de estructura de Inventario',
versionNumber,request.requestId,principal.userId, versionNumber,request.requestId,principal.userId,
]); ]);
const created = await this.loadView(manager, id); const created = await this.loadView(manager, id);
@@ -305,8 +244,7 @@ export class InventoryStructureService {
metadata: { metadata: {
versionNumber,inventoryStructureKind: dto.kind, versionNumber,inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null, inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
operatorCompanyId, operatorOwnership: false,
concessionTypeId,
}, },
}, manager); }, manager);
return created; return created;
@@ -352,9 +290,7 @@ export class InventoryStructureService {
}); });
const rows = (await manager.query(` const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId", asset.inventory_family_id AS "inventoryFamilyId"
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE' WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
@@ -369,65 +305,8 @@ export class InventoryStructureService {
return parent; 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> { private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id; if (parent.typeCode.toLowerCase()==='area') return parent.id;
if (parent.operationalAreaId) return parent.operationalAreaId;
const rows = (await manager.query(` const rows = (await manager.query(`
WITH RECURSIVE lineage AS ( WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid 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; 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 { private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'EMPRESA' ? 'EMP' const prefix = kind === 'EMPRESA' ? 'EMP'
: kind === 'DEPARTAMENTO' ? 'DEP' : kind === 'DEPARTAMENTO' ? 'DEP'
@@ -555,8 +400,6 @@ export class InventoryStructureService {
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", CASE WHEN 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( CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level, 'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels 'informationLabels',family.information_labels
@@ -566,9 +409,6 @@ export class InventoryStructureService {
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN assets area ON area.id=asset.operational_area_id LEFT JOIN 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 LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid WHERE asset.id=$1::uuid
`, [id]); `, [id]);
@@ -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_parent_id', ['parentId'])
@Index('idx_assets_operational_area_id', ['operationalAreaId']) @Index('idx_assets_operational_area_id', ['operationalAreaId'])
@Index('idx_assets_operator_company_id', ['operatorCompanyId']) @Index('idx_assets_operator_company_id', ['operatorCompanyId'])
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId']) @Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
@Index('idx_assets_information_status', ['informationStatus']) @Index('idx_assets_information_status', ['informationStatus'])
@Index('idx_assets_operational_status', ['operationalStatus']) @Index('idx_assets_operational_status', ['operationalStatus'])
@@ -55,9 +54,6 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true }) @Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
operatorCompanyId!: string | null; operatorCompanyId!: string | null;
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
concessionTypeId!: string | null;
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true }) @Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null; inventoryFamilyId!: string | null;
@@ -30,8 +30,6 @@ export enum InspectionDeadlineBasis {
GEDO_DATE = 'GEDO_DATE', GEDO_DATE = 'GEDO_DATE',
} }
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
@Entity({ name: 'inspection_acts' }) @Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" }) @Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true }) @Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -1,35 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5/F6 permits physical Inventory to belong to an Área without persisting a
* Company snapshot. Company snapshots are optional creation/history metadata;
* current ownership is resolved from area_company_relations.
*
* The F4 paired CHECK survived the trigger migration and contradicted that
* model. Keep only the invariant that a Company snapshot cannot exist without
* an Área.
*/
export class F61AreaOwnedOperationalContextCheck1790094500000 implements MigrationInterface {
name='F61AreaOwnedOperationalContextCheck1790094500000';
public async up(queryRunner:QueryRunner):Promise<void>{
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
await queryRunner.query(`
ALTER TABLE assets
ADD CONSTRAINT chk_assets_operational_assignment_pair
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
`);
}
public async down(queryRunner:QueryRunner):Promise<void>{
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
await queryRunner.query(`
ALTER TABLE assets
ADD CONSTRAINT chk_assets_operational_assignment_pair
CHECK (
(operational_area_id IS NULL AND operator_company_id IS NULL)
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
)
`);
}
}
@@ -1,163 +0,0 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { F61_PRESENTATION_TERRITORY_SOURCE as SOURCE } from '../../reference-data/f6-1-presentation-territory-source';
type IdRow={id:string};
const NO_OPERATOR='Sin Empresa Operadora';
const DOC='DH-F6.1-PRESENTATION-TERRITORY-20260909';
const key=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase()
.replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
const code=(prefix:string,value:string,length=12)=>
`${prefix}-${createHash('sha1').update(value).digest('hex').slice(0,length).toUpperCase()}`;
const uniqueBy=<T>(rows:T[],identity:(row:T)=>string)=>{
const seen=new Set<string>(); return rows.filter((row)=>{const id=identity(row);if(seen.has(id))return false;seen.add(id);return true;});
};
export class F61PresentationTerritoryReset1790094600000 implements MigrationInterface {
name='F61PresentationTerritoryReset1790094600000';
public async up(q:QueryRunner):Promise<void>{
const rows=SOURCE.rows;
if(SOURCE.file!=='Tablas de yacimiento y areas(1).xlsx'||SOURCE.sheet!=='cr26e_tabla1'
||SOURCE.sha256!=='afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb'||rows.length!==230)
throw new Error('F6.1 presentation source contract mismatch');
const areas=uniqueBy(rows,(r)=>key(r.area));
const pairs=uniqueBy(rows,(r)=>`${key(r.area)}|${key(r.yacimiento)}`);
const departments=uniqueBy(rows,(r)=>key(r.departamento));
const companies=[...new Set(rows.map((r)=>r.empresaOperadora.trim())
.filter((name)=>name&&key(name)!==key(NO_OPERATOR)))].sort((a,b)=>a.localeCompare(b,'es'));
if(areas.length!==64||pairs.length!==230||departments.length!==7||companies.length!==12
||areas.filter((r)=>key(r.empresaOperadora)!==key(NO_OPERATOR)).length!==47)
throw new Error('F6.1 presentation source cardinality mismatch');
const concessions=new Set(rows.map((r)=>r.tipoConcesion.trim()));
if(concessions.size!==2||!concessions.has('Exploración')||!concessions.has('Explotación'))
throw new Error('F6.1 presentation concession contract mismatch');
for(const area of areas){
const same=rows.filter((r)=>key(r.area)===key(area.area));
for(const field of ['departamento','tipoConcesion','empresaOperadora'] as const)
if(new Set(same.map((r)=>key(r[field]))).size!==1) throw new Error(`Conflicting ${field}: ${area.area}`);
}
const companyType=await this.type(q,
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`);
const departmentType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true AND can_be_root=true LIMIT 1`);
const areaType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
const yacimientoType=await this.type(q,
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
const [rules]=await q.query(`
SELECT COUNT(*)::integer AS total FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='area' AND lower(parent.code)='departamento')
OR (lower(child.code)='yacimiento' AND lower(parent.code)='area')`);
if(Number(rules?.total)!==2) throw new Error('F6.1 canonical hierarchy rules are incomplete');
// Destructive clean start requested for the presentation. Users, RBAC,
// technical families, Finding masters and system configuration are preserved.
// Recovery is the automatic PRE database backup made by deploy-github.sh.
await q.query('TRUNCATE TABLE assets CASCADE');
await q.query('TRUNCATE TABLE administrative_departments CASCADE');
await q.query('TRUNCATE TABLE source_documents CASCADE');
await q.query('TRUNCATE TABLE audit_events');
await q.query(`DO $$ DECLARE t text; BEGIN FOR t IN SELECT tablename FROM pg_tables
WHERE schemaname=current_schema() AND tablename LIKE 'asset_import_%'
LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE',t); END LOOP; END $$;`);
const [doc]=(await q.query(`INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id`,
[DOC,SOURCE.file,`sha256:${SOURCE.sha256}`,`F6.1 · ${SOURCE.sheet} · ${rows.length} filas`])) as IdRow[];
if(!doc?.id) throw new Error('Could not create F6.1 presentation source');
const companyIds=new Map<string,string>();
for(const name of companies){
const id=await this.asset(q,companyType,null,null,code('PRES-ORG',key(name)),name,
'Empresa/Operadora del corte de presentación.',`F6.1:PRESENTATION:COMPANY:${code('SRC',key(name),10)}`,'Empresa');
companyIds.set(key(name),id);
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name)
VALUES($1::uuid,$2::organization_kind,$3)`,[id,name.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',name]);
await this.link(q,id,doc.id,'Empresa/Operadora');
}
const departmentIds=new Map<string,string>();
for(const row of departments.sort((a,b)=>a.departamento.localeCompare(b.departamento,'es'))){
const id=await this.asset(q,departmentType,null,null,code('PRES-DEP',key(row.departamento)),row.departamento,
'Departamento administrativo raíz F6.1.',`F6.1:PRESENTATION:DEPARTMENT:${code('SRC',key(row.departamento),10)}`,'Departamento');
departmentIds.set(key(row.departamento),id); await this.link(q,id,doc.id,'Departamento');
}
const areaIds=new Map<string,string>();
for(const row of areas.sort((a,b)=>a.area.localeCompare(b.area,'es'))){
const parent=departmentIds.get(key(row.departamento)); if(!parent)throw new Error(`Missing Departamento ${row.departamento}`);
const id=await this.asset(q,areaType,parent,null,code('PRES-AREA',key(row.area)),row.area,
`Área del Departamento ${row.departamento}.`,`F6.1:PRESENTATION:AREA:${code('SRC',key(row.area),10)}`,'Área');
areaIds.set(key(row.area),id); await this.link(q,id,doc.id,`Área · ${row.departamento}`);
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
id,row.tipoConcesion==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
`${row.tipoConcesion} · ${row.area}`,doc.id,`F6.1 · ${SOURCE.file}`]);
if(key(row.empresaOperadora)!==key(NO_OPERATOR)){
const company=companyIds.get(key(row.empresaOperadora)); if(!company)throw new Error(`Missing Empresa ${row.empresaOperadora}`);
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,
[id,company,doc.id,`F6.1 · operadora vigente según ${SOURCE.file}`]);
}
}
for(const row of pairs){
const area=areaIds.get(key(row.area)); if(!area)throw new Error(`Missing Área ${row.area}`);
const id=await this.asset(q,yacimientoType,area,area,code('PRES-YAC',`${key(row.area)}|${key(row.yacimiento)}`),
row.yacimiento,`Yacimiento del Área ${row.area}.`,
`F6.1:PRESENTATION:YAC:${code('SRC',`${key(row.area)}|${key(row.yacimiento)}`,12)}`,`${SOURCE.sheet} · fila ${row.sourceRow}`);
await this.link(q,id,doc.id,`Yacimiento · ${row.area} · fila ${row.sourceRow}`);
}
const [c]=await q.query(`SELECT
(SELECT COUNT(*) FROM assets)::integer total_assets,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer yacimientos,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE')::integer legal_rights,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLORATION_PERMIT')::integer exploration_rights,
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLOITATION_CONCESSION')::integer exploitation_rights,
(SELECT COUNT(*) FROM area_company_relations WHERE relation_role='OPERATOR' AND valid_until IS NULL)::integer operators,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area'
AND NOT EXISTS(SELECT 1 FROM area_company_relations r WHERE r.area_id=a.id AND r.relation_role='OPERATOR' AND r.valid_until IS NULL))::integer areas_without_operator,
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN assets p ON p.id=a.parent_id
LEFT JOIN asset_types pt ON pt.id=p.asset_type_id WHERE lower(t.code)='area' AND (p.id IS NULL OR lower(pt.code)<>'departamento'))::integer invalid_area_parents,
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id LEFT JOIN assets a ON a.id=y.parent_id
LEFT JOIN asset_types at ON at.id=a.asset_type_id WHERE lower(t.code)='yacimiento'
AND (a.id IS NULL OR lower(at.code)<>'area' OR y.operational_area_id IS DISTINCT FROM a.id))::integer invalid_yacimiento_contexts,
(SELECT COUNT(*) FROM asset_source_documents WHERE document_id=$1::uuid)::integer source_links,
(SELECT COUNT(*) FROM source_documents WHERE document_number=$2)::integer source_documents`,[doc.id,DOC]);
const expected:Record<string,number>={total_assets:313,companies:12,departments:7,areas:64,yacimientos:230,
legal_rights:64,exploration_rights:18,exploitation_rights:46,operators:47,areas_without_operator:17,
invalid_area_parents:0,invalid_yacimiento_contexts:0,source_links:313,source_documents:1};
for(const [field,value] of Object.entries(expected))
if(Number(c?.[field]??-1)!==value)throw new Error(`F6.1 seed verification failed: ${field}=${c?.[field]} expected=${value}`);
}
public async down():Promise<void>{
throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.');
}
private async type(q:QueryRunner,sql:string):Promise<string>{
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing F6.1 master type: ${sql}`); return rows[0].id;
}
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,assetCode:string,name:string,
description:string,sourceReference:string,sourceNotes:string):Promise<string>{
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
code,name,description,information_status,operational_status,data_origin,source_name,source_reference,source_notes,is_inventory_instance)
VALUES($1::uuid,$2::uuid,$3::uuid,NULL,NULL,$4,$5,$6,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$7,$8,$9,false) RETURNING id`,
[typeId,parentId,areaId,assetCode,name,description,SOURCE.file,sourceReference,sourceNotes])) as IdRow[];
if(!row?.id)throw new Error(`Could not seed ${name}`); return row.id;
}
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
}
}
@@ -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 { Transform } from 'class-transformer';
import { import {
ArrayMaxSize, ArrayMaxSize,
ArrayMinSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsEnum, IsEnum,
@@ -41,6 +42,7 @@ export class CreateInspectionActDto {
observations?: string | null; observations?: string | null;
@IsArray() @IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200) @ArrayMaxSize(200)
@ArrayUnique() @ArrayUnique()
@IsUUID('4', { each: true }) @IsUUID('4', { each: true })
@@ -5,16 +5,10 @@ import {
InspectionVisitActsController, InspectionVisitActsController,
} from './inspection-acts.controller'; } from './inspection-acts.controller';
import { InspectionActsService } from './inspection-acts.service'; import { InspectionActsService } from './inspection-acts.service';
import { MobileInspectionActsController } from './mobile-inspection-acts.controller';
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
@Module({ @Module({
imports: [AuditModule], imports: [AuditModule],
controllers: [ controllers: [InspectionVisitActsController, InspectionActsController],
InspectionVisitActsController, providers: [InspectionActsService],
MobileInspectionActsController,
InspectionActsController,
],
providers: [InspectionActsService, MobileInspectionActsService],
}) })
export class InspectionActsModule {} export class InspectionActsModule {}
@@ -154,26 +154,41 @@ export class InspectionActsService {
SELECT 1 SELECT 1
FROM inspection_act_assets search_link FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id WHERE search_link.act_id = act.id
AND search_link.included = true AND search_link.included = true
AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search}) AND (
) search_asset.code ILIKE ${search}
OR EXISTS ( OR search_asset.name ILIKE ${search}
SELECT 1 FROM assets search_company OR search_company.name ILIKE ${search}
WHERE search_company.id=visit.operator_company_id OR search_area.name ILIKE ${search}
AND search_company.name ILIKE ${search} )
)
OR EXISTS (
SELECT 1 FROM assets search_area
WHERE search_area.id=visit.operational_area_id
AND search_area.name ILIKE ${search}
) )
)`); )`);
} }
if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`); if (query.companyId) {
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`); conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1
FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.inspectorId) { if (query.inspectorId) {
const inspector = add(query.inspectorId); const inspector = add(query.inspectorId);
conditions.push(`( conditions.push(`(
@@ -230,8 +245,24 @@ export class InspectionActsService {
} }
if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.status) conditions.push(`act.status = ${add(query.status)}`);
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`); if (query.companyId) {
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`); conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.act_id = act.id
AND company_link.included = true
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
)`);
}
if (query.areaId) {
conditions.push(`EXISTS (
SELECT 1 FROM inspection_act_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.act_id = act.id
AND area_link.included = true
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
)`);
}
if (query.inspectorId) { if (query.inspectorId) {
const inspector = add(query.inspectorId); const inspector = add(query.inspectorId);
conditions.push(`( conditions.push(`(
@@ -517,16 +548,21 @@ export class InspectionActsService {
LEFT JOIN inspection_reports report ON report.act_id = act.id LEFT JOIN inspection_reports report ON report.act_id = act.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) 'id', company.id,
) END AS companies, 'code', company.code,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( 'name', company.name
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
) END AS areas COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
FROM inspection_visits context_visit 'id', area.id,
LEFT JOIN assets company ON company.id=context_visit.operator_company_id 'code', area.code,
LEFT JOIN assets area ON area.id=context_visit.operational_area_id 'name', area.name
WHERE context_visit.id=act.visit_id )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true
) context ON true ) context ON true
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
@@ -656,7 +692,6 @@ export class InspectionActsService {
visitId: string, visitId: string,
assetIds: string[], assetIds: string[],
): Promise<void> { ): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(` const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count SELECT COUNT(*)::integer AS count
FROM inspection_visit_assets FROM inspection_visit_assets
@@ -664,7 +699,7 @@ export class InspectionActsService {
AND asset_id = ANY($2::uuid[]) AND asset_id = ANY($2::uuid[])
AND included = true AND included = true
`, [visitId, assetIds])) as Array<{ count: number }>; `, [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({ throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID', code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada inventario del acta debe estar incluido en la inspección', message: 'Cada inventario del acta debe estar incluido en la inspección',
@@ -778,8 +813,6 @@ export class InspectionActsService {
'code', visit.code, 'code', visit.code,
'status', visit.status, 'status', visit.status,
'scopeAssetId', visit.scope_asset_id, 'scopeAssetId', visit.scope_asset_id,
'operationalAreaId', visit.operational_area_id,
'operatorCompanyId', visit.operator_company_id,
'actualStartedAt', visit.actual_started_at 'actualStartedAt', visit.actual_started_at
), ),
'assets', COALESCE(( 'assets', COALESCE((
@@ -1,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,14 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable() @Injectable()
export class F3FindingCatalogResolverService { export class F3FindingCatalogResolverService {
constructor( constructor(private readonly dataSource: DataSource) {}
private readonly dataSource: DataSource,
private readonly contextualCatalog: FindingCatalogService,
) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(` const [asset] = await this.dataSource.query(`
@@ -40,28 +36,17 @@ export class F3FindingCatalogResolverService {
enabled: true, enabled: true,
code: 'OTHER', code: 'OTHER',
label: 'OTROS', label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista entre los asociados a este Inventario. Quedará registrado para revisión en oficina.', help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. Quedará registrado para revisión en oficina.',
}; };
if (asset.typeCode.toLowerCase() === 'yacimiento') {
const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query);
return {
...contextual,
inventoryFamily: null,
catalogSource: 'ASSET_TYPE' as const,
configurationReason: contextual.configurationReason
?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.',
other,
};
}
if (!asset.familyId || asset.familyActive !== true) { if (!asset.familyId || asset.familyActive !== true) {
return { return {
asset: { id: asset.id, code: asset.code, name: asset.name }, asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null, inventoryFamily: null,
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: false, typeConfigured: false,
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.', configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'El Yacimiento es contexto estructural y no posee clasificación técnica. Usá OTROS sólo si necesitás registrar una observación excepcional.'
: 'El elemento todavía no tiene una clasificación técnica activa.',
categories: [], categories: [],
items: [], items: [],
other, other,
@@ -118,7 +103,6 @@ export class F3FindingCatalogResolverService {
name: asset.familyName, name: asset.familyName,
level: asset.familyLevel, level: asset.familyLevel,
}, },
catalogSource: 'INVENTORY_FAMILY' as const,
typeConfigured: true, typeConfigured: true,
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(), configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
categories, categories,
@@ -153,16 +153,13 @@ export class InspectionFindingWorklistService {
workflowFilters.push(`finding.status = 'OPEN'`); workflowFilters.push(`finding.status = 'OPEN'`);
} }
// Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se
// leen del contexto congelado de la Inspección que originó el Hallazgo, no
// del snapshot de compatibilidad del Inventario.
const joins = ` const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = visit.operator_company_id LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = visit.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
`; `;
const filters = [...contextFilters, ...workflowFilters]; const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
@@ -408,24 +408,30 @@ export class InspectionReportsService {
} }
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string { private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
// Reports are historical documents. Their Area/Company filters must use the return `EXISTS (
// parent Inspection snapshot, never the mutable/current Inventory context. SELECT 1
return `visit.${column} = ${parameter}::uuid`; FROM inspection_act_assets context_filter_link
INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id
WHERE context_filter_link.act_id = act.id
AND context_filter_link.included = true
AND context_filter_asset.${column} = ${parameter}::uuid
)`;
} }
private contextSelect(): string { private contextSelect(): string {
return ` return `
SELECT SELECT
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) 'id',company.id,'code',company.code,'name',company.name
) END AS companies, )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) 'id',area.id,'code',area.code,'name',area.name
) END AS areas )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
FROM inspection_visits context_visit FROM inspection_act_assets context_link
LEFT JOIN assets company ON company.id=context_visit.operator_company_id INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
LEFT JOIN assets area ON area.id=context_visit.operational_area_id LEFT JOIN assets company ON company.id=context_asset.operator_company_id
WHERE context_visit.id=act.visit_id LEFT JOIN assets area ON area.id=context_asset.operational_area_id
WHERE context_link.act_id=act.id AND context_link.included=true
`; `;
} }
@@ -66,6 +66,9 @@ interface LockedFindingRow {
assetId: string; assetId: string;
assetCode: string; assetCode: string;
assetName: string; assetName: string;
companyId: string | null;
companyCode: string | null;
companyName: string | null;
areaId: string | null; areaId: string | null;
areaCode: string | null; areaCode: string | null;
areaName: string | null; areaName: string | null;
@@ -73,12 +76,6 @@ interface LockedFindingRow {
status: string; status: string;
} }
interface PlannedOperatorRow {
companyId: string;
companyCode: string;
companyName: string;
}
@Injectable() @Injectable()
export class InspectionVerificationsService { export class InspectionVerificationsService {
constructor( constructor(
@@ -145,19 +142,8 @@ export class InspectionVerificationsService {
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN LATERAL (
SELECT operator_company.id,operator_company.code,operator_company.name
FROM area_company_relations relation
INNER JOIN assets operator_company ON operator_company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND operator_company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
) company ON true
${activeVisitJoin} ${activeVisitJoin}
`; `;
@@ -509,11 +495,15 @@ export class InspectionVerificationsService {
finding.next_control_on AS "nextControlOn", finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode", asset.code AS "assetCode",
asset.name AS "assetName", asset.name AS "assetName",
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName",
area.id AS "areaId", area.id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName" area.name AS "areaName"
FROM inspection_findings finding FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id LEFT JOIN assets area ON area.id = asset.operational_area_id
WHERE finding.id = ANY($1::uuid[]) WHERE finding.id = ANY($1::uuid[])
FOR UPDATE OF finding FOR UPDATE OF finding
@@ -529,51 +519,25 @@ export class InspectionVerificationsService {
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`, message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
}); });
} }
const missingArea = rows.find((row) => !row.areaId); const missingContext = rows.find((row) => !row.companyId || !row.areaId);
if (missingArea) { if (missingContext) {
throw new BadRequestException({ throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_REQUIRED', code: 'VERIFICATION_CONTEXT_REQUIRED',
message: `${missingArea.code} necesita un Área física antes de planificar la visita`, message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`,
}); });
} }
const companyId = rows[0]?.companyId;
const areaId = rows[0]?.areaId; const areaId = rows[0]?.areaId;
if (!areaId) { if (!companyId || !areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' }); throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' });
} }
if (rows.some((row) => row.areaId !== areaId)) { if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) {
throw new BadRequestException({ throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_MIXED', code: 'VERIFICATION_CONTEXT_MIXED',
message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación', message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación',
}); });
} }
// A verification visit is a new operational act. Its operator is therefore
// the Company valid for the Area at the planned visit date, never the
// historical compatibility snapshot stored on the Inventory.
const [operator] = await manager.query(`
SELECT
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
AND company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
FOR SHARE OF relation
`, [areaId, plannedStartAt]) as PlannedOperatorRow[];
if (!operator) {
throw new ConflictException({
code: 'VERIFICATION_OPERATOR_NOT_ACTIVE',
message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación',
});
}
const companyId = operator.companyId;
const activeLinks = await manager.query(` const activeLinks = await manager.query(`
SELECT verification_link.finding_id AS "findingId", visit.code SELECT verification_link.finding_id AS "findingId", visit.code
FROM inspection_finding_verification_visits verification_link FROM inspection_finding_verification_visits verification_link
@@ -588,7 +552,7 @@ export class InspectionVerificationsService {
}); });
} }
const companyName = operator.companyName; const companyName = rows[0]?.companyName ?? 'Empresa';
const areaName = rows[0]?.areaName ?? 'Área'; const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt); const code = await nextInspectionVisitCode(manager, plannedStartAt);
const findingCodes = rows.map((row) => row.code).join(', '); const findingCodes = rows.map((row) => row.code).join(', ');
@@ -673,7 +637,6 @@ export class InspectionVerificationsService {
areaId, areaId,
findingIds, findingIds,
assetIds, assetIds,
operatorSource: 'area_company_relations',
}, },
}, manager); }, manager);
@@ -684,7 +647,7 @@ export class InspectionVerificationsService {
status: visit.status, status: visit.status,
plannedStartAt: visit.plannedStartAt, plannedStartAt: visit.plannedStartAt,
}, },
company: { id: companyId, code: operator.companyCode, name: companyName }, company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
findingCount: findingIds.length, findingCount: findingIds.length,
assetCount: assetIds.length, assetCount: assetIds.length,
@@ -1,18 +1,9 @@
import { IsISO8601, IsOptional, IsUUID } from 'class-validator'; import { IsISO8601, IsUUID } from 'class-validator';
export class CreateInspectionVisitDto { export class CreateInspectionVisitDto {
@IsUUID('4') @IsUUID('4')
operationalAreaId!: string; operationalAreaId!: string;
/**
* Yacimiento concreto de la Inspección. Se mantiene opcional sólo para no
* romper clientes Android anteriores, que todavía abren una Inspección a
* nivel Área; la WEB F6.1 siempre lo envía.
*/
@IsOptional()
@IsUUID('4')
scopeAssetId?: string;
@IsUUID('4') @IsUUID('4')
operatorCompanyId!: string; operatorCompanyId!: string;
@@ -1,9 +0,0 @@
import { IsUUID } from 'class-validator';
export class OpenMobileInspectionDto {
@IsUUID('4')
operationalAreaId!: string;
@IsUUID('4')
operatorCompanyId!: string;
}
@@ -1,9 +1,9 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsEmpty,
IsISO8601, IsISO8601,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
MaxLength, MaxLength,
} from 'class-validator'; } from 'class-validator';
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
objective?: string | null; objective?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' }) @IsUUID('4')
scopeAssetId?: string | null; scopeAssetId?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' }) @IsUUID('4')
operationalAreaId?: string | null; operationalAreaId?: string | null;
@IsOptional() @IsOptional()
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' }) @IsUUID('4')
operatorCompanyId?: string | null; operatorCompanyId?: string | null;
@IsOptional() @IsOptional()
@@ -61,7 +61,7 @@ export class F3FieldInventoryStructureService {
principal: AuthPrincipal, principal: AuthPrincipal,
) { ) {
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase; 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 parent = await this.parent(effectiveParentId);
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()]; const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
const data = expectedTypeCode const data = expectedTypeCode
@@ -4,25 +4,15 @@ import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import type { import type { InspectionVisitView } from './inspection-visits.service';
InspectionChecklistItemKind,
InspectionVisitView,
} from './inspection-visits.service';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
type F4ChecklistClassification = {
findingId: string;
itemKind: InspectionChecklistItemKind;
referenceOn: string | null;
};
/** /**
* Capa activa F4 sobre el servicio de Inspecciones. * Capa activa F4 sobre el servicio de Inspecciones.
* *
* El checklist persistido es una generación histórica append-only. F4 necesita * Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica
* mostrar la clasificación vigente de controles, pero una lectura nunca debe * la clasificación técnica F4 del checklist. El contrato público de Inspección ya
* reescribir esa generación. La clasificación se deriva con SELECT y se aplica * no expone título independiente ni fecha prevista de fin.
* sólo a la vista devuelta por la API.
*/ */
@Injectable() @Injectable()
export class F4InspectionVisitsService extends InspectionVisitsService { export class F4InspectionVisitsService extends InspectionVisitsService {
@@ -42,8 +32,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
} }
override async getById(id: string): Promise<InspectionVisitView> { override async getById(id: string): Promise<InspectionVisitView> {
const visit = await super.getById(id); await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await this.classifyChecklistView(visit)); return this.normalizeChecklist(await super.getById(id));
} }
override async create( override async create(
@@ -51,8 +41,9 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionVisitView> { ): Promise<InspectionVisitView> {
const visit = await super.create(dto, principal, request); const created = await super.create(dto, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit)); await this.reclassifyCurrentChecklist(created.id);
return this.normalizeChecklist(await super.getById(created.id));
} }
override async generateChecklist( override async generateChecklist(
@@ -60,106 +51,88 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
principal: AuthPrincipal, principal: AuthPrincipal,
request: RequestWithContext, request: RequestWithContext,
): Promise<InspectionVisitView> { ): Promise<InspectionVisitView> {
const visit = await super.generateChecklist(id, principal, request); await super.generateChecklist(id, principal, request);
return this.normalizeChecklist(await this.classifyChecklistView(visit)); await this.reclassifyCurrentChecklist(id);
return this.normalizeChecklist(await super.getById(id));
} }
private normalizeChecklist<T>(visit: T): T { private normalizeChecklist<T>(visit: T): T {
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit; if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
const view = visit as T & InspectionVisitView; const view = visit as T & InspectionVisitView;
const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE');
const actionableAssetIds = new Set(
items
.filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind))
.map((item) => item.asset.id),
);
view.checklist = { view.checklist = {
...view.checklist, ...view.checklist,
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
companyOverdue: 0, companyOverdue: 0,
verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length, items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'),
upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
actionableAssets: actionableAssetIds.size,
items,
}; };
return view; return view;
} }
/** private async reclassifyCurrentChecklist(visitId: string): Promise<void> {
* Deriva el estado F4 actual de los ítems sin mutar la generación persistida. await this.f4DataSource.transaction(async (manager) => {
* La fuente administrativa de empresa vive a nivel Acta; en este checklist de const [visit] = await manager.query(`
* campo sólo importan próximos controles y verificaciones no resueltas. SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt"
*/ FROM inspection_visits
private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> { WHERE id=$1
if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit; FOR UPDATE
const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))]; `, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>;
if (findingIds.length === 0) return visit; if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return;
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
const classifications = (await this.f4DataSource.query(` const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
SELECT await manager.query(`
finding.id AS "findingId", UPDATE inspection_visit_checklist_items item
CASE SET
WHEN finding.status='OPEN' item_kind = CASE
AND finding.next_control_on IS NOT NULL WHEN finding.status='OPEN'
AND finding.next_control_on < $2::date AND finding.next_control_on IS NOT NULL
AND COALESCE(( AND finding.next_control_on < $3::date
SELECT verification.outcome AND COALESCE((
FROM inspection_finding_verification_visits verification SELECT verification.outcome
WHERE verification.finding_id=finding.id FROM inspection_finding_verification_visits verification
AND verification.outcome IS NOT NULL WHERE verification.finding_id=finding.id
ORDER BY verification.result_recorded_at DESC NULLS LAST, AND verification.outcome IS NOT NULL
verification.created_at DESC, ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.id DESC verification.created_at DESC,
LIMIT 1 verification.id DESC
), '') <> 'RESOLVED' LIMIT 1
THEN 'VERIFICATION_OVERDUE' ), '') <> 'RESOLVED'
WHEN finding.status='OPEN' THEN 'VERIFICATION_OVERDUE'
AND finding.next_control_on IS NOT NULL WHEN finding.status='OPEN'
AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30) AND finding.next_control_on IS NOT NULL
AND COALESCE(( AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30)
SELECT verification.outcome AND COALESCE((
FROM inspection_finding_verification_visits verification SELECT verification.outcome
WHERE verification.finding_id=finding.id FROM inspection_finding_verification_visits verification
AND verification.outcome IS NOT NULL WHERE verification.finding_id=finding.id
ORDER BY verification.result_recorded_at DESC NULLS LAST, AND verification.outcome IS NOT NULL
verification.created_at DESC, ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.id DESC verification.created_at DESC,
LIMIT 1 verification.id DESC
), '') <> 'RESOLVED' LIMIT 1
THEN 'UPCOMING_CONTROL' ), '') <> 'RESOLVED'
ELSE 'ANTECEDENT' THEN 'UPCOMING_CONTROL'
END AS "itemKind", ELSE 'ANTECEDENT'
CASE END,
WHEN finding.status='OPEN' reference_on = CASE
AND finding.next_control_on IS NOT NULL WHEN finding.status='OPEN'
AND COALESCE(( AND finding.next_control_on IS NOT NULL
SELECT verification.outcome AND COALESCE((
FROM inspection_finding_verification_visits verification SELECT verification.outcome
WHERE verification.finding_id=finding.id FROM inspection_finding_verification_visits verification
AND verification.outcome IS NOT NULL WHERE verification.finding_id=finding.id
ORDER BY verification.result_recorded_at DESC NULLS LAST, AND verification.outcome IS NOT NULL
verification.created_at DESC, ORDER BY verification.result_recorded_at DESC NULLS LAST,
verification.id DESC verification.created_at DESC,
LIMIT 1 verification.id DESC
), '') <> 'RESOLVED' LIMIT 1
THEN finding.next_control_on::text ), '') <> 'RESOLVED'
ELSE NULL THEN finding.next_control_on
END AS "referenceOn" ELSE NULL
FROM inspection_findings finding END
WHERE finding.id=ANY($1::uuid[]) FROM inspection_findings finding
`, [findingIds, targetDate])) as F4ChecklistClassification[]; WHERE item.visit_id=$1
AND item.generation_number=$2
const byFinding = new Map(classifications.map((item) => [item.findingId, item])); AND finding.id=item.finding_id
visit.checklist = { `, [visitId, visit.generation, targetDate]);
...visit.checklist, });
items: visit.checklist.items.map((item) => {
const classification = byFinding.get(item.findingId);
return classification
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
: item;
}),
};
return visit;
} }
} }
@@ -129,7 +129,6 @@ export class FieldFindingsService {
visit.id AS "visitId", visit.id AS "visitId",
visit.code AS "visitCode", visit.code AS "visitCode",
visit.status AS "visitStatus", visit.status AS "visitStatus",
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId", visit.operational_area_id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName", area.name AS "areaName",
@@ -140,7 +139,8 @@ export class FieldFindingsService {
asset_type.code AS "assetTypeCode", asset_type.code AS "assetTypeCode",
asset.inventory_family_id AS "inventoryFamilyId", 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 ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -148,11 +148,9 @@ export class FieldFindingsService {
SELECT parent.id,parent.parent_id SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) )
SELECT 1 FROM ancestors SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1
WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
LIMIT 1
) )
) AS "insideScope", ) AS "insideArea",
EXISTS ( EXISTS (
SELECT 1 SELECT 1
FROM area_company_relations relation FROM area_company_relations relation
@@ -215,7 +213,6 @@ export class FieldFindingsService {
visitId: string; visitId: string;
visitCode: string; visitCode: string;
visitStatus: string; visitStatus: string;
scopeAssetId: string | null;
areaId: string | null; areaId: string | null;
areaCode: string | null; areaCode: string | null;
areaName: string | null; areaName: string | null;
@@ -225,7 +222,7 @@ export class FieldFindingsService {
assetId: string | null; assetId: string | null;
assetTypeCode: string | null; assetTypeCode: string | null;
inventoryFamilyId: string | null; inventoryFamilyId: string | null;
insideScope: boolean; insideArea: boolean;
operatorRelationValid: boolean; operatorRelationValid: boolean;
assigned: boolean; assigned: boolean;
selected: boolean; selected: boolean;
@@ -268,23 +265,22 @@ export class FieldFindingsService {
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
const assetTypeCode = String(row.assetTypeCode).toLowerCase(); if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID', code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación', message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
}); });
} }
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) { if (!row.inventoryFamilyId) {
throw new ConflictException({ throw new ConflictException({
code: 'FIELD_FINDING_FAMILY_REQUIRED', code: 'FIELD_FINDING_FAMILY_REQUIRED',
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', 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({ throw new BadRequestException({
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT', 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) { if (!row.selected) {
@@ -23,7 +23,6 @@ interface MobileVisitContext {
code: string; code: string;
status: string; status: string;
areaId: string; areaId: string;
scopeAssetId: string;
areaCode: string; areaCode: string;
areaName: string; areaName: string;
companyId: string; companyId: string;
@@ -81,12 +80,12 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false); const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context); if (query.parentId) await this.requireParentInContext(query.parentId, context);
// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope. // Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.scopeAssetId, visitId]; const args: unknown[] = [context.areaId, visitId];
const conditions = [ const conditions = [
"asset.information_status <> 'INACTIVE'", "asset.information_status <> 'INACTIVE'",
`( `(
asset.id = $1::uuid asset.operational_area_id = $1::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id SELECT id,parent_id FROM assets WHERE id=asset.parent_id
@@ -199,7 +198,7 @@ export class FieldInventoryService {
principal: AuthPrincipal, principal: AuthPrincipal,
) { ) {
const context = await this.requireVisitContext(visitId, principal, false); 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 parent = await this.requireParentInContext(effectiveParentId, context);
const data = await this.dataSource.query(` const data = await this.dataSource.query(`
@@ -273,7 +272,7 @@ export class FieldInventoryService {
request: RequestWithContext, request: RequestWithContext,
) { ) {
const context = await this.requireVisitContext(visitId, principal, true); 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); await this.requireParentInContext(parentId, context);
const capturedAt = new Date(dto.deviceCapturedAt); const capturedAt = new Date(dto.deviceCapturedAt);
@@ -413,7 +412,6 @@ export class FieldInventoryService {
visit.id, visit.id,
visit.code, visit.code,
visit.status, visit.status,
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
visit.operational_area_id AS "areaId", visit.operational_area_id AS "areaId",
area.code AS "areaCode", area.code AS "areaCode",
area.name AS "areaName", area.name AS "areaName",
@@ -487,7 +485,6 @@ export class FieldInventoryService {
private publicContext(context: MobileVisitContext) { private publicContext(context: MobileVisitContext) {
return { return {
inspection: { id: context.id, code: context.code, status: context.status }, inspection: { id: context.id, code: context.code, status: context.status },
scopeAssetId: context.scopeAssetId,
area: { id: context.areaId, code: context.areaCode, name: context.areaName }, area: { id: context.areaId, code: context.areaCode, name: context.areaName },
operatorCompany: { operatorCompany: {
id: context.companyId, id: context.companyId,
@@ -506,6 +503,7 @@ export class FieldInventoryService {
asset.asset_type_id AS "typeId", asset.asset_type_id AS "typeId",
( (
asset.id=$2::uuid asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id 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 FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
) )
) AS "insideScope" ) AS "insideArea"
FROM assets asset FROM assets asset
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [parentId, context.scopeAssetId])) as Array<{ `, [parentId, context.areaId])) as Array<{
id: string; id: string;
code: string; code: string;
name: string; name: string;
typeId: string; typeId: string;
insideScope: boolean; insideArea: boolean;
}>; }>;
if (!parent) { if (!parent) {
@@ -532,10 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe', message: 'La ubicación padre no existe',
}); });
} }
if (!parent.insideScope) { if (!parent.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección', message: 'La ubicación padre no pertenece al Área de la inspección',
}); });
} }
return parent; return parent;
@@ -547,7 +545,7 @@ export class FieldInventoryService {
asset.id, asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
( (
asset.id=$2::uuid asset.operational_area_id=$2::uuid
OR EXISTS ( OR EXISTS (
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id 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 FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
) )
) AS "insideScope" ) AS "insideArea"
FROM assets asset FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE' AND asset.information_status <> 'INACTIVE'
`, [assetId, context.scopeAssetId])) as Array<{ `, [assetId, context.areaId])) as Array<{
id: string; id: string;
eligibleTarget: boolean; eligibleTarget: boolean;
insideScope: boolean; insideArea: boolean;
}>; }>;
if (!asset) { if (!asset) {
@@ -573,10 +571,10 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado', message: 'Registro de Inventario no encontrado',
}); });
} }
if (!asset.insideScope) { if (!asset.insideArea) {
throw new BadRequestException({ throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección', message: 'El registro no pertenece al Área de esta inspección',
}); });
} }
if (!asset.eligibleTarget) { if (!asset.eligibleTarget) {
@@ -1,249 +0,0 @@
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
import { InspectionVisitsService } from './inspection-visits.service';
/**
* Creación WEB. La ubicación territorial es Departamento → Área → Yacimiento.
* Empresa y Tipo de concesión pertenecen directamente al Yacimiento; por eso
* la Operadora elegida debe coincidir con el Yacimiento seleccionado.
*/
@Injectable()
export class InspectionPlanningCreateService {
private readonly logger = new Logger(InspectionPlanningCreateService.name);
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly visits: InspectionVisitsService,
) {}
async create(
dto: CreateInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const scopeAssetId = dto.scopeAssetId;
if (!scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_REQUIRED',
message: 'Seleccioná el Yacimiento de la Inspección',
});
}
const plannedStartAt = new Date(dto.plannedStartAt);
if (!Number.isFinite(plannedStartAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_START_DATE_REQUIRED',
message: 'Indicá una fecha y hora prevista de inicio válida',
});
}
let stage = 'transaction:start';
try {
const visitId = await this.dataSource.transaction(async (manager) => {
stage = 'scope:validate';
await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId);
stage = 'operator:validate';
const [context] = await manager.query(`
SELECT yacimiento.id
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND yacimiento.operator_company_id=$3::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND yacimiento.concession_type_id IS NOT NULL
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, dto.operationalAreaId, dto.operatorCompanyId]) as Array<{ id: string }>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO',
message: 'La Empresa seleccionada no es la Empresa relacionada con el Yacimiento',
});
}
stage = 'inspector:validate';
const [inspector] = await manager.query(`
SELECT user_account.id
FROM users user_account
WHERE user_account.id=$1::uuid
AND user_account.status='ACTIVE'
AND EXISTS (
SELECT 1
FROM user_roles user_role
INNER JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id
INNER JOIN permissions permission ON permission.id=role_permission.permission_id
WHERE user_role.user_id=user_account.id
AND permission.code='inspections.execute'
)
LIMIT 1
`, [dto.leadInspectorUserId]) as Array<{ id: string }>;
if (!inspector) {
throw new BadRequestException({
code: 'INSPECTION_MEMBER_INVALID',
message: 'El Inspector responsable no está activo o no tiene permiso de ejecución',
});
}
stage = 'code:next';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const visit = manager.getRepository(InspectionVisit).create({
code,
objective: null,
status: InspectionVisitStatus.DRAFT,
scopeAssetId,
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
leadInspectorUserId: dto.leadInspectorUserId,
plannedStartAt,
actualStartedAt: null,
actualClosedAt: null,
instructions: null,
cancellationReason: null,
checklistGeneration: 1,
checklistGeneratedAt: new Date(),
createdBy: principal.userId,
updatedBy: principal.userId,
});
stage = 'visit:save';
await manager.getRepository(InspectionVisit).save(visit);
stage = 'member:insert';
await manager.query(`
INSERT INTO inspection_visit_members (visit_id,user_id,included,assigned_by)
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
stage = 'checklist:insert';
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
visit_id,generation_number,finding_id,asset_id,item_kind,
reference_on,finding_status,finding_code,finding_title,severity
)
SELECT
$1::uuid,1,finding.id,finding.asset_id,
CASE
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE'
WHEN finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
CASE WHEN finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END,
finding.status,finding.code,finding.title,finding.severity
FROM inspection_findings finding
INNER JOIN assets inventory ON inventory.id=finding.asset_id
WHERE inventory.operational_area_id=$2::uuid
AND finding.status<>'VOIDED'
AND EXISTS (
WITH RECURSIVE ancestors AS (
SELECT current_asset.id,current_asset.parent_id
FROM assets current_asset WHERE current_asset.id=finding.asset_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent INNER JOIN ancestors child ON child.parent_id=parent.id
)
SELECT 1 FROM ancestors WHERE id=$3::uuid LIMIT 1
)
ORDER BY finding.created_at,finding.id
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
stage = 'checklist:assets';
await manager.query(`
INSERT INTO inspection_visit_assets (
visit_id, asset_id, included, planning_source, added_by
)
SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
ON CONFLICT (visit_id,asset_id) DO UPDATE SET
included=true,
planning_source=CASE
WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'
ELSE 'AUTOMATIC'
END,
exclusion_reason=NULL,
excluded_by=NULL,
excluded_at=NULL,
added_by=EXCLUDED.added_by,
updated_at=CURRENT_TIMESTAMP
`, [visit.id, principal.userId]);
stage = 'checklist:asset-events';
await manager.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, reason, actor_user_id, metadata
)
SELECT DISTINCT
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
jsonb_build_object('generation',1,'source','AUTHORITATIVE_CREATE')
FROM inspection_visit_checklist_items item
WHERE item.visit_id=$1::uuid
AND item.generation_number=1
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
AND NOT EXISTS (
SELECT 1 FROM inspection_visit_asset_events event
WHERE event.visit_id=$1::uuid
AND event.asset_id=item.asset_id
AND event.event_type='AUTO_INCLUDED'
)
`, [visit.id, principal.userId]);
stage = 'audit:record';
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_CREATED,
entityType: 'inspection_visit',
entityId: visit.id,
afterData: {
id: visit.id,
code,
status: visit.status,
departmentHierarchy: 'Departamento → Área → Yacimiento',
operationalAreaId: dto.operationalAreaId,
scopeAssetId,
operatorCompanyId: dto.operatorCompanyId,
operatorSource: 'YACIMIENTO',
plannedStartAt: plannedStartAt.toISOString(),
leadInspectorUserId: dto.leadInspectorUserId,
},
}, manager);
return visit.id;
});
stage = 'view:load';
return await this.visits.getById(visitId);
} catch (error) {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'INSPECTION_CODE_ALREADY_EXISTS',
message: 'El identificador de Inspección ya existe; volvé a intentar',
});
}
const technical = error instanceof Error
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
: String(error);
this.logger.error(`inspection create failed at ${stage}: ${technical}`);
throw error;
}
}
}
@@ -1,116 +0,0 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface InspectionPlanningHierarchyItem {
id: string;
code: string;
name: string;
}
@Injectable()
export class InspectionPlanningHierarchyService {
constructor(private readonly dataSource: DataSource) {}
async departments(): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
const data = await this.dataSource.query(`
SELECT department.id, department.code, department.name
FROM assets department
INNER JOIN asset_types type ON type.id=department.asset_type_id
WHERE lower(type.code)='departamento'
AND type.is_active=true
AND department.information_status<>'INACTIVE'
AND EXISTS (
SELECT 1
FROM assets area
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
WHERE area.parent_id=department.id
AND lower(area_type.code)='area'
AND area.information_status<>'INACTIVE'
)
ORDER BY department.name, department.code
`) as InspectionPlanningHierarchyItem[];
return { data };
}
async areasForDepartment(departmentId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(departmentId, 'departamento', 'El Departamento seleccionado no es válido');
const data = await this.dataSource.query(`
SELECT area.id, area.code, area.name
FROM assets area
INNER JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.parent_id=$1::uuid
AND lower(type.code)='area'
AND type.is_active=true
AND area.information_status<>'INACTIVE'
ORDER BY area.name, area.code
`, [departmentId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async yacimientosForArea(areaId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
const data = await this.dataSource.query(`
SELECT yacimiento.id, yacimiento.code, yacimiento.name
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=$1::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
ORDER BY yacimiento.name, yacimiento.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
return { data };
}
async operatorsForArea(
areaId: string,
at?: string,
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
if (at) {
const effectiveAt = new Date(at);
if (!Number.isFinite(effectiveAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_PLANNING_DATE_INVALID',
message: 'La fecha de planificación no es válida',
});
}
}
// Modelo autoritativo: la Empresa pertenece al Yacimiento. Para mantener el
// selector de planificación por Área, se proyectan las Empresas de todos los
// Yacimientos activos que pertenecen a esa Área.
const data = await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
FROM assets yacimiento
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE yacimiento.parent_id=$1::uuid
AND lower(yacimiento_type.code)='yacimiento'
AND yacimiento_type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
AND company.information_status<>'INACTIVE'
ORDER BY name, company.code
`, [areaId]) as InspectionPlanningHierarchyItem[];
return { data };
}
private async requireType(id: string, typeCode: string, message: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1
FROM assets asset
INNER JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid
AND lower(type.code)=lower($2)
AND type.is_active=true
AND asset.information_status<>'INACTIVE'
LIMIT 1
`, [id, typeCode]) as Array<{ '?column?': number }>;
if (!row) {
throw new BadRequestException({ code: 'INSPECTION_PLANNING_HIERARCHY_INVALID', message });
}
}
}
@@ -1,35 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import type { EntityManager } from 'typeorm';
/**
* F6.1 keeps territorial levels explicit. operationalAreaId freezes the Area
* context while scopeAssetId identifies the Yacimiento inspected inside it.
* Legacy/mobile callers may still use the Area itself as scope until Android
* supplies a Yacimiento explicitly.
*/
export async function validateInspectionPlanningScope(
manager: EntityManager,
operationalAreaId: string,
scopeAssetId: string,
): Promise<void> {
if (scopeAssetId === operationalAreaId) return;
const [scope] = await manager.query(`
SELECT 1
FROM assets yacimiento
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
WHERE yacimiento.id=$1::uuid
AND yacimiento.parent_id=$2::uuid
AND lower(type.code)='yacimiento'
AND type.is_active=true
AND yacimiento.information_status<>'INACTIVE'
LIMIT 1
`, [scopeAssetId, operationalAreaId]) as Array<{ '?column?': number }>;
if (!scope) {
throw new BadRequestException({
code: 'INSPECTION_YACIMIENTO_OUTSIDE_AREA',
message: 'El Yacimiento seleccionado no pertenece al Área indicada',
});
}
}
@@ -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 };
}
}
@@ -295,13 +295,11 @@ export class InspectionVisitLifecycleService {
} }
} }
// El contexto operativo de una Inspección es Área + Operadora vigente. El
// Yacimiento pertenece al alcance físico y nunca reemplaza al Área raíz.
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> { private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) { if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_PLANNING_INCOMPLETE', code: 'INSPECTION_PLANNING_INCOMPLETE',
message: 'Definí Área, Operadora, Inspector responsable y fecha de inicio antes de planificar', message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
}); });
} }
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) { if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
@@ -322,7 +320,7 @@ export class InspectionVisitLifecycleService {
if (!relation) { if (!relation) {
throw new ConflictException({ throw new ConflictException({
code: 'INSPECTION_OPERATOR_RELATION_INVALID', code: 'INSPECTION_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área', message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
}); });
} }
const [inspector] = await manager.query(` const [inspector] = await manager.query(`
@@ -13,18 +13,14 @@ import {
import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@@ -33,8 +29,6 @@ export class InspectionVisitsController {
constructor( constructor(
private readonly visits: InspectionVisitsService, private readonly visits: InspectionVisitsService,
private readonly lifecycle: InspectionVisitLifecycleService, private readonly lifecycle: InspectionVisitLifecycleService,
private readonly planningHierarchy: InspectionPlanningHierarchyService,
private readonly planningCreate: InspectionPlanningCreateService,
) {} ) {}
@Get() @Get()
@@ -49,29 +43,6 @@ export class InspectionVisitsController {
return this.visits.listAssignees(); return this.visits.listAssignees();
} }
@Get('planning-context/departments')
@RequirePermissions('inspections.read')
planningDepartments() {
return this.planningHierarchy.departments();
}
@Get('planning-context/departments/:departmentId/areas')
@RequirePermissions('inspections.read')
planningAreasForDepartment(
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
) {
return this.planningHierarchy.areasForDepartment(departmentId);
}
@Get('planning-context/areas/:areaId/yacimientos')
@RequirePermissions('inspections.read')
planningYacimientos(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
) {
return this.planningHierarchy.yacimientosForArea(areaId);
}
/** Compatibilidad con clientes anteriores: lista todas las Áreas. */
@Get('planning-context/areas') @Get('planning-context/areas')
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
planningAreas() { planningAreas() {
@@ -82,48 +53,10 @@ export class InspectionVisitsController {
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
planningOperators( planningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@Query('at') at?: string,
) { ) {
return this.planningHierarchy.operatorsForArea(areaId, at);
}
@Get('mobile/planning-context/areas')
@RequirePermissions('inspections.execute')
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
assertMobileInspector(principal);
return this.visits.listPlanningAreas();
}
@Get('mobile/planning-context/areas/:areaId/operators')
@RequirePermissions('inspections.execute')
mobilePlanningOperators(
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
@CurrentAuth() principal: AuthPrincipal,
) {
assertMobileInspector(principal);
return this.visits.listPlanningOperators(areaId); return this.visits.listPlanningOperators(areaId);
} }
@Post('mobile/open')
@RequirePermissions('inspections.execute')
async openMobile(
@Body() dto: OpenMobileInspectionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
assertMobileInspector(principal);
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
const created = await this.visits.create({
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
plannedStartAt: new Date().toISOString(),
leadInspectorUserId: principal.userId,
}, principal, request);
await this.lifecycle.plan(created.id, principal, request);
return this.lifecycle.start(created.id, principal, request);
}
@Get(':id') @Get(':id')
@RequirePermissions('inspections.read') @RequirePermissions('inspections.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -137,7 +70,7 @@ export class InspectionVisitsController {
@CurrentAuth() principal: AuthPrincipal, @CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext, @Req() request: RequestWithContext,
) { ) {
return this.planningCreate.create(dto, principal, request); return this.visits.create(dto, principal, request);
} }
@Patch(':id') @Patch(':id')
@@ -8,27 +8,15 @@ import { FieldFindingsController } from './field-findings.controller';
import { FieldFindingsService } from './field-findings.service'; import { FieldFindingsService } from './field-findings.service';
import { FieldInventoryController } from './field-inventory.controller'; import { FieldInventoryController } from './field-inventory.controller';
import { FieldInventoryService } from './field-inventory.service'; import { FieldInventoryService } from './field-inventory.service';
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsController } from './inspection-visits.controller';
import { InspectionVisitsService } from './inspection-visits.service'; import { InspectionVisitsService } from './inspection-visits.service';
@Module({ @Module({
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule], imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
controllers: [ controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
InspectionVisitsController,
InspectionPreventiveCandidatesController,
FieldInventoryController,
FieldFindingsController,
],
providers: [ providers: [
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService }, { provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
InspectionPlanningCreateService,
InspectionPlanningHierarchyService,
InspectionPreventiveCandidatesService,
InspectionVisitLifecycleService, InspectionVisitLifecycleService,
FieldInventoryService, FieldInventoryService,
F3FieldInventoryStructureService, 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 { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { nextInspectionVisitCode } from './inspection-visit-code'; import { nextInspectionVisitCode } from './inspection-visit-code';
import { validateInspectionPlanningScope } from './inspection-planning-scope';
export interface InspectionPerson { export interface InspectionPerson {
id: string; id: string;
@@ -313,7 +312,7 @@ export class InspectionVisitsService {
} }
const operationalAreaId = dto.operationalAreaId; const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId; const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt); await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
await this.requireAsset(manager, operationalAreaId); await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]); await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt); const code = await nextInspectionVisitCode(manager, plannedStartAt);
@@ -383,24 +382,29 @@ export class InspectionVisitsService {
? visit.operatorCompanyId ? visit.operatorCompanyId
: dto.operatorCompanyId; : dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined; const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId; const nextScope = contextTouched
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart); ? nextAreaId
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
await this.requireAsset(manager, nextScope); await this.requireAsset(manager, nextScope);
if (nextAreaId && nextScope) { if (contextTouched && nextAreaId && nextCompanyId) {
await validateInspectionPlanningScope(manager, nextAreaId, nextScope); await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
} else if (dto.scopeAssetId !== undefined) {
await this.assertCurrentAssetsInScope(manager, id, nextScope); await this.assertCurrentAssetsInScope(manager, id, nextScope);
} }
if (dto.objective !== undefined) visit.objective = dto.objective; if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) { if (contextTouched) {
visit.operationalAreaId = nextAreaId; visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId; visit.operatorCompanyId = nextCompanyId;
} visit.scopeAssetId = nextAreaId;
if (dto.scopeAssetId !== undefined) { } else if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId; visit.scopeAssetId = dto.scopeAssetId;
} }
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart; if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions; 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; visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit); await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id); const updated = await this.loadView(manager, id);
@@ -459,10 +463,11 @@ export class InspectionVisitsService {
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos', message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
}); });
} }
await this.assertAssetsInScope( await this.assertAssetsMatchContext(
manager, manager,
additions, additions,
visit.scopeAssetId ?? visit.operationalAreaId, visit.operationalAreaId,
visit.operatorCompanyId,
); );
for (const assetId of additions) { for (const assetId of additions) {
await manager.query(` await manager.query(`
@@ -624,10 +629,11 @@ export class InspectionVisitsService {
if (!visit.operationalAreaId || !visit.operatorCompanyId) { if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' }); throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
} }
await this.assertAssetsInScope( await this.assertAssetsMatchContext(
manager, manager,
[assetId], [assetId],
visit.scopeAssetId ?? visit.operationalAreaId, visit.operationalAreaId,
visit.operatorCompanyId,
); );
await manager.query(` await manager.query(`
UPDATE inspection_visit_assets UPDATE inspection_visit_assets
@@ -914,7 +920,6 @@ export class InspectionVisitsService {
manager: EntityManager, manager: EntityManager,
operationalAreaId: string | null, operationalAreaId: string | null,
operatorCompanyId: string | null, operatorCompanyId: string | null,
effectiveAt: Date | null = null,
): Promise<void> { ): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return; if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) { if (!operationalAreaId || !operatorCompanyId) {
@@ -933,8 +938,8 @@ export class InspectionVisitsService {
WHERE relation.area_id = area.id WHERE relation.area_id = area.id
AND relation.company_id = company.id AND relation.company_id = company.id
AND relation.relation_role = 'OPERATOR' AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= $3::timestamptz AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz) AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
) AS "activeOperatorRelation" ) AS "activeOperatorRelation"
FROM assets area FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
@@ -946,7 +951,7 @@ export class InspectionVisitsService {
AND company_type.operational_role = 'COMPANY' AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE' AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE' AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{ `, [operationalAreaId, operatorCompanyId])) as Array<{
areaId: string; areaId: string;
companyId: string; companyId: string;
activeOperatorRelation: boolean; 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( private async appendAssetEvent(
manager: EntityManager, manager: EntityManager,
visitId: string, visitId: string,
@@ -998,7 +1041,7 @@ export class InspectionVisitsService {
message: 'Indicá la fecha prevista antes de generar el checklist', 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; const generation = visit.checklistGeneration + 1;
await manager.query(` await manager.query(`
INSERT INTO inspection_visit_checklist_items ( INSERT INTO inspection_visit_checklist_items (
@@ -1063,25 +1106,14 @@ export class InspectionVisitsService {
FROM inspection_findings finding FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3 WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED' 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 ORDER BY finding.created_at, finding.id
`, [ `, [
visit.id, visit.id,
generation, generation,
visit.operationalAreaId, visit.operationalAreaId,
visit.scopeAssetId ?? visit.operationalAreaId, visit.operatorCompanyId,
visit.plannedStartAt.toISOString().slice(0, 10), 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', message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
}); });
} }
await this.validatePlanningContext( await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
manager,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt,
);
if (!visit.scopeAssetId) { if (!visit.scopeAssetId) {
throw new BadRequestException({ throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED', code: 'INSPECTION_SCOPE_REQUIRED',
@@ -1323,10 +1350,13 @@ export class InspectionVisitsService {
message: 'El equipo debe incluir al inspector responsable', 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.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[]> { 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,30 +0,0 @@
import { gunzipSync } from 'node:zlib';
export interface F61PresentationTerritoryRow {
sourceRow: number;
yacimiento: string;
area: string;
departamento: string;
tipoConcesion: string;
empresaOperadora: string;
}
/**
* F6.1 · fuente única del corte de presentación.
* Archivo: Tablas de yacimiento y areas(1).xlsx
* Hoja: cr26e_tabla1
* SHA-256: afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb
*
* Las 230 filas se guardan comprimidas para versionar exactamente la fuente
* aprobada sin depender del binario Excel durante CI o deploy.
*/
const SNAPSHOT_GZIP_BASE64 = 'H4sIADTzoWoC/+2d23IiuRmAX0Xli1RSNeOiwZzmrmFY21OAWfBStZvNhdzIWI6QvN0tZ00qj5CLPMI+wtbepXLnF4vU+NA6AbPLINXgOyNh+PpH0n/Qr19//edRxniaoDH7x9GH6rujB5jgBUY0Z0cfjrrw8Vc4g2DGU3j07gimCIrWKV4mMLlBKcxE4wzdwTSHi6d/mUAKujAlTPbl+I51GU1QhhkVnb2f7wjLxTc8/kZFN1rcpSiDF3fio2YslZ89Qnn6+BtBLAMTPhOfmuIEUvGqR1E6fwCT4/j46F/vFOqaRj3iNBfMCBCYgQ5MZwXnE3wfgnvxPkihyT7G9+Jp7zHcG/qJhi5oU0iTMnC5SePt89vHX6h80i5/YHuDrmvQ380FFBIDImTohgYdzzkEHfE9s9LIVhs18AEkMJ0//g/9LubxRffMCtbUwYj4cAIpTjASUiJgBB84LTG6+neK+91lD/z52+/Oh5fxMAa9P41APD7tDS/PxavJ8fi4f/wOvHYPe+PT78H5cNqbXA7EuybFk74Dp7H46/JiHJ/2QDz8CAbnHyeX4148AJPeeHre7T2/sXsxGMWP/3n8dwwuJ52i8S+6oFr64oTSlIE+5nc8gW/y+dC2ymeI5il7k86HqOIWj1hF0jcRfYgifSkSD4uznzguCafU5F17RlUXsPaLBgVdM4ysNMVLSKRKjK/gLQsVXDdWegR8y9FCfGaoxLql0meFQShQCc5ZFip2wzBoUZYzEJNrmAYra92OucRigZUGONEM2rCwW+uxCwUwQDMcrNzbGx5gyIRxHih8tWIs4FzI20CWjQVBuiJ4eYfF+RzDa4iI6xHSdY8wwRT0Vs3gtV1njqzMmsJ5anqlZnTnuGcwz4WQ/16W8MSUcdWyCI5Yoi+AQcm4Zl8BB1h4lYrDFhb2iXs4g5cZxKjOr71hBfHZ/Gtn5yWkc7Z2KtZdoQhwIWQftG9fbTjZlWkZILmuNfucEhm+ev9sFQZN3/o83RPgExhOswxs8ry0xJRads88RZRhYo9qGg6rcA2KbyPimbrwChJSXsFFt1z+zP79hLFqkQt3wAimMBzQqjUQ0GEEwRkq29e+QWuG4l6IOTVCRFhpYlQri7JvVl3zjTFNxM8fL2CKBUk4oHVjmJIFTxTA54bPJfrjBkStYd2AcQhSdklZlvq+9hBVrWmdvE+jaYhn6op4iBJqmVGa7g0W1t/b4Gmbohnh5AbSspl1kKI5qbhFo9rQhyke3bjpQ9BHyQ1fwkOXjG5NjaB4WpYevFyMCIZ0hZbC2z/0VfjEYSoWPuPBC6duF44wcHpZAgmjBz+xDBMZXQtPn5ZHzmuL/wSfpqk5xC8p1gGsBBlCQm7Zg6+jFKM8VDHbkx8+MjFDwCcOlWlj6dq/q1c3tj5ojhMZKSFAOvogzlN4Lz5iZqB/I1QJFwLZ+XT/fvSNPeErskq3zOEZsLoeUA9h+8KsbcBULW1PkCeOvRdIM6FjAhiNuo6cMiIWCDCBMu1zIDRhAIwN6y89YEKAqR4WL/ftGnDUHdsBm/ZfecxuzR0fL4CtdRIMAdBQOTdQOMw3HFFtHuvtexmBjYqJJzSe+HdwJjdp1EiZrU/DvOR3nM5hzr7M/mPD0DE3+Ibj2Sq4B8Xs1nYRHN1fu/3bqG4jJ9AVvTK98U1gjdrnCGzyJrDGyVYC05XYoUqrvpW0NI1woLIy8zRuGZD5SH12peojtwy/eiE1dzSgDkVerW3lVYTQ3oRWCM22FSYWdEb1vIIDFlLTsin2A1RDjYcsnsgZq+4ymWP/JqZCTNUNYnpbyl9EZY+PSQNhgAjBy3U2QpF6WJzngvvysZu6pfwRZdeYFGlkoAMfWFi09Q20G5Z+H8gNcwUesDQwuTYtuhTiDNLAOHVD6ZTDmZL5EgBj29wu6zKiboL4x2xVLFkPcmQGhhnZg7xnKKWQztAyLNqqY+OBrY/8+UB1nE8ZQcIWYc36lhHNGYLp+TmIS5SvLTvdB3UiGSGT4ftpGad4tVOU7Y5KtYxdG0avCUc0wbqVZnTs9MfcGC1v6Tqnw8UCNEbkni9eKaW2hHQJ6c73vdacW2i1DDND6EOU4/I+sR+ytuUk3IDNWY4yz2jtigVtta/pn03XIgPpkVzeIOydzHCtIP2Jizd3fQ+1ds1w+hg4lYcXkW8y44yicLGE7d9lUlehxLvk6na+lRNIecK8jzqbU/JC4ZWs6STTd8z98LXcfIp69UNni4nCTD1144UsqhjqAXPQ5UQYxt7RIssptefia73sTuiKzDujw7MQzuW3Ql1A73w1ZzRe22V96rCeEP8KSzZZKs5MWEaRUnJGbdztsXlX8k9UqZtl28QiphyyOmVX0tEWHwXiB0h27vKNCM/uUJ4y17CyJ6GdwQVMjP1777D2k31nHFJOAsJsrcFUlZh3VIs+67I0YVcBjdGo4oQMbYQa5dE+QfldghdmhcYbYTRLoRZF905d3Y46qKEb1bZaDOyPsndYXUt9//JCDOWzCUAUdBHoIIqucSL8wE/dUXDPUDenoaX6XyCwDbfAe93hSt5FNl/YMm+aj3EMBBs3Z2fxSB0U5GO0fscCEwh62z2QhG8QE8Eylzs7AYu/am5FLe4gmKK0HGoKhNVyjLqjJIq8Nuwlhz8yi6MRGYzQI3V7x6q5HNZM/LJzrZ75ftFcx4P4469s6ZFL12DS8/tGlpvCWTmouXeuhjnmi21CddS/NO3JfzUKj8kA9RmjMxZqqfzIqDZmMVBCQ247LlT4qFyoEBh1zeKTdViOCCmXLgwNOnJWJ9YdydDIqyb5mGUw4KlolCaTChN04K0c26vif+Gyn5hpUBQmzChAERp33VQk0xe+QJkbTmbw41H841G45E03eeFclhPnQmNvudnV4FRo4G03eNhruFE/q0SuOL6hcZseWSbMwHSB5+FemhQZRaf6mBZZzGXP9hnd0rVTy3+71LLIKAhlgBlDPBTyk43k6hAPhbtu8e1lQlV5j05p249HaFQwKnbMu+LHl6n4il4xOvZfpyY6sW/MxUQ4XkINUg13lek6sB5o/YPObO94cNw7tsu05cxCeGYJALJtD50QmYaArlIly90jZ71iE6bwSj7ie5xpRZKLPqT1+Zju9ciSQnnJ0plM4DFKmtg79QLfMi6z/IJzy6hbNJDv12K3SttOi3L8vjt1arZ00Oc6vMS2MJTe8dT7+AvdX6zLqGgkr/kTjmqcIvGwpTk3ghlT74103SbxZXL1I6O0UakoWAEkA/i5mpHmH9pIM+FpikFXCXH5p7TkRsYELrTMQ/+cFudtNVCVgoH+OQ1lZhAFRNuobKTVzW//zM7z1U8jNyRWswzwA6e60hWNj/+lLm37hY/MREYhoJV3kskDhkwaX+UBq3e972V5ypNcLGrgnOYoXaAZhrtWyOuSRBuWjTeCSlr25bUHJ8GoguM+aWjp2dMYddTkE99G9GtSlaOHX2YubSnYpvUKw8dfy8cgwqFtbT4XGw5s2wWrRUmDITYKktjJgsOOtqv4HQ6w47zAiC2Z9GMSual4kSR4JoOixHgC9/t2qi22fJja5ofZ8AQp/iL8m1W2UWBDK/SpQhe1S/cWxms6VF48Y1fIN1tjTXVX32xNW0JMJpCIoiWo9HNhYe2ecbFgWEMGe1kNWs6rHEBxuQ7DP3EUJroZcdTZIA2S3KiwIWM2soAbozNcjpOO0TWfYwbKS9Fug2Q3DFH8Mzgl7EoYimO0wswc1wlH2zsZoaEbp6lXfOEC65qt/OWBIp+4kHVfOTTwuvVqWx1aABcdu/ZCndrEKNkxLmRG+UN5YVMbA7h0XNeBT5Vh4qV+iMzs8ODdGyU9nqikmhiie8WAtHX5QG7b9McMgTjF5TV4CglBBa1SoWE/EYn22jtAZAzvHquXvPrFdV7xekFgrtTF9Mtpvw3k+UZlnAcDaj/mttrxDwbS7oPZ9oL9ctY3cKoLq19WeyhSeBOnMJzRaU/8GImvxuGMTnO7bM7pSv+MMLsNhrNty6mISXlPzydgtVJxAWrRUL+UkZMymNldNcqNOO/z8otZs20tiDkuFGQWCuOJhbEL72E4hHUnYVATR9c4q22v4l7JHFKcEchCQW06UAkYoBkOBrNlPXg7Et+m3CfvF7K9RR06r4RGAZBXhoCWdKP2hzyFW8D0WSgWcNUo9VFAGr+20rjbFLzTh1NwIRMYUHqPE/RUOMoErTmORfaJ8HiXalLeFC8TmNyIL8nsvMKvW/nI+wneVKMTS4FTeaBzqXjAwWHX7ZZysf2C74OWeMNySuUTp7kSyQmO2jhijVZRq76sKhEyeMuyJE9Qskoxk+YNS1NIkJFUHd6T6OrvB6mjSxv2L68/Nyb8x0OUVaNyR59TIgf1++dIlWZAdkSzPK1iEW6f3z7+srpThD/sLxm8Wo0cG/TyY/X7JCxd+1GMRpWP7hCcn58bV9iprfuPWleNuh/PBY01Ur3ZB6quBTuMIC0eWG7ygWhzzUZiCVpRZOppELXDB27DfaGscdmi0eMDuOkw41b2L01Wnx+qcqja1NwUo9twT75WjYIipbTkbsqXthOwAaoNo8KIOjhCHTFGjZEpFGIFA47SPKRB87f/A/kcwxXWpwAA';
export const F61_PRESENTATION_TERRITORY_SOURCE = {
file: 'Tablas de yacimiento y areas(1).xlsx',
sheet: 'cr26e_tabla1',
sha256: 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb',
rows: JSON.parse(
gunzipSync(Buffer.from(SNAPSHOT_GZIP_BASE64, 'base64')).toString('utf8'),
) as F61PresentationTerritoryRow[],
} as const;
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.29.0-1'; export const API_VERSION = '0.28.0-1';
export const API_PHASE = 'F6.1'; export const API_PHASE = 'F6';
@@ -33,15 +33,13 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
}); });
test('D5.3/F6.1 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => { test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts'); const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/); assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/); assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /Ending an operator relation never moves, rewrites or blocks existing Inventory/); assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
assert.match(service, /operatorSnapshotPreserved:true/);
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/); assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/); assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.doesNotMatch(service, /UPDATE assets asset[\s\S]*SET operator_company_id=/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
}); });
@@ -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));
}
});
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version'; import { API_PHASE, API_VERSION } from '../../src/version';
test('health metadata reports the current F6.1 release', () => { test('health metadata reports the current F6 release', () => {
assert.equal(API_PHASE, 'F6.1'); assert.equal(API_PHASE, 'F6');
assert.equal(API_VERSION, '0.29.0-1'); assert.equal(API_VERSION, '0.28.0-1');
}); });
@@ -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/); 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 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(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/);
assert.match(structure, /dto\.kind === 'YACIMIENTO'/); assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
assert.match(structure, /INVENTORY_YACIMIENTO_COMPANY_REQUIRED/); assert.match(structure, /operator_company_id,inventory_family_id/);
assert.match(structure, /INVENTORY_YACIMIENTO_CONCESSION_REQUIRED/);
assert.match(structure, /asset_context_history/); assert.match(structure, /asset_context_history/);
assert.match(dto, /operatorCompanyId\?: string \| null/); assert.match(structure, /operational_area_id,operator_company_id/);
assert.match(dto, /concessionTypeId\?: string \| null/);
}); });
test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => { 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/); assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/);
}); });
test('F7 Inventory Configuration exposes only simple types, containment and fields', () => { test('F5.1/F6 Configuration manages findings inline and filters Subinstallations by compatible Installation classifications', () => {
const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx'); const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx');
assert.match(configPage, /Tipos y campos/); assert.match(configPage, /replaceInventoryFamilyFindings/);
assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/); assert.match(configPage, /Agregar o quitar/);
assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/); assert.match(configPage, /familyFindingIds/);
assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/); assert.match(configPage, /selectedInstallationFamilyId/);
assert.match(configPage, /Tipos de Instalación/); assert.match(configPage, /family\.parentFamilyIds\.includes\(selectedInstallationFamilyId\)/);
assert.match(configPage, /Tipos de Subinstalación/); assert.match(configPage, /Filtrar por tipo de Instalació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/);
}); });
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 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, /kind: 'DEPARTAMENTO'/);
assert.match(createPage, /AREA: 'Departamento'/); assert.match(createPage, /AREA:'Departamento'/);
assert.match(createPage, /YACIMIENTO: 'Área'/); assert.match(createPage, /Datos opcionales/);
assert.match(createPage, /INSTALACION: 'Yacimiento'/); assert.match(configPage, /<strong>Departamento<\/strong>/);
assert.match(createPage, /SUBINSTALACION: 'Instalación'/); assert.match(configPage, /familyId=\$\{familyEditor\.id\}/);
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/);
}); });
+4 -4
View File
@@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string {
return readFileSync(resolve(process.cwd(), '..', path), 'utf8'); return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
} }
test('F8 Android QA targets production API and has a distinct installable debug version', () => { test('F6 Android test cut targets production API and has a distinct installable debug version', () => {
const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
assert.match(gradle, /versionCode = 29/); assert.match(gradle, /versionCode = 21/);
assert.match(gradle, /versionName = "0\.20\.0"/); assert.match(gradle, /versionName = "0\.14\.0"/);
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
assert.match(gradle, /applicationIdSuffix = "\.debug"/); assert.match(gradle, /applicationIdSuffix = "\.debug"/);
}); });
test('F5/F6.3 field inventory exposes Other families as reviewable choices to Android', () => { test('F5/F6 field inventory exposes Other families as reviewable choices to Android', () => {
const service = readFileSync( const service = readFileSync(
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'), resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
'utf8', 'utf8',
@@ -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 mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8'); const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8'); const 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, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
assert.doesNotMatch(mergeSource, /misma Área y Operadora/); assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
assert.match(fieldSource, /Yacimiento definido como alcance de esta inspección/); assert.match(fieldSource, /Área de esta inspección/);
assert.match(fieldSource, /scopeAssetId/);
assert.match(fieldSource, /area_company_relations/); assert.match(fieldSource, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/); assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
@@ -1,67 +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.1 allows direct findings only on Yacimiento, Instalacion and Subinstalacion', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
assert.match(findings, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/);
assert.match(findings, /FIELD_FINDING_TARGET_LEVEL_INVALID/);
assert.match(findings, /Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación/);
assert.match(findings, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/);
assert.match(findings, /FIELD_FINDING_FAMILY_REQUIRED/);
});
test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-another everywhere', () => {
const findings = source('src/inspection-visits/field-findings.service.ts');
const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
const catalog = source('src/inspection-findings/finding-catalog.service.ts');
const androidFinding = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt');
assert.match(findings, /canAddAnother: true/);
assert.match(resolver, /typeCode\.toLowerCase\(\) === 'yacimiento'/);
assert.match(resolver, /contextualCatalog\.listApplicableForAsset\(assetId, query\)/);
assert.match(resolver, /catalogSource: 'ASSET_TYPE'/);
assert.match(catalog, /finding_catalog_item_asset_types mapping/);
assert.match(catalog, /finding_catalog_asset_overrides override/);
assert.match(resolver, /code: 'OTHER'/);
assert.match(resolver, /label: 'OTROS'/);
assert.match(androidFinding, /OTROS · No está en el catálogo/);
assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/);
});
test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => {
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts');
assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/);
assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/);
assert.match(controller, /@Post\('mobile\/open'\)/);
assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/);
assert.match(controller, /assertMobileInspector\(principal\)/);
assert.match(controller, /leadInspectorUserId: principal\.userId/);
assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/);
assert.match(controller, /this\.lifecycle\.plan\(created\.id/);
assert.match(controller, /this\.lifecycle\.start\(created\.id/);
assert.match(dto, /operationalAreaId!: string/);
assert.match(dto, /operatorCompanyId!: string/);
});
test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => {
const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt');
const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt');
const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt');
assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/);
assert.match(client, /inspection-visits\/mobile\/open/);
assert.match(home, /Abrir inspección/);
assert.match(home, /Abrir inspección ahora/);
assert.match(home, /Operadora vigente/);
assert.match(home, /model\.openVisit\(opened\.id\)/);
assert.match(gate, /else -> MobileHomeScreen\(model\)/);
});
@@ -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';
function source(path: string): string {
return readFileSync(resolve(process.cwd(), path), 'utf8');
}
test('F6.1 inspection planning exposes Departamento → Área → Yacimiento explicitly', () => {
const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts');
const controller = source('src/inspection-visits/inspection-visits.controller.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(hierarchy, /lower\(type\.code\)='departamento'/);
assert.match(hierarchy, /area\.parent_id=\$1::uuid/);
assert.match(hierarchy, /lower\(type\.code\)='yacimiento'/);
assert.match(hierarchy, /yacimiento\.parent_id=\$1::uuid/);
assert.match(controller, /planning-context\/departments/);
assert.match(controller, /planning-context\/departments\/:departmentId\/areas/);
assert.match(controller, /planning-context\/areas\/:areaId\/yacimientos/);
assert.match(createPage, /Departamento → Área → Yacimiento/);
assert.match(createPage, /<span>Departamento<\/span>/);
assert.match(createPage, /<span>Área<\/span>/);
assert.match(createPage, /<span>Yacimiento<\/span>/);
assert.doesNotMatch(createPage, /Área \/ Yacimiento/);
});
test('Authoritative WEB creation freezes Area and validates Company against selected Yacimiento', () => {
const dto = source('src/inspection-visits/dto/create-inspection-visit.dto.ts');
const scope = source('src/inspection-visits/inspection-planning-scope.ts');
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
const createPage = source('../web-v2/src/pages/InspectionVisitCreateF61Page.tsx');
assert.match(dto, /scopeAssetId\?: string/);
assert.match(scope, /yacimiento\.parent_id=\$2::uuid/);
assert.match(scope, /lower\(type\.code\)='yacimiento'/);
assert.match(create, /scopeAssetId,/);
assert.match(create, /operationalAreaId: dto\.operationalAreaId/);
assert.match(create, /yacimiento\.operator_company_id=\$3::uuid/);
assert.match(create, /yacimiento\.concession_type_id IS NOT NULL/);
assert.match(create, /INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO/);
assert.match(createPage, /scopeAssetId: yacimientoId/);
});
test('Authoritative WEB inspection creation uses Yacimiento context and not descendant Inventory snapshots', () => {
const create = source('src/inspection-visits/inspection-planning-create.service.ts');
assert.match(create, /yacimiento\.operator_company_id/);
assert.match(create, /operatorSource: 'YACIMIENTO'/);
assert.doesNotMatch(create, /area_company_relations relation/);
assert.doesNotMatch(create, /inventory\.operator_company_id/);
assert.doesNotMatch(create, /asset\.operator_company_id/);
});
test('F4 checklist classification is derived on read and never rewrites the persisted generation', () => {
const f4 = source('src/inspection-visits/f4-inspection-visits.service.ts');
const d55 = source('src/database/migrations/1789063200000-phase-d5-5-web-planning-checklist.ts');
assert.match(d55, /REVOKE UPDATE, DELETE ON TABLE[\s\S]*inspection_visit_checklist_items/);
assert.match(f4, /classifyChecklistView/);
assert.match(f4, /FROM inspection_findings finding/);
assert.doesNotMatch(f4, /UPDATE inspection_visit_checklist_items/);
assert.doesNotMatch(f4, /reclassifyCurrentChecklist/);
});
test('Field preparation is an Inspection stage, not a parallel sidebar module', () => {
const layout = source('../web-v2/src/layout/AppLayout.tsx');
const briefing = source('../web-v2/src/pages/FieldBriefingsPage.tsx');
const detail = source('../web-v2/src/pages/InspectionVisitDetailF61Page.tsx');
const app = source('../web-v2/src/app/App.tsx');
assert.doesNotMatch(layout, /label: 'Preparación de campo'/);
assert.match(briefing, /ETAPA DE LA INSPECCIÓN/);
assert.match(briefing, /inspectionId/);
assert.match(briefing, /Volver a la inspección/);
assert.match(detail, /ETAPA PREVIA AL CAMPO/);
assert.match(detail, /preparacion-campo\?inspectionId=/);
assert.match(app, /InspectionVisitDetailF61Page/);
});
@@ -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:/);
});

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