Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ae6c8ac8 | ||
|
|
efb1b2bdad | ||
|
|
b1d13653e8 | ||
|
|
b03010e67a | ||
|
|
7522a82df3 | ||
|
|
6f7d3358f5 | ||
|
|
91c50c84c2 | ||
|
|
3436fca82c | ||
|
|
bfea4fee09 | ||
|
|
20ffa9e4b3 | ||
|
|
509bc0cde1 |
@@ -1,39 +1,36 @@
|
||||
name: Android APK
|
||||
# F6.1: genera una APK debug verificable contra la API productiva F6.1.
|
||||
name: Android CI / RC
|
||||
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/f5-android-test'
|
||||
- 'feature/f2-2*'
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
- 'feature/f3-1*'
|
||||
- 'feature/f3-2*'
|
||||
- 'feature/f6-1*'
|
||||
- 'release/f6-1*'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/**'
|
||||
- '.github/workflows/android.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/auth/**'
|
||||
- 'api-v3/src/asset-master/**'
|
||||
- 'api-v3/src/inspection-visits/**'
|
||||
- 'api-v3/src/inspection-acts/**'
|
||||
- 'api-v3/src/inspection-findings/**'
|
||||
- 'api-v3/src/inspection-verifications/**'
|
||||
- 'api-v3/src/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dhv2-android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-debug-apk:
|
||||
android:
|
||||
name: Android · lint, tests, debug APK, release compile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -55,18 +52,92 @@ jobs:
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- name: Assemble debug
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
- name: Validate mobile security and identity contract
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
|
||||
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
|
||||
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
|
||||
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
|
||||
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
|
||||
|
||||
- name: Unit tests
|
||||
- name: Android lint
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:lintDebug
|
||||
|
||||
- name: Print complete lint failures
|
||||
if: failure()
|
||||
run: |
|
||||
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
|
||||
if [ -f "$report" ]; then
|
||||
echo '========== ANDROID LINT =========='
|
||||
cat "$report"
|
||||
fi
|
||||
|
||||
- name: Android unit tests
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:testDebugUnitTest
|
||||
|
||||
- name: Upload APK
|
||||
- name: Require real unit-test results
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
test_dir="android-app/app/build/test-results/testDebugUnitTest"
|
||||
test -d "$test_dir"
|
||||
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
|
||||
test "$total" -gt 0
|
||||
echo "Android unit tests discovered: $total"
|
||||
|
||||
- name: Assemble debug APK
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
|
||||
- name: Compile unsigned release variant
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleRelease
|
||||
|
||||
- name: Package RC artifact and checksum
|
||||
id: package
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
|
||||
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
|
||||
test -n "$version"
|
||||
test -n "$code"
|
||||
short_sha="${GITHUB_SHA::12}"
|
||||
mkdir -p android-app/dist
|
||||
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
|
||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||
sha256sum "$apk" > "${apk}.sha256"
|
||||
{
|
||||
echo "phase=F6.1"
|
||||
echo "version=$version"
|
||||
echo "versionCode=$code"
|
||||
echo "commit=$GITHUB_SHA"
|
||||
echo "artifact=$(basename "$apk")"
|
||||
echo "applicationId=com.korexlabs.dhinspeccion.debug"
|
||||
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
|
||||
echo "channel=DEBUG_RC"
|
||||
} > android-app/dist/release-metadata.txt
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$code" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload debug RC
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DH-Inspeccion-F6.1-0.15.0-debug
|
||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
|
||||
path: android-app/dist/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Android diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-diagnostics-${{ github.sha }}
|
||||
path: |
|
||||
android-app/app/build/reports/lint-results-debug.html
|
||||
android-app/app/build/reports/tests/testDebugUnitTest/**
|
||||
android-app/app/build/test-results/testDebugUnitTest/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
+21
-13
@@ -116,9 +116,9 @@ jobs:
|
||||
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||
|
||||
# F5.1 intentionally ends with zero operational/domain instances. The
|
||||
# technical family and finding masters remain, but territory preload,
|
||||
# imports, applicability links and old audits are deliberately gone.
|
||||
# F5.1 performs the historical clean start. F6.1 then seeds only the
|
||||
# presentation territory approved by DH: 7 Departamentos, 64 Áreas,
|
||||
# 230 Yacimientos and 12 real Empresas.
|
||||
docker compose --env-file .env.example exec -T db \
|
||||
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||
DO $$
|
||||
@@ -128,6 +128,7 @@ jobs:
|
||||
audits integer;
|
||||
applicability integer;
|
||||
territory_sources integer;
|
||||
presentation_sources integer;
|
||||
source_installations integer;
|
||||
source_subinstallations integer;
|
||||
source_findings integer;
|
||||
@@ -149,18 +150,18 @@ jobs:
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO domain_assets FROM assets;
|
||||
IF domain_assets <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets;
|
||||
IF domain_assets <> 313 THEN
|
||||
RAISE EXCEPTION 'F6.1 presentation preload must contain 313 Assets, got %', domain_assets;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO audits FROM audit_events;
|
||||
IF audits <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits;
|
||||
RAISE EXCEPTION 'F6.1 presentation preload must start with 0 audit events, got %', audits;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
|
||||
IF applicability <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability;
|
||||
RAISE EXCEPTION 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO territory_sources
|
||||
@@ -170,13 +171,20 @@ jobs:
|
||||
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO presentation_sources
|
||||
FROM source_documents
|
||||
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
|
||||
IF presentation_sources <> 1 THEN
|
||||
RAISE EXCEPTION 'F6.1 must contain exactly one presentation territory source, got %', presentation_sources;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
|
||||
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
|
||||
INTO source_installations,source_subinstallations
|
||||
FROM inventory_families
|
||||
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
|
||||
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
|
||||
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
|
||||
RAISE EXCEPTION 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO source_findings
|
||||
@@ -184,7 +192,7 @@ jobs:
|
||||
JOIN finding_categories category ON category.id=item.category_id
|
||||
WHERE lower(category.code)='f5model' AND item.is_active=true;
|
||||
IF source_findings <> 177 THEN
|
||||
RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings;
|
||||
RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO department_types
|
||||
@@ -205,13 +213,13 @@ jobs:
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
# F5.1 is intentionally one-way: production rollback is the PRE database
|
||||
# backup, not migration:revert. Prove instead that the completed chain is
|
||||
# idempotent and has no pending migration on a second run.
|
||||
# Both destructive cuts are intentionally one-way: production rollback is
|
||||
# the PRE database backup, not migration:revert. Prove the completed chain
|
||||
# is idempotent and has no pending migration on a second run.
|
||||
rerun_log="$(mktemp)"
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
|
||||
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
|
||||
echo "ERROR: F5.1 migration chain is not idempotent." >&2
|
||||
echo "ERROR: F6.1 migration chain is not idempotent." >&2
|
||||
cat "$rerun_log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Production dependency audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dhv2-production-audit-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · production dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- name: Reject high/critical runtime advisories
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
web:
|
||||
name: WEB · production dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- name: Reject high/critical runtime advisories
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
@@ -12,10 +12,12 @@ Repositorio del sistema DH Inspección V2.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -26,4 +28,4 @@ Repositorio del sistema DH Inspección V2.
|
||||
- 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 y el procedimiento de cambio seguro están en los manuales enlazados arriba.
|
||||
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# DH Inspección Android · release F6.1
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- Fase funcional: **F6.1**.
|
||||
- `versionName`: **0.15.1**.
|
||||
- `versionCode`: **23**.
|
||||
- Application ID release: `com.korexlabs.dhinspeccion`.
|
||||
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`.
|
||||
- Launcher de presentación: adaptación vectorial local inspirada en el escudo institucional publicado por Gobierno de Mendoza en `mendoza.gov.ar`, para reemplazar el ícono genérico del sistema.
|
||||
|
||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||
|
||||
## Barrera obligatoria
|
||||
|
||||
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
|
||||
|
||||
1. validación de identidad, HTTPS y políticas básicas del manifest;
|
||||
2. Android lint;
|
||||
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
|
||||
4. `assembleDebug`;
|
||||
5. `assembleRelease` para comprobar que la variante productiva compile;
|
||||
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
|
||||
|
||||
La barrera de lint exige además manejo explícito de la revocación de permisos de ubicación durante una captura GPS y declara la cámara como capacidad de hardware opcional, sin relajar permisos ni desactivar reglas globalmente.
|
||||
|
||||
El artefacto de CI contiene:
|
||||
|
||||
- APK debug;
|
||||
- archivo `.sha256`;
|
||||
- `release-metadata.txt` con fase, versión, versionCode, commit, applicationId, API base y canal.
|
||||
|
||||
## Firma release
|
||||
|
||||
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
|
||||
|
||||
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
|
||||
|
||||
## Criterio de distribución
|
||||
|
||||
Antes de distribuir una APK productiva:
|
||||
|
||||
- todas las barreras de CI del SHA exacto deben estar verdes;
|
||||
- comprobar certificado/huella de firma contra la versión histórica;
|
||||
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
|
||||
- ejecutar smoke funcional contra el entorno objetivo: login, lista de inspecciones, inicio, inventario, alta de campo, foto/GPS, Acta, Hallazgo y cierre;
|
||||
- registrar el SHA Git y SHA-256 de la APK distribuida.
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 22
|
||||
versionName = "0.15.0"
|
||||
versionCode = 23
|
||||
versionName = "0.15.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
@@ -77,4 +77,4 @@ dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_mendoza_launcher"
|
||||
android:roundIcon="@drawable/ic_mendoza_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DHInspeccion"
|
||||
|
||||
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
|
||||
@@ -727,16 +727,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) {
|
||||
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
} else {
|
||||
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) {
|
||||
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
} else {
|
||||
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
|
||||
@@ -389,13 +389,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
|
||||
@@ -431,13 +431,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
try {
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Launcher de presentación F6.1 inspirado en el escudo institucional publicado
|
||||
por Gobierno de Mendoza en mendoza.gov.ar. Se mantiene como vector local para
|
||||
evitar el ícono genérico del sistema y no depender de assets remotos.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- fondo institucional -->
|
||||
<path
|
||||
android:fillColor="#0B5FA5"
|
||||
android:pathData="M54,4 A50,50 0,1 0,54 104 A50,50 0,1 0,54 4" />
|
||||
|
||||
<!-- escudo exterior -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,13 C72,13 84,22 87,38 L83,65 C80,81 68,93 54,99 C40,93 28,81 25,65 L21,38 C24,22 36,13 54,13 Z" />
|
||||
|
||||
<!-- campo superior celeste -->
|
||||
<path
|
||||
android:fillColor="#22A9E0"
|
||||
android:pathData="M27,38 C30,27 39,21 54,21 C69,21 78,27 81,38 L78,59 L30,59 Z" />
|
||||
|
||||
<!-- campo inferior dorado -->
|
||||
<path
|
||||
android:fillColor="#C6A66A"
|
||||
android:pathData="M30,63 L78,63 L76,68 C73,80 65,89 54,94 C43,89 35,80 32,68 Z" />
|
||||
|
||||
<!-- gorro frigio simplificado -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M47,31 C52,26 61,25 67,28 C64,31 63,35 64,39 C58,37 52,37 46,40 C44,36 44,33 47,31 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,39 L57,39 L56,48 L52,48 Z" />
|
||||
|
||||
<!-- racimo de uva simplificado -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,69 C56,69 58,71 58,74 C58,77 56,79 53,79 C50,79 48,77 48,74 C48,71 50,69 53,69 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M45,73 C48,73 50,75 50,78 C50,81 48,83 45,83 C42,83 40,81 40,78 C40,75 42,73 45,73 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M61,73 C64,73 66,75 66,78 C66,81 64,83 61,83 C58,83 56,81 56,78 C56,75 58,73 61,73 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,80 C56,80 58,82 58,85 C58,88 56,90 53,90 C50,90 48,88 48,85 C48,82 50,80 53,80 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M52,67 C54,63 58,61 62,61 C60,65 57,68 53,70 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.korexlabs.dhinspeccion
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(23, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.15.1-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://"))
|
||||
}
|
||||
}
|
||||
Generated
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.20.0-2",
|
||||
"version": "0.29.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.20.0-2",
|
||||
"version": "0.29.0-1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -4166,9 +4166,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -4666,9 +4666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
||||
+5
-1
@@ -42,5 +42,9 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"multer": "2.3.0",
|
||||
"qs": "6.16.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* F5/F6 permits physical Inventory to belong to an Área without persisting a
|
||||
* Company snapshot. Company snapshots are optional creation/history metadata;
|
||||
* current ownership is resolved from area_company_relations.
|
||||
*
|
||||
* The F4 paired CHECK survived the trigger migration and contradicted that
|
||||
* model. Keep only the invariant that a Company snapshot cannot exist without
|
||||
* an Área.
|
||||
*/
|
||||
export class F61AreaOwnedOperationalContextCheck1790094500000 implements MigrationInterface {
|
||||
name='F61AreaOwnedOperationalContextCheck1790094500000';
|
||||
|
||||
public async up(queryRunner:QueryRunner):Promise<void>{
|
||||
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE assets
|
||||
ADD CONSTRAINT chk_assets_operational_assignment_pair
|
||||
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner:QueryRunner):Promise<void>{
|
||||
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE assets
|
||||
ADD CONSTRAINT chk_assets_operational_assignment_pair
|
||||
CHECK (
|
||||
(operational_area_id IS NULL AND operator_company_id IS NULL)
|
||||
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { F61_PRESENTATION_TERRITORY_SOURCE as SOURCE } from '../../reference-data/f6-1-presentation-territory-source';
|
||||
|
||||
type IdRow={id:string};
|
||||
const NO_OPERATOR='Sin Empresa Operadora';
|
||||
const DOC='DH-F6.1-PRESENTATION-TERRITORY-20260909';
|
||||
|
||||
const key=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
|
||||
const code=(prefix:string,value:string,length=12)=>
|
||||
`${prefix}-${createHash('sha1').update(value).digest('hex').slice(0,length).toUpperCase()}`;
|
||||
const uniqueBy=<T>(rows:T[],identity:(row:T)=>string)=>{
|
||||
const seen=new Set<string>(); return rows.filter((row)=>{const id=identity(row);if(seen.has(id))return false;seen.add(id);return true;});
|
||||
};
|
||||
|
||||
export class F61PresentationTerritoryReset1790094600000 implements MigrationInterface {
|
||||
name='F61PresentationTerritoryReset1790094600000';
|
||||
|
||||
public async up(q:QueryRunner):Promise<void>{
|
||||
const rows=SOURCE.rows;
|
||||
if(SOURCE.file!=='Tablas de yacimiento y areas(1).xlsx'||SOURCE.sheet!=='cr26e_tabla1'
|
||||
||SOURCE.sha256!=='afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb'||rows.length!==230)
|
||||
throw new Error('F6.1 presentation source contract mismatch');
|
||||
|
||||
const areas=uniqueBy(rows,(r)=>key(r.area));
|
||||
const pairs=uniqueBy(rows,(r)=>`${key(r.area)}|${key(r.yacimiento)}`);
|
||||
const departments=uniqueBy(rows,(r)=>key(r.departamento));
|
||||
const companies=[...new Set(rows.map((r)=>r.empresaOperadora.trim())
|
||||
.filter((name)=>name&&key(name)!==key(NO_OPERATOR)))].sort((a,b)=>a.localeCompare(b,'es'));
|
||||
if(areas.length!==64||pairs.length!==230||departments.length!==7||companies.length!==12
|
||||
||areas.filter((r)=>key(r.empresaOperadora)!==key(NO_OPERATOR)).length!==47)
|
||||
throw new Error('F6.1 presentation source cardinality mismatch');
|
||||
|
||||
const concessions=new Set(rows.map((r)=>r.tipoConcesion.trim()));
|
||||
if(concessions.size!==2||!concessions.has('Exploración')||!concessions.has('Explotación'))
|
||||
throw new Error('F6.1 presentation concession contract mismatch');
|
||||
for(const area of areas){
|
||||
const same=rows.filter((r)=>key(r.area)===key(area.area));
|
||||
for(const field of ['departamento','tipoConcesion','empresaOperadora'] as const)
|
||||
if(new Set(same.map((r)=>key(r[field]))).size!==1) throw new Error(`Conflicting ${field}: ${area.area}`);
|
||||
}
|
||||
|
||||
const companyType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`);
|
||||
const departmentType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true AND can_be_root=true LIMIT 1`);
|
||||
const areaType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
|
||||
const yacimientoType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
|
||||
const [rules]=await q.query(`
|
||||
SELECT COUNT(*)::integer AS total FROM asset_type_parent_rules rule
|
||||
JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||
WHERE (lower(child.code)='area' AND lower(parent.code)='departamento')
|
||||
OR (lower(child.code)='yacimiento' AND lower(parent.code)='area')`);
|
||||
if(Number(rules?.total)!==2) throw new Error('F6.1 canonical hierarchy rules are incomplete');
|
||||
|
||||
// Destructive clean start requested for the presentation. Users, RBAC,
|
||||
// technical families, Finding masters and system configuration are preserved.
|
||||
// Recovery is the automatic PRE database backup made by deploy-github.sh.
|
||||
await q.query('TRUNCATE TABLE assets CASCADE');
|
||||
await q.query('TRUNCATE TABLE administrative_departments CASCADE');
|
||||
await q.query('TRUNCATE TABLE source_documents CASCADE');
|
||||
await q.query('TRUNCATE TABLE audit_events');
|
||||
await q.query(`DO $$ DECLARE t text; BEGIN FOR t IN SELECT tablename FROM pg_tables
|
||||
WHERE schemaname=current_schema() AND tablename LIKE 'asset_import_%'
|
||||
LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE',t); END LOOP; END $$;`);
|
||||
|
||||
const [doc]=(await q.query(`INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes)
|
||||
VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id`,
|
||||
[DOC,SOURCE.file,`sha256:${SOURCE.sha256}`,`F6.1 · ${SOURCE.sheet} · ${rows.length} filas`])) as IdRow[];
|
||||
if(!doc?.id) throw new Error('Could not create F6.1 presentation source');
|
||||
|
||||
const companyIds=new Map<string,string>();
|
||||
for(const name of companies){
|
||||
const id=await this.asset(q,companyType,null,null,code('PRES-ORG',key(name)),name,
|
||||
'Empresa/Operadora del corte de presentación.',`F6.1:PRESENTATION:COMPANY:${code('SRC',key(name),10)}`,'Empresa');
|
||||
companyIds.set(key(name),id);
|
||||
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name)
|
||||
VALUES($1::uuid,$2::organization_kind,$3)`,[id,name.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',name]);
|
||||
await this.link(q,id,doc.id,'Empresa/Operadora');
|
||||
}
|
||||
|
||||
const departmentIds=new Map<string,string>();
|
||||
for(const row of departments.sort((a,b)=>a.departamento.localeCompare(b.departamento,'es'))){
|
||||
const id=await this.asset(q,departmentType,null,null,code('PRES-DEP',key(row.departamento)),row.departamento,
|
||||
'Departamento administrativo raíz F6.1.',`F6.1:PRESENTATION:DEPARTMENT:${code('SRC',key(row.departamento),10)}`,'Departamento');
|
||||
departmentIds.set(key(row.departamento),id); await this.link(q,id,doc.id,'Departamento');
|
||||
}
|
||||
|
||||
const areaIds=new Map<string,string>();
|
||||
for(const row of areas.sort((a,b)=>a.area.localeCompare(b.area,'es'))){
|
||||
const parent=departmentIds.get(key(row.departamento)); if(!parent)throw new Error(`Missing Departamento ${row.departamento}`);
|
||||
const id=await this.asset(q,areaType,parent,null,code('PRES-AREA',key(row.area)),row.area,
|
||||
`Área del Departamento ${row.departamento}.`,`F6.1:PRESENTATION:AREA:${code('SRC',key(row.area),10)}`,'Área');
|
||||
areaIds.set(key(row.area),id); await this.link(q,id,doc.id,`Área · ${row.departamento}`);
|
||||
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
|
||||
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
|
||||
id,row.tipoConcesion==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
|
||||
`${row.tipoConcesion} · ${row.area}`,doc.id,`F6.1 · ${SOURCE.file}`]);
|
||||
if(key(row.empresaOperadora)!==key(NO_OPERATOR)){
|
||||
const company=companyIds.get(key(row.empresaOperadora)); if(!company)throw new Error(`Missing Empresa ${row.empresaOperadora}`);
|
||||
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
|
||||
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,
|
||||
[id,company,doc.id,`F6.1 · operadora vigente según ${SOURCE.file}`]);
|
||||
}
|
||||
}
|
||||
|
||||
for(const row of pairs){
|
||||
const area=areaIds.get(key(row.area)); if(!area)throw new Error(`Missing Área ${row.area}`);
|
||||
const id=await this.asset(q,yacimientoType,area,area,code('PRES-YAC',`${key(row.area)}|${key(row.yacimiento)}`),
|
||||
row.yacimiento,`Yacimiento del Área ${row.area}.`,
|
||||
`F6.1:PRESENTATION:YAC:${code('SRC',`${key(row.area)}|${key(row.yacimiento)}`,12)}`,`${SOURCE.sheet} · fila ${row.sourceRow}`);
|
||||
await this.link(q,id,doc.id,`Yacimiento · ${row.area} · fila ${row.sourceRow}`);
|
||||
}
|
||||
|
||||
const [c]=await q.query(`SELECT
|
||||
(SELECT COUNT(*) FROM assets)::integer total_assets,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer yacimientos,
|
||||
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE')::integer legal_rights,
|
||||
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLORATION_PERMIT')::integer exploration_rights,
|
||||
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLOITATION_CONCESSION')::integer exploitation_rights,
|
||||
(SELECT COUNT(*) FROM area_company_relations WHERE relation_role='OPERATOR' AND valid_until IS NULL)::integer operators,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area'
|
||||
AND NOT EXISTS(SELECT 1 FROM area_company_relations r WHERE r.area_id=a.id AND r.relation_role='OPERATOR' AND r.valid_until IS NULL))::integer areas_without_operator,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN assets p ON p.id=a.parent_id
|
||||
LEFT JOIN asset_types pt ON pt.id=p.asset_type_id WHERE lower(t.code)='area' AND (p.id IS NULL OR lower(pt.code)<>'departamento'))::integer invalid_area_parents,
|
||||
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id LEFT JOIN assets a ON a.id=y.parent_id
|
||||
LEFT JOIN asset_types at ON at.id=a.asset_type_id WHERE lower(t.code)='yacimiento'
|
||||
AND (a.id IS NULL OR lower(at.code)<>'area' OR y.operational_area_id IS DISTINCT FROM a.id))::integer invalid_yacimiento_contexts,
|
||||
(SELECT COUNT(*) FROM asset_source_documents WHERE document_id=$1::uuid)::integer source_links,
|
||||
(SELECT COUNT(*) FROM source_documents WHERE document_number=$2)::integer source_documents`,[doc.id,DOC]);
|
||||
const expected:Record<string,number>={total_assets:313,companies:12,departments:7,areas:64,yacimientos:230,
|
||||
legal_rights:64,exploration_rights:18,exploitation_rights:46,operators:47,areas_without_operator:17,
|
||||
invalid_area_parents:0,invalid_yacimiento_contexts:0,source_links:313,source_documents:1};
|
||||
for(const [field,value] of Object.entries(expected))
|
||||
if(Number(c?.[field]??-1)!==value)throw new Error(`F6.1 seed verification failed: ${field}=${c?.[field]} expected=${value}`);
|
||||
}
|
||||
|
||||
public async down():Promise<void>{
|
||||
throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.');
|
||||
}
|
||||
|
||||
private async type(q:QueryRunner,sql:string):Promise<string>{
|
||||
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing F6.1 master type: ${sql}`); return rows[0].id;
|
||||
}
|
||||
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,assetCode:string,name:string,
|
||||
description:string,sourceReference:string,sourceNotes:string):Promise<string>{
|
||||
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
|
||||
code,name,description,information_status,operational_status,data_origin,source_name,source_reference,source_notes,is_inventory_instance)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,NULL,NULL,$4,$5,$6,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$7,$8,$9,false) RETURNING id`,
|
||||
[typeId,parentId,areaId,assetCode,name,description,SOURCE.file,sourceReference,sourceNotes])) as IdRow[];
|
||||
if(!row?.id)throw new Error(`Could not seed ${name}`); return row.id;
|
||||
}
|
||||
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
|
||||
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
|
||||
VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
|
||||
export interface F61PresentationTerritoryRow {
|
||||
sourceRow: number;
|
||||
yacimiento: string;
|
||||
area: string;
|
||||
departamento: string;
|
||||
tipoConcesion: string;
|
||||
empresaOperadora: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* F6.1 · fuente única del corte de presentación.
|
||||
* Archivo: Tablas de yacimiento y areas(1).xlsx
|
||||
* Hoja: cr26e_tabla1
|
||||
* SHA-256: afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb
|
||||
*
|
||||
* Las 230 filas se guardan comprimidas para versionar exactamente la fuente
|
||||
* aprobada sin depender del binario Excel durante CI o deploy.
|
||||
*/
|
||||
const SNAPSHOT_GZIP_BASE64 = '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';
|
||||
|
||||
export const F61_PRESENTATION_TERRITORY_SOURCE = {
|
||||
file: 'Tablas de yacimiento y areas(1).xlsx',
|
||||
sheet: 'cr26e_tabla1',
|
||||
sha256: 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb',
|
||||
rows: JSON.parse(
|
||||
gunzipSync(Buffer.from(SNAPSHOT_GZIP_BASE64, 'base64')).toString('utf8'),
|
||||
) as F61PresentationTerritoryRow[],
|
||||
} as const;
|
||||
@@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string {
|
||||
test('F6.1 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 22/);
|
||||
assert.match(gradle, /versionName = "0\.15\.0"/);
|
||||
assert.match(gradle, /versionCode = 23/);
|
||||
assert.match(gradle, /versionName = "0\.15\.1"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
@@ -25,4 +25,4 @@ test('F5/F6.1 field inventory exposes Other families as reviewable choices to An
|
||||
assert.match(service, /F5:SYSTEM:OTHER:%/);
|
||||
assert.match(service, /AS "isOther"/);
|
||||
assert.match(service, /isOtherFamily: family\.isOther/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('F6.1 DB check allows Área-only physical context and rejects Company without Área', () => {
|
||||
const migration=readFileSync(
|
||||
resolve(process.cwd(),'src/database/migrations/1790094500000-f6-1-area-owned-operational-context-check.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(migration,/CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/);
|
||||
assert.doesNotMatch(migration,/operational_area_id IS NULL OR operator_company_id IS NULL/);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8');
|
||||
const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8');
|
||||
const android = (path: string) => readFileSync(resolve(process.cwd(), '..', 'android-app', path), 'utf8');
|
||||
|
||||
test('F6.1 presentation metadata keeps the visible WEB version aligned with package metadata', () => {
|
||||
const pkg = JSON.parse(web('package.json')) as { version: string };
|
||||
const version = web('src/config/version.ts');
|
||||
const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1];
|
||||
|
||||
assert.equal(visibleVersion, pkg.version);
|
||||
assert.match(version, /APP_PHASE\s*=\s*'F6\.1 · Contexto operativo Área–Operadora consolidado'/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => {
|
||||
const app = web('src/app/App.tsx');
|
||||
const layout = web('src/layout/AppLayout.tsx');
|
||||
|
||||
assert.doesNotMatch(app, /relevamientos/i);
|
||||
assert.doesNotMatch(layout, /relevamientos/i);
|
||||
assert.match(layout, /label: 'Inspecciones'/);
|
||||
assert.match(layout, /label: 'Inventarios'/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps the complete Inspector profile and documentary-copy explanation visible', () => {
|
||||
const user = web('src/pages/UserDetailPage.tsx');
|
||||
const delivery = api('src/inspection-reports/inspection-document-delivery.service.ts');
|
||||
|
||||
for (const field of ['dni', 'phone', 'jobTitle', 'employeeNumber']) {
|
||||
assert.match(user, new RegExp(`name="${field}"`));
|
||||
}
|
||||
assert.match(user, /email es obligatorio para un Inspector/i);
|
||||
assert.match(user, /la documentación se enviará también/i);
|
||||
assert.match(delivery, /recipientKind:'INSPECTOR'/);
|
||||
assert.match(delivery, /documentKind:'ACT_PDF'/);
|
||||
assert.match(delivery, /documentKind:'REPORT_WORD'/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps Android start, Otro and chronological merge flows wired to the canonical API', () => {
|
||||
const mobileApi = android('app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt');
|
||||
const overview = android('app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt');
|
||||
const viewModel = android('app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt');
|
||||
|
||||
assert.match(mobileApi, /inspection-visits\/\{id\}\/start/);
|
||||
assert.match(mobileApi, /field-inventory\/\{assetId\}\/merge/);
|
||||
assert.match(overview, /"Iniciar inspección"/);
|
||||
assert.match(overview, /Otro \/ no catalogado/);
|
||||
assert.match(viewModel, /repository\.startVisit\(id\)/);
|
||||
assert.match(viewModel, /selectedFieldAsset = repository\.selectFieldAsset\(visitId, result\.canonical\.id\)/);
|
||||
assert.match(viewModel, /la historia de \$\{result\.source\.code\} permanece trazable/);
|
||||
});
|
||||
|
||||
test('F6.1 presentation keeps catalog merge append-only for emitted findings', () => {
|
||||
const merge = api('src/inspection-findings/finding-catalog-merge.service.ts');
|
||||
const resolver = api('src/inspection-findings/f3-finding-catalog-resolver.service.ts');
|
||||
|
||||
assert.match(merge, /los Hallazgos emitidos nunca se reescriben/);
|
||||
assert.match(merge, /historicalFindingsRewritten: false/);
|
||||
assert.match(merge, /historyPolicy: 'EMITTED_FINDINGS_PRESERVED'/);
|
||||
assert.match(resolver, /code: 'OTHER'/);
|
||||
assert.match(resolver, /label: 'OTROS'/);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { F61_PRESENTATION_TERRITORY_SOURCE } from '../../src/reference-data/f6-1-presentation-territory-source';
|
||||
|
||||
function key(value: string): string {
|
||||
return value.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
test('F6.1 presentation territory snapshot matches the approved spreadsheet cardinalities', () => {
|
||||
const source = F61_PRESENTATION_TERRITORY_SOURCE;
|
||||
const rows = source.rows;
|
||||
|
||||
assert.equal(source.file, 'Tablas de yacimiento y areas(1).xlsx');
|
||||
assert.equal(source.sheet, 'cr26e_tabla1');
|
||||
assert.equal(source.sha256, 'afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb');
|
||||
assert.equal(rows.length, 230);
|
||||
|
||||
const areas = new Map<string, typeof rows[number][]>();
|
||||
for (const row of rows) {
|
||||
const id = key(row.area);
|
||||
const current = areas.get(id) ?? [];
|
||||
current.push(row);
|
||||
areas.set(id, current);
|
||||
}
|
||||
|
||||
const pairs = new Set(rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
|
||||
const departments = new Set(rows.map((row) => key(row.departamento)));
|
||||
const companies = new Set(
|
||||
rows
|
||||
.map((row) => row.empresaOperadora)
|
||||
.filter((name) => key(name) !== key('Sin Empresa Operadora'))
|
||||
.map(key),
|
||||
);
|
||||
const areasWithoutOperator = [...areas.values()]
|
||||
.filter((sameArea) => key(sameArea[0]!.empresaOperadora) === key('Sin Empresa Operadora'));
|
||||
const concessions = new Set(rows.map((row) => row.tipoConcesion));
|
||||
|
||||
assert.equal(areas.size, 64);
|
||||
assert.equal(pairs.size, 230);
|
||||
assert.equal(departments.size, 7);
|
||||
assert.equal(companies.size, 12);
|
||||
assert.equal(areasWithoutOperator.length, 17);
|
||||
assert.deepEqual([...concessions].sort((a, b) => a.localeCompare(b, 'es')), ['Exploración', 'Explotación']);
|
||||
|
||||
for (const [area, sameArea] of areas) {
|
||||
assert.equal(new Set(sameArea.map((row) => key(row.departamento))).size, 1, `${area}: Departamento conflictivo`);
|
||||
assert.equal(new Set(sameArea.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión conflictiva`);
|
||||
assert.equal(new Set(sameArea.map((row) => key(row.empresaOperadora))).size, 1, `${area}: Operadora conflictiva`);
|
||||
}
|
||||
});
|
||||
|
||||
test('F6.1 presentation reset preserves system masters and enforces the physical hierarchy', () => {
|
||||
const migration = readFileSync(
|
||||
resolve(process.cwd(), 'src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(migration, /TRUNCATE TABLE assets CASCADE/);
|
||||
assert.match(migration, /TRUNCATE TABLE audit_events/);
|
||||
assert.doesNotMatch(migration, /TRUNCATE TABLE users/i);
|
||||
assert.doesNotMatch(migration, /TRUNCATE TABLE roles/i);
|
||||
assert.doesNotMatch(migration, /TRUNCATE TABLE permissions/i);
|
||||
assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/i);
|
||||
assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/i);
|
||||
|
||||
assert.match(migration, /area_company_relations/);
|
||||
assert.match(migration, /area_legal_rights/);
|
||||
assert.match(migration, /key\(row\.empresaOperadora\)!==key\(NO_OPERATOR\)/);
|
||||
for (const contract of [
|
||||
/total_assets:313/,
|
||||
/companies:12/,
|
||||
/departments:7/,
|
||||
/areas:64/,
|
||||
/yacimientos:230/,
|
||||
/legal_rights:64/,
|
||||
/operators:47/,
|
||||
/areas_without_operator:17/,
|
||||
]) assert.match(migration, contract);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
# DH Inspección V2 · corte de presentación F6.1
|
||||
|
||||
Este documento define el alcance verificable del corte F6.1 y el recorrido recomendado para una presentación funcional. No reemplaza al Manual de Usuario ni al Manual del Programador.
|
||||
|
||||
## Versiones del corte
|
||||
|
||||
- API: `0.29.0-1`.
|
||||
- WEB: `0.23.0-1`.
|
||||
- Android: `0.15.0` (`versionCode 22`).
|
||||
- Modelo funcional: **F6.1 · contexto operativo Área–Operadora consolidado**.
|
||||
|
||||
La versión visible en el footer WEB debe coincidir con `web-v2/package.json`; este contrato queda protegido por tests.
|
||||
|
||||
## Matriz de aceptación funcional
|
||||
|
||||
### Inventario
|
||||
|
||||
- Jerarquía física vigente: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
|
||||
- Empresa permanece fuera del árbol físico.
|
||||
- La Operadora se resuelve por relación temporal vigente con el Área.
|
||||
- Instalación y Subinstalación admiten familia **Otro / no catalogado** cuando corresponda.
|
||||
- Un alta de campo conserva inspección, actor, fecha, contexto, GPS y evidencia exigida por el flujo.
|
||||
|
||||
> El issue histórico F3.1 describía un árbol que comenzaba en Área. Esa definición fue supersedida por F5.1/F6, que incorporó Departamento como raíz territorial autorizada.
|
||||
|
||||
### Merge cronológico de Inventario
|
||||
|
||||
- Sólo se fusionan Instalaciones/Subinstalaciones compatibles.
|
||||
- El registro duplicado no se elimina: queda fusionado/inactivo y conserva identidad histórica.
|
||||
- Las referencias históricas emitidas no se reescriben.
|
||||
- El dossier canónico agrega cronológicamente alias y eventos, mostrando el Inventario original cuando corresponde.
|
||||
- WEB muestra el destino canónico.
|
||||
- Android, al conciliar un alta nacida en campo, selecciona inmediatamente el canónico y confirma qué código se conserva.
|
||||
|
||||
### Hallazgos y catálogo
|
||||
|
||||
- `OTROS` permanece disponible en el resolver de Hallazgos.
|
||||
- La fusión de catálogo mueve aplicabilidad futura al canónico sin reescribir Hallazgos ya emitidos.
|
||||
- Las instancias históricas conservan el modelo append-only y la trazabilidad de auditoría.
|
||||
|
||||
### Inspecciones y Android
|
||||
|
||||
- Una inspección planificada puede iniciarse desde Android por un Inspector autorizado y asignado.
|
||||
- El inicio registra `actualStartedAt`, actor y auditoría del servidor.
|
||||
- El contexto operativo es Área + Operadora vigente.
|
||||
- Una Inspección puede tener múltiples Actas, con un único borrador simultáneo.
|
||||
- El cierre exige Actas selladas y verificaciones requeridas completas.
|
||||
|
||||
### Perfil del Inspector
|
||||
|
||||
- Nombre y apellido obligatorios.
|
||||
- Datos administrativos disponibles: DNI, email, teléfono, cargo/función y legajo/matrícula.
|
||||
- El email es obligatorio para rol Inspector.
|
||||
|
||||
### Entrega documental
|
||||
|
||||
- El Acta PDF se prepara para Empresa, Oficina e Inspector principal.
|
||||
- El INF Word se prepara también para el Inspector principal cuando existe.
|
||||
- El outbox conserva destinatario, estado, intentos, error y fecha de envío; los pendientes son reintentables.
|
||||
- La entrega depende de SMTP y destinatarios configurados: si faltan, el registro queda en un estado de espera auditable en lugar de perderse.
|
||||
|
||||
### Relevamientos
|
||||
|
||||
- `Relevamientos` no existe como módulo/ruta/entrada de menú activa.
|
||||
- La captura en campo vive dentro de Inspecciones + Inventario de campo.
|
||||
|
||||
## Seguridad de dependencias del corte
|
||||
|
||||
La candidata mantiene una barrera separada para dependencias productivas de API y WEB mediante `npm audit --omit=dev --audit-level=high`.
|
||||
|
||||
Para F6.1 se fijaron las resoluciones parcheadas que eliminan los advisories detectados durante el cierre:
|
||||
|
||||
- API: `multer 2.3.0` y `qs 6.16.0`.
|
||||
- WEB: `maplibre-gl 6.4.1`.
|
||||
|
||||
Las dependencias de runtime con vulnerabilidades altas o críticas bloquean el corte aunque typecheck, tests y builds estén verdes.
|
||||
|
||||
## Barrera técnica obligatoria
|
||||
|
||||
Un SHA sólo es candidato de presentación cuando pasan:
|
||||
|
||||
1. API typecheck, tests y build.
|
||||
2. WEB typecheck y build.
|
||||
3. auditoría de dependencias productivas de API y WEB, sin advisories altos/críticos.
|
||||
4. migraciones sobre PostGIS limpio.
|
||||
5. arranque real de API.
|
||||
6. preflight aislado equivalente al entorno Docker.
|
||||
7. build de imágenes productivas.
|
||||
8. Android lint.
|
||||
9. Android unit tests reales.
|
||||
10. Android `assembleDebug`.
|
||||
11. Android `assembleRelease`.
|
||||
12. APK debug con SHA-256 y metadata del SHA exacto.
|
||||
|
||||
## Recorrido sugerido de demo
|
||||
|
||||
1. **Login WEB** y Dashboard.
|
||||
2. **Inventarios**: navegar Departamento → Área → Yacimiento → Instalación → Subinstalación y abrir un dossier.
|
||||
3. Mostrar la **cronología/dossier**, documentos, fotos y Hallazgos.
|
||||
4. Mostrar, si existe un caso preparado, una **fusión cronológica** y el aviso de registro canónico.
|
||||
5. **Usuarios**: abrir un Inspector y mostrar perfil, email documental y roles.
|
||||
6. **Inspecciones**: crear/abrir una planificación con Área + Operadora, Inspector y checklist.
|
||||
7. **Android**: ingresar con Inspector, abrir o seleccionar la Inspección e iniciarla.
|
||||
8. En Android, abrir **Inventario de campo**, seleccionar un registro o crear uno nuevo con GPS y foto. Para una familia no catalogada, usar **Otro / no catalogado**.
|
||||
9. Crear un **Acta**, registrar un **Hallazgo** (incluyendo `OTROS` si se quiere demostrar el fallback), adjuntar evidencia y sellar el Acta.
|
||||
10. Cerrar la Inspección cuando todas las Actas estén selladas.
|
||||
11. En WEB, mostrar **Actas / Informes / Entrega documental** y el estado auditable de los envíos.
|
||||
|
||||
## Preparación del entorno de presentación
|
||||
|
||||
Antes de una demo con envío real de correo, verificar en Administración:
|
||||
|
||||
- email institucional de Oficina;
|
||||
- email de la Empresa involucrada;
|
||||
- email del Inspector principal;
|
||||
- SMTP configurado y operativo.
|
||||
|
||||
Para una demo sin correo saliente, el resto del flujo puede demostrarse y la bandeja de entrega debe reflejar el estado de espera correspondiente; no se debe presentar un envío como exitoso si el transporte no está configurado.
|
||||
|
||||
## Evidencia del corte
|
||||
|
||||
El SHA presentado debe conservarse junto con:
|
||||
|
||||
- resultado verde de GitHub Actions;
|
||||
- auditoría productiva verde;
|
||||
- metadata de versiones;
|
||||
- APK debug de la candidata y su SHA-256;
|
||||
- si se distribuye APK productiva, firma histórica verificada y SHA-256 del artefacto firmado.
|
||||
Generated
+6
-6
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.20.0-2",
|
||||
"version": "0.23.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-web",
|
||||
"version": "0.20.0-2",
|
||||
"version": "0.23.0-1",
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^6.0.0",
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^8.0.0"
|
||||
@@ -1633,9 +1633,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.3.0.tgz",
|
||||
"integrity": "sha512-F0Is48MTzn3DvOEidPjh68E0kuSA7hdzY1YIR0ypPtFgcif3WPtzI1oZFgsv9WtHmarQnbwIXfsf+EfQYvM00A==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.4.1.tgz",
|
||||
"integrity": "sha512-KzxQKtfBu/pSz1C+yW1hNS9eyj2h2lC7ufdAi6/SEt177n3oAfDfmUmslRfJdXY7ReAFBcnvwsqmiyoDhtA9GQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^6.0.0",
|
||||
"maplibre-gl": "6.4.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router": "^8.0.0"
|
||||
@@ -24,4 +24,4 @@
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.21.0-1';
|
||||
export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado';
|
||||
export const APP_VERSION = '0.23.0-1';
|
||||
export const APP_PHASE = 'F6.1 · Contexto operativo Área–Operadora consolidado';
|
||||
|
||||
Reference in New Issue
Block a user